VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
Node.js library
Check access from your backend
Receive webhooks
Custom payment providers

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.

server/routes/export.ts
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.list) to look the slug up. 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, other not_found, 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. They are every perk the customer can use now, across all pages.

const grants = await voidhash.entitlements.getGrantsByDistinctId({
  distinctId: "user_123",
});

Each grant has these fields.

FieldTypeDescription
perkIdstringThe perk this grant is for. Match it against the perk you care about.
perkSlugstring | nullThe perk's slug.
expiresAtstring | nullAn ISO timestamp. null means the grant never expires.
source"subscription" | "purchase" | "manual"How the customer obtained the grant.
sourceIdstring | nullThe subscription or purchase behind the grant.
sourcePersonIdstringThe 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.

The same lookup is available as two separate calls, which helps when you already hold the person's id. An unknown distinctId is an empty page here, not an error.

const { data } = await voidhash.persons.list({ params: { distinctId: "user_123" } });
const person = data[0];

const entitlements = await voidhash.persons.listEntitlements({
  params: { personId: person.id },
});

Person is intentionally thin. It carries id, distinctId, email, name and isIdentified, because it identifies the customer. It does not describe what they paid for.

Handle errors deliberately

When an API call rejects, getVoidhashApiError(error) reads the API's error body with the response's status: { type, code, message, param, status, requestId }. Branch on type and code, never on message. requestId, the response's request-id header, identifies the request when you contact support. Transport failures (DNS, TLS, timeouts) reject with an Effect HttpClientError instead, for which it returns undefined.

The example below treats an unknown person as "no access", treats a bad key as your own bug, and rethrows everything else.

import { getVoidhashApiError } from "@voidhash/node";

try {
  const grants = await voidhash.entitlements.getGrantsByDistinctId({
    distinctId: "user_123",
  });

  return grants.some((grant) => grant.perkId === premiumPerkId);
} catch (error) {
  const apiError = getVoidhashApiError(error);
  if (apiError?.code === "person_not_found") {
    // Never identified from a client: nothing was ever bought.
    return false;
  }
  if (apiError?.type === "unauthenticated" || apiError?.type === "permission_denied") {
    // 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.");
  }
  throw error;
}

The types you are most likely to see are unauthenticated (401), permission_denied (403), not_found (404, such as person_not_found) and invalid_request (400, with param naming the field).

Live and development data

Grants are scoped to an environment. The server picks the scope from the x-environment request header.

x-environmentGrants returned
absent or productionReal purchases, from store production and, by default, store sandbox.
developmentOnly simulated purchases made by an SDK in a debug build.
allBoth of the above.

Sandbox purchases count as production so that TestFlight, Google Play testers, and App Review get access in your published app. A project can turn that off. Then no key or x-environment value returns store sandbox grants.

The SDK never sends this header for you, so a plain client always reads production 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.

Next steps

  • Receive webhooks
  • Persons in the API reference
PreviousNode.js libraryNextReceive webhooks

On this page

Check a perkRead the grants yourselfHandle errors deliberatelyLive and development dataCaching and failuresNext steps