Check access from your backend
Gate server-side features on a customer's active perk grants.
Use the Node.js library to decide, on your server, whether a customer may use a feature. The check asks Voidhash for the customer's active perk grants, so nothing a customer reaches through your API depends on a client-side decision. Set up the Node.js library with a secret key first.
Check a perk
hasActivePerk answers one question: does this customer hold an active grant for this perk? The
handler below gates an export route on the premium perk.
app.get("/reports/export", async (req, res) => {
const hasPremium = await voidhash.entitlements.hasActivePerk({
distinctId: req.user.id,
perkSlug: "premium",
});
if (!hasPremium) {
return res.status(402).json({ error: "premium_required" });
}
return res.json(await buildExport(req.user.id));
});distinctId is the same identifier your app passed to
identify(). Pass exactly one of perkId or perkSlug. If
you pass both or neither, the call rejects with VoidhashNodeConfigurationError before any request
is made.
Checking by perkSlug is convenient, but it costs one extra request (perks.listPerks) to resolve
the slug to an id. On a hot path, look the id up once when your server starts and pass perkId
instead.
An unknown customer has no access
hasActivePerk returns false when Voidhash has never seen the distinctId, and when the
perkSlug matches no perk. It does not hide authentication, authorization, 5xx, or transport
failures. Those reject, so a mistyped secret key can never look like "nobody has premium".
Read the grants yourself
hasActivePerk only returns a boolean. When you need to render an account page, show an expiry
date, or branch on where access came from, read the grants directly.
const grants = await voidhash.entitlements.getGrantsByDistinctId({
distinctId: "user_123",
});Each grant has these fields.
| Field | Type | Description |
|---|---|---|
perkId | string | The perk this grant is for. Match it against the perk you care about. |
status | "active" | "expired" | Only active grants confer access. |
expiresAt | string | null | An ISO timestamp. null means the grant never expires. |
source | "subscription" | "purchase" | "manual" | How the customer obtained the grant. |
sourceId | string | null | The subscription or purchase behind the grant. |
sourcePersonId | string | The person who holds the source. On shared plans this differs from the person you asked about. |
Unlike hasActivePerk, this call treats an unknown distinctId as an error. That leaves the
decision to you: whether "never seen" and "seen, but bought nothing" should mean the same thing.
If you already hold a personId, the same lookup is available as two separate calls.
const person = await voidhash.persons.getPersonByDistinctId({
params: { distinctId: "user_123" },
});
const { grants } = await voidhash.persons.getPersonEntitlements({
params: { personId: person.personId },
});Person is intentionally thin. It carries personId, distinctId, email, and name, because
it identifies the customer. It does not describe what they paid for.
Handle errors deliberately
When an API call rejects, the error carries the decoded server error on error.data. Its _tag
field is stable, so you can switch on it. Transport failures (DNS, TLS, timeouts) reject with an
Effect HttpClientError instead, which has no data.
The example below treats an unknown person as "no access", treats a bad key as your own bug, and rethrows everything else.
const serverTag = (error: unknown): string | undefined =>
(error as { data?: { _tag?: string } } | null)?.data?._tag;
try {
const grants = await voidhash.entitlements.getGrantsByDistinctId({
distinctId: "user_123",
});
return grants.some((grant) => grant.perkId === premiumPerkId && grant.status === "active");
} catch (error) {
switch (serverTag(error)) {
case "Api/PersonNotFoundError":
// Never identified from a client: nothing was ever bought.
return false;
case "Api/NotAuthenticatedError":
case "Api/ActionForbiddenError":
// Our key is wrong. Our bug, not the customer's. Do not lock them out.
throw new Error("Voidhash secret key is invalid or lacks access.");
default:
throw error;
}
}The tags you are most likely to see are Api/NotAuthenticatedError (401),
Api/ActionForbiddenError (403), Api/PersonNotFoundError (404),
Api/WebhookEndpointNotFoundError (404), and Api/WebhookValidationError (400).
Live and development data
Grants are scoped to an environment. The server picks the scope from the x-environment request
header.
x-environment | Grants returned |
|---|---|
absent or production | Real purchases, from both store production and store sandbox. |
development | Only simulated purchases made by an SDK in a debug build. |
all | Both of the above. |
The SDK never sends this header for you, so a plain client always reads production and sandbox grants. To read development purchases, set the header on a separate client.
const voidhashDevelopment = createVoidhashSdk({
secretKey: process.env.VOIDHASH_SECRET_KEY!,
headers: { "x-environment": "development" },
});Secret keys are not scoped to an environment. Which key you use does not change the answer. Only the header does.
Any other value silently means production
An unrecognized x-environment value falls back to production instead of failing. Never wire
the header directly to a variable like NODE_ENV. A value of "test" would quietly read
production grants.
Caching and failures
The SDK does not cache, retry, or de-duplicate. Every call is a live HTTP round trip.
If you check access on every request, cache the result yourself for a short window and refresh it in the background. 60 seconds is a reasonable starting point.
When a call fails with a transport error or a 5xx, treat the answer as unknown, not as no
access. Serve the last known good value, or fail the request. Revoking a paying customer's access
because of a network blip is worse than serving a slightly stale cache.
To keep the cache warm without polling, subscribe to webhooks and invalidate the cached entry when a subscription or purchase event arrives for that person.