VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Groups
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Rust library
Check access
Receive webhooks
Persons and events
Custom payment providers

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:

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

src/access.rs
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 whose type_ is internal or unavailable, 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.

PreviousRust libraryNextReceive webhooks

On this page

Check a perkRead the grantsHandle errorsEnvironmentsCaching and failuresNext steps