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<Arc<VoidhashClient>>,
Json(claims): Json<Claims>,
) -> Result<Json<Value>, ApiError> {
let has_premium = voidhash
.persons()
.has_active_perk(&claims.sub, None, Some("premium"))
.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(). Pass exactly one of the perk's id or slug.
Passing both or neither returns voidhash::Error::Request before any request is made.
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. They are every perk the
customer can use now.
let grants = voidhash
.persons()
.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. |
perk_slug | Option<String> | The perk's slug. |
expires_at | Option<String> | An ISO timestamp. None means the grant never expires. |
source | 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.
The same lookup is available as two separate calls, which helps when you already hold the person's id.
let person = voidhash.persons().get_by_distinct_id("user_123").await?;
let entitlements = voidhash.persons().entitlements(&person.id).await?;Person is intentionally thin. It carries id, distinct_id, email, name and
is_identified, because it identifies the customer. It does not describe what they paid for.
Handle errors deliberately
Every failure is a voidhash::Error, an enum with three variants.
Api { status, type_, code, message, param, request_id }: the API rejected the call with its error body. Match ontype_andcode, never onmessage.paramnames the field at fault, andrequest_ididentifies the request when you contact support.Transport(reqwest::Error): a DNS, TLS, or timeout failure. No response body exists.Request(String): anything else, such as a client setup that is invalid or cannot choose a project.
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.persons().grants_by_distinct_id("user_123").await {
Ok(grants) => Ok(grants.iter().any(|grant| grant.perk_id == premium_perk_id)),
Err(voidhash::Error::Api { code, .. }) if code == "person_not_found" =>
// Never identified from a client: nothing was ever bought.
Ok(false),
Err(voidhash::Error::Api { type_, .. })
if type_ == "unauthenticated" || type_ == "permission_denied" =>
// 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(e @ voidhash::Error::Transport(_)) =>
// Unknown, not "no access": serve the last known good value or fail the request.
Err(e.into()),
Err(e) => Err(e.into()),
}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-environment | Grants returned |
|---|---|
absent or production | Real purchases, from store production and, by default, store sandbox. |
development | Only simulated purchases made by an SDK in a debug build. |
all | Both 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 library never sends this header for you, so a plain client always reads production grants. To read development purchases, build a second client that sets the header.
let voidhash_development = VoidhashClient::builder()
.secret_key(secret_key)
.header("x-environment", "development")
.build()?;Secret keys are not scoped to an environment. Which key you use does not change the answer. Only the header does.
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.