Check access
Gate server-side features on a person's perks with the Node.js library.
voidhash.entitlements asks Voidhash which perks a person can use
right now, on a configured client. Run it before you serve
anything paid that a person can reach without your app: an export endpoint, a paid AI model, a
download.
The client SDKs check access in the app to decide what to show, but anyone can call your API directly, so gate server-side resources here. Both read the same entitlements, so they agree.
Check a perk
hasActivePerk resolves with a boolean. This Express route gates an export on premium:
import express from "express";
import { voidhash } from "../voidhash";
export const router = express.Router();
router.get("/reports/export", async (req, res) => {
const userId = currentUserId(req); // the user ID your app passes to identify()
const hasPremium = await voidhash.entitlements.hasActivePerk({
distinctId: userId,
perkSlug: "premium",
});
if (!hasPremium) {
return res.status(402).json({ error: "premium_required" });
}
res.json(await buildExport(userId));
});Passing both or neither of perkSlug and perkId rejects with VoidhashNodeConfigurationError
before any request. A slug costs one extra request to look up the perk, and needs read access to
perks, so on a hot path pass perkId.
hasActivePerk resolves false for a distinct ID Voidhash has never seen and for a slug that
matches no perk. Authentication, permission, server and network failures reject, never resolve
false, so a wrong key can't look like "nobody has access". The key needs read access to persons.
Read the grants
getGrantsByDistinctId returns VoidhashEntitlementGrant objects with perkId, perkSlug,
source, sourceId, sourcePersonId and expiresAt:
const grants = await voidhash.entitlements.getGrantsByDistinctId({ distinctId: "user_123" });
const premium = grants.find((grant) => grant.perkSlug === "premium");
console.log(premium?.source, premium?.expiresAt); // "subscription" "2026-11-20T09:12:44.000Z"Every grant returned is active now, one per perk, across every page of results. source is
subscription or purchase, and sourcePersonId can name a person
merged into this one. The schema also allows the source manual, a
fallback label Voidhash doesn't create today; treat it like any other active grant. Reading the
grants fails with the code person_not_found for a distinct ID Voidhash has never seen, so you can
tell "never seen" from "seen, bought nothing".
When you already hold the person's ID, call
voidhash.persons.listEntitlements({ params: { personId } }).
Handle errors
getVoidhashApiError reads the code and type from a rejection:
import { getVoidhashApiError } from "@voidhash/node";
import { voidhash } from "./voidhash";
export async function premiumUntil(distinctId: string): Promise<string | null | false> {
try {
const grants = await voidhash.entitlements.getGrantsByDistinctId({ distinctId });
const premium = grants.find((grant) => grant.perkSlug === "premium");
return premium ? premium.expiresAt : false;
} catch (error) {
const apiError = getVoidhashApiError(error);
if (apiError?.code === "person_not_found") {
return false; // Never identified, so nothing was bought.
}
if (apiError?.type === "unauthenticated" || apiError?.type === "permission_denied") {
throw new Error("The Voidhash secret key is invalid or lacks access.");
}
throw error; // Network or server error: the answer is unknown, not "no".
}
}Environments
The secret key the client uses decides which purchases count. A production key (vh_sk_…) sees
production purchases, plus store sandbox purchases while they
unlock production apps.
A test key (vh_sk_test_…) sees store sandbox, simulated and test mode purchases, never production
ones. No option changes this, so give each deployment its own key: test on staging, production in
production.
Caching and failures
hasActivePerk and getGrantsByDistinctId make live requests; the library doesn't cache. On a hot
path:
- Cache the answer per person for a short time, such as 60 seconds.
- Clear a person's cached answer when a webhook for them arrives, so renewals, refunds and expirations apply at once.
- Treat a rejection that isn't an API error, or one with type
internalorunavailable, as unknown, not as no access. Serve the last known answer, or fail the request and let the client retry. Locking out a paying person because of a timeout is worse than a slightly stale answer.
Next steps
- Receive webhooks to clear cached answers when access changes.
- Persons and events
- Subscription lifecycle: when access starts and ends.
Something wrong or missing on this page? Tell us by email or on Discord.