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.

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.

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