Check access
Gate server-side features on a person's perks with the Go library.
client.Persons.Entitlements 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
HasActivePerk returns (bool, error). This net/http handler gates an export on premium:
package main
import (
"encoding/json"
"net/http"
voidhash "github.com/voidhashcom/voidhash-go"
)
func exportReport(client *voidhash.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := currentUserID(r) // the user ID your app passes to identify()
hasPremium, err := client.Persons.Entitlements.HasActivePerk(r.Context(), voidhash.HasActivePerkParams{
DistinctID: userID,
PerkSlug: "premium",
})
if err != nil {
http.Error(w, "could not check access", http.StatusBadGateway)
return
}
if !hasPremium {
http.Error(w, `{"error":"premium_required"}`, http.StatusPaymentRequired)
return
}
json.NewEncoder(w).Encode(buildExport(userID))
}
}Setting both or neither of PerkSlug and PerkID returns an *APIError before any request.
HasActivePerk returns false for a distinct ID Voidhash has never seen and for a slug that
matches no perk. Authentication, permission, server and network failures return an error, never
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
GrantsByDistinctID returns voidhash.EntitlementGrant values with PerkId, PerkSlug, Source,
SourceId, SourcePersonId and ExpiresAt. Optional fields are pointers:
grants, err := client.Persons.Entitlements.GrantsByDistinctID(ctx, "user_123")
if err != nil {
return err
}
for _, grant := range grants {
if grant.PerkSlug != nil && *grant.PerkSlug == "premium" {
fmt.Println(grant.Source, grant.ExpiresAt) // subscription 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 SourcePersonId 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".
Handle errors
API failures are an *APIError, with Code, Type and StatusCode; match it with
errors.As:
package main
import (
"context"
"errors"
"fmt"
voidhash "github.com/voidhashcom/voidhash-go"
)
// premiumUntil reports whether the person has premium, and until when.
// A nil expiry with ok true means premium doesn't expire.
func premiumUntil(ctx context.Context, client *voidhash.Client, distinctID string) (expiresAt *string, ok bool, err error) {
grants, err := client.Persons.Entitlements.GrantsByDistinctID(ctx, distinctID)
var apiErr *voidhash.APIError
switch {
case errors.As(err, &apiErr) && apiErr.Code == "person_not_found":
return nil, false, nil // Never identified, so nothing was bought.
case errors.As(err, &apiErr) && (apiErr.Type == "unauthenticated" || apiErr.Type == "permission_denied"):
return nil, false, fmt.Errorf("the Voidhash secret key is invalid or lacks access: %w", err)
case err != nil:
return nil, false, err // Network or server error: the answer is unknown, not "no".
}
for _, grant := range grants {
if grant.PerkSlug != nil && *grant.PerkSlug == "premium" {
return grant.ExpiresAt, true, nil
}
}
return nil, false, nil
}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
HasActivePerk and GrantsByDistinctID 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 that isn't an
*APIError, or one withTypeinternalorunavailable, 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.