Check access from your backend
Gate server-side features on a customer's active perk grants.
Use the Rust 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 Rust library with a secret key first.
Check a perk
has_active_perk answers one question: does this customer hold an active grant for this perk? The
handler below gates an export route on the premium perk.
async fn export(
State(voidhash): State<VoidhashClient>,
Json(claims): Json<Claims>,
) -> Result<Json<Value>, ApiError> {
let has_premium = voidhash
.entitlements
.has_active_perk(&voidhash::HasActivePerk {
distinct_id: claims.sub.clone(),
perk: voidhash::PerkSelector::Slug("premium".into()),
})
.await?;
if !has_premium {
return Err(ApiError::PaymentRequired("premium_required"));
}
let export = build_export(&claims.sub).await?;
Ok(Json(export))
}distinct_id is the same identifier your app passed to
identify(). The perk field takes exactly one selector,
either PerkSelector::Id or PerkSelector::Slug. Passing both or neither returns
VoidhashError::Configuration before any request is made.
Checking by Slug is convenient, but it costs one extra request (perks.list_perks) to resolve the
slug to an id. On a hot path, look the id up once when your server starts and pass Id instead.
An unknown customer has no access
has_active_perk returns false when Voidhash has never seen the distinct_id, and when the
slug matches no perk. It does not hide authentication, authorization, 5xx, or transport
failures. Those return Err, so a mistyped secret key can never look like "nobody has premium".
Read the grants yourself
has_active_perk 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.
let grants = voidhash
.entitlements
.get_grants_by_distinct_id("user_123")
.await?;Each grant has these fields.
| Field | Type | Description |
|---|---|---|
perk_id | String | The perk this grant is for. Match it against the perk you care about. |
status | GrantStatus::Active | GrantStatus::Expired | Only Active grants confer access. |
expires_at | Option<DateTime<Utc>> | None means the grant never expires. |
source | GrantSource::Subscription | Purchase | Manual | How the customer obtained the grant. |
source_id | Option<String> | The subscription or purchase behind the grant. |
source_person_id | String | The person who holds the source. On shared plans this differs from the person you asked about. |
Unlike has_active_perk, this call treats an unknown distinct_id 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 person_id, the same lookup is available as two separate calls.
let person = voidhash
.persons
.get_person_by_distinct_id("user_123")
.await?;
let entitlements = voidhash
.persons
.get_person_entitlements(&person.person_id)
.await?;Person is intentionally thin. It carries person_id, distinct_id, email, and name, because
it identifies the customer. It does not describe what they paid for.
Handle errors deliberately
Every failure is a VoidhashError, an enum with three variants.
Configuration(String): the client setup is invalid. This is returned before any request is made.Api { status, tag, message }: the server rejected the call.tagis the stable error_tag, so you can match on it.Transport(Arc<dyn Error>): a DNS, TLS, or timeout failure. No response body exists.
The example below treats an unknown person as "no access", treats a bad key as your own bug, propagates every other API error, and treats a transport failure as an unknown answer.
match voidhash.entitlements.get_grants_by_distinct_id("user_123").await {
Ok(grants) => Ok(grants.iter().any(|grant| {
grant.perk_id == premium_perk_id && grant.status == GrantStatus::Active
})),
Err(VoidhashError::Api { tag, .. }) => match tag.as_str() {
"Api/PersonNotFoundError" =>
// Never identified from a client: nothing was ever bought.
Ok(false),
"Api/NotAuthenticatedError" | "Api/ActionForbiddenError" =>
// Our key is wrong. Our bug, not the customer's. Do not lock them out.
Err(anyhow!("Voidhash secret key is invalid or lacks access.")),
_ => Err(anyhow!(tag)),
},
Err(e @ VoidhashError::Transport(_)) =>
// Unknown, not "no access": serve the last known good value or fail the request.
Err(e.into()),
Err(e) => Err(e.into()),
}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 library never sends this header for you, so a plain client always reads production and sandbox grants. To read development purchases, build a second client that sets the header.
let voidhash_development = VoidhashClient::new(secret_key)?
.header("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 PROFILE. A value of "test" would quietly read
production grants.
Caching and failures
The library 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. A moka or quick-cache instance
keyed by distinct_id works well. Keep the TTL short enough that a revoked grant expires from the
cache quickly.
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.