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.

src/routes/export.rs
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.

FieldTypeDescription
perk_idStringThe perk this grant is for. Match it against the perk you care about.
statusGrantStatus::Active | GrantStatus::ExpiredOnly Active grants confer access.
expires_atOption<DateTime<Utc>>None means the grant never expires.
sourceGrantSource::Subscription | Purchase | ManualHow the customer obtained the grant.
source_idOption<String>The subscription or purchase behind the grant.
source_person_idStringThe 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. tag is 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-environmentGrants returned
absent or productionReal purchases, from both store production and store sandbox.
developmentOnly simulated purchases made by an SDK in a debug build.
allBoth 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.

Next steps