Check access
Gate server-side features on a person's perks with the Rust library.
voidhash.persons() 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
has_active_perk takes the distinct ID, a perk ID and a perk slug, and returns
Result<bool, voidhash::Error>. This axum handler gates an export on premium:
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use voidhash::VoidhashClient;
use crate::auth::CurrentUser;
use crate::reports::{build_export, Report};
pub async fn export_report(
State(voidhash): State<Arc<VoidhashClient>>,
user: CurrentUser, // the user ID your app passes to identify()
) -> Result<Json<Report>, StatusCode> {
let has_premium = voidhash
.persons()
.has_active_perk(&user.id, None, Some("premium"))
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
if !has_premium {
return Err(StatusCode::PAYMENT_REQUIRED);
}
Ok(Json(build_export(&user.id).await))
}Pass None for the selector you don't use. Both or neither returns Error::Request before any
request.
has_active_perk returns Ok(false) for a distinct ID Voidhash has never seen and for a slug
that matches no perk. Authentication, permission, server and network failures return Err, never
Ok(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
grants_by_distinct_id returns grants with perk_id, perk_slug, source, source_id,
source_person_id and expires_at:
let grants = voidhash.persons().grants_by_distinct_id("user_123").await?;
if let Some(premium) = grants.iter().find(|grant| grant.perk_slug.as_deref() == Some("premium")) {
println!("{} {:?}", premium.source, premium.expires_at); // subscription Some("2026-11-20T09:12:44.000Z")
}Every grant returned is active now, one per perk, across every page of results. source is
subscription or purchase, and source_person_id 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().entitlements("person_k2v9x4m8q1r3").
Handle errors
API failures are Error::Api, with status, type_ and code; match on them:
use voidhash::VoidhashClient;
pub enum Premium {
Until(Option<String>), // None: doesn't expire
No,
}
pub async fn premium(voidhash: &VoidhashClient, distinct_id: &str) -> Result<Premium, voidhash::Error> {
let grants = match voidhash.persons().grants_by_distinct_id(distinct_id).await {
Ok(grants) => grants,
// Never identified, so nothing was bought.
Err(voidhash::Error::Api { code, .. }) if code == "person_not_found" => return Ok(Premium::No),
// A bad key, a network or a server error: the answer is unknown, not "no".
Err(error) => return Err(error),
};
Ok(grants
.into_iter()
.find(|grant| grant.perk_slug.as_deref() == Some("premium"))
.map_or(Premium::No, |grant| Premium::Until(grant.expires_at)))
}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
has_active_perk and grants_by_distinct_id 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 an error other than
Error::Api, or one whosetype_isinternalorunavailable, 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.