Check access
Gate server-side features on a person's perks with the PHP library.
$voidhash->persons->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 returns a bool. This Laravel route gates an export on premium:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Voidhash\VoidhashClient;
Route::get('/reports/export', function (Request $request, VoidhashClient $voidhash) {
$hasPremium = $voidhash->persons->entitlements->hasActivePerk([
'distinctId' => (string) $request->user()->id, // the user ID your app passes to identify()
'perkSlug' => 'premium',
]);
if (! $hasPremium) {
return response()->json(['error' => 'premium_required'], 402);
}
return response()->json(buildExport($request->user()));
})->middleware('auth');Passing both or neither of perkSlug and perkId throws an ApiException with the code
configuration_error before any request.
hasActivePerk returns false for a distinct ID Voidhash has never seen and for a slug that
matches no perk. Authentication, permission, server and network failures throw, never return
false, so a wrong key can't look like "nobody has access". The key needs read access to persons.
A slug and an ID cost the same: both are compared against the person's entitlements.
Read the grants
grantsByDistinctId returns grants with getPerkId(), getPerkSlug(), getSource(),
getSourceId(), getSourcePersonId() and getExpiresAt():
$grants = $voidhash->persons->entitlements->grantsByDistinctId('user_123');
foreach ($grants as $grant) {
if ($grant->getPerkSlug() === 'premium') {
echo $grant->getSource(), ' ', $grant->getExpiresAt(); // subscription 2026-11-20T09:12:44.000Z
}
}Every grant returned is active now, one per perk, across every page of results. getSource() is
subscription or purchase, and getSourcePersonId() 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->getEntitlements('person_k2v9x4m8q1r3').
Handle errors
API failures throw ApiException, with getErrorCode() and getType():
use Voidhash\Exception\ApiException;
use Voidhash\VoidhashClient;
/** The premium expiry, null for no expiry, or false without access. */
function premiumUntil(VoidhashClient $voidhash, string $distinctId): string|null|false
{
try {
$grants = $voidhash->persons->entitlements->grantsByDistinctId($distinctId);
} catch (ApiException $error) {
if ($error->getErrorCode() === 'person_not_found') {
return false; // Never identified, so nothing was bought.
}
if (in_array($error->getType(), ['unauthenticated', 'permission_denied'], true)) {
throw new RuntimeException('The Voidhash secret key is invalid or lacks access.', previous: $error);
}
throw $error; // Server error: the answer is unknown, not "no".
}
foreach ($grants as $grant) {
if ($grant->getPerkSlug() === 'premium') {
return $grant->getExpiresAt();
}
}
return false;
}Network failures throw a PSR-18 ClientExceptionInterface; treat them as unknown too.
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 grantsByDistinctId 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 PSR-18
ClientExceptionInterface, or anApiExceptionwith typeinternalorunavailable, 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.