Check access
Gate features using the current customer's active perk grants.
Use the current person's active perk grants to decide what a customer can access in your app. The person snapshot also carries subscription state and purchase history, but perk grants are what you gate on.
The SDK decides what to show on the device. It cannot protect anything a customer can reach by calling your API directly. Those checks belong on your server. See Check access from your backend.
Gate a feature
A perk is available when the person holds a grant for it with status active. The built-in
useHasPerk hook takes the perk slug configured in Studio and checks whether the current customer
holds such a grant:
const { hasAccess, isLoading } = voidhash.useHasPerk("premium");
if (isLoading) return null;
return hasAccess ? <PremiumContent /> : <UpgradePrompt />;The hook returns these fields.
| Field | Meaning |
|---|---|
hasAccess | true when the person holds an active grant for the perk. |
grant | The active grant behind hasAccess, or null. |
isLoading | true while the SDK initializes and until the hook's first check has answered. |
isStale | true while the server has not confirmed hasAccess recently, or there is no snapshot. |
error | Set only when the check had nothing to answer with, such as AUTHENTICATION_FAILED. |
refetch | A function that forces a refresh. |
Network failures never set error: the hook keeps answering from the cached snapshot and sets
isStale until a refresh lands.
Outside React, the client exposes the same check as an async call. Like every client method, it
answers a Result:
const result = await voidhash.client.hasPerk("premium");
if (result.isOk()) {
const { hasAccess, isStale } = result.value;
}Gate on perks, not subscription status
Subscription status cannot tell you which features a product unlocks. It also misses access that comes from one-time purchases or manual grants. Active perk grants are the source of truth for access control.
Offline and failure behavior
A failed refresh, whether the device is offline or the server returned an error, is not proof that the customer has no access. Neither check fails because of the network. Both keep answering from the evidence they already have rather than answering a confident "no":
- If a cached snapshot shows an active grant,
hasAccessstaystrueandisStaleis set. The SDK keeps the snapshot when a request fails or when something between the app and Voidhash, such as a proxy, answers instead of the API. - If there is no cached evidence of access,
hasAccessisfalseandisStaleis set.client.hasPerkanswersOkwithreason: "no-cache": there is no evidence either way, which is not the same as a denial. - If the publishable key was rejected and nothing is cached, the check fails once with
AUTHENTICATION_FAILED, and the hook reports it inerror.
Choose a fallback that fits each feature. hasAccess: false with isStale: true means the server
has not confirmed the answer, for example because the device is offline and nothing cached shows
access. This example keeps serving cached access and offers a retry instead of an upgrade prompt
when it could not confirm a "no":
const { hasAccess, isLoading, isStale, refetch } = voidhash.useHasPerk("premium");
if (isLoading) return null;
// Cached access keeps working while offline.
if (hasAccess) return <PremiumContent />;
if (isStale) return <AccessUnconfirmed onRetry={refetch} />;
return <UpgradePrompt />;client.hasPerk also reports how far to trust its answer.
| Field | Meaning |
|---|---|
reason | Why the answer has the freshness it has, described below. |
isStale | true when the server has not confirmed the answer recently. |
isExpired | true when the cached snapshot is past its lifetime, so access from it is a best guess rather than a recent confirmation. |
reason takes one of four values:
fresh: the server confirmed the answer, during this check or within the last five minutes.refresh-in-flight: the answer comes from the cache while a refresh is still running. The refresh lands for the next check, and mounted hooks re-render with it.refresh-failed: the answer comes from the cache because the refresh ended without a result, for example because the device is offline.no-cache: the SDK has never seen a snapshot for this customer, sohasAccess: falsemeans "no evidence yet", not "denied".
Use no-cache to tell a missing answer apart from a denial:
const result = await voidhash.client.hasPerk("premium");
if (result.isOk() && result.value.reason === "no-cache") {
// Nothing cached and the server could not be reached. Offer a retry.
}Sometimes you need fresh confirmation, for example before unlocking a consumable. Pass
{ forceFetch: true } so the check waits for the server, and act only on an answer the server
confirmed:
const result = await voidhash.client.hasPerk("premium", { forceFetch: true });
if (result.isOk() && result.value.reason === "fresh" && result.value.hasAccess) {
unlockConsumable();
} else {
// Could not confirm access right now.
}Read the current person
When you need more than a single perk check, read the full snapshot:
const { data: person, error, isLoading, refetch } = voidhash.useCurrentPerson();data is the person snapshot. It is null until the first snapshot loads, and while the client is
disabled. See the SDK reference for the full shape.
For advanced cases you can still filter the grants directly. Prefer useHasPerk for gating:
const premiumGrant = person?.entitlements.grants.find(
(grant) => grant.perkSlug === "premium" && grant.status === "active",
);Grant fields
Each entry in entitlements.grants has these fields.
| Field | Meaning |
|---|---|
perkSlug | The perk slug configured in Studio. Absent when the perk has been deleted. |
perkId | The perk's internal ID. Compare slugs through perkSlug, not this field. |
status | active or expired. |
source | subscription, purchase, or manual. |
sourceId | The subscription, purchase, or manual grant that created it. |
expiresAt | Expiration time, or null for access without an expiry. |
Use subscriptions.current for account UI, such as the current plan or its renewal state. Use
purchases.history when you need to show past transactions.
Refresh behavior
The SDK refreshes the person at launch, when the app returns to the foreground, and after
purchases, restores, and identity changes. Between refreshes, reads are cache-first: a snapshot
younger than five minutes answers immediately, without a request. An older one starts a refresh and
answers from cache if the refresh takes longer than half a second. When a refresh lands,
useHasPerk and useCurrentPerson re-render with it, whichever call started it. After identify()
or reset(), both check again for the new person without remounting.
The server's answer that it has no person for the customer yet, which is common for anonymous
customers, is cached the same way, so those checks do not wait on the network every time. Calling
identify(), setting person attributes, or reporting a purchase makes the next check ask again.
When you need a network round trip right away, force one:
await voidhash.client.getCurrentPerson({ forceFetch: true });
// Or re-run the hook's request:
refetch();