Check access from your backend
Gate server-side features on a customer's active perk grants.
Use the Go 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 Go library with a secret key first.
Check a perk
HasActivePerk answers one question: does this customer hold an active grant for this perk? The
handler below gates an export route on the premium perk.
func exportHandler(client *voidhash.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hasPremium, err := client.Persons.Entitlements.HasActivePerk(r.Context(), voidhash.HasActivePerkParams{
DistinctID: userIDFrom(r),
PerkSlug: "premium",
})
if err != nil {
http.Error(w, "upstream error", http.StatusInternalServerError)
return
}
if !hasPremium {
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]string{"error": "premium_required"})
return
}
buildExport(w, r, userIDFrom(r))
}
}DistinctID is the same identifier your app passed to
identify(). Set exactly one of PerkID or PerkSlug. If
you set both or neither, the call returns an error before any request is made.
HasActivePerk returns false when Voidhash has never seen the DistinctID, and when the
PerkSlug matches no perk. It does not hide authentication, authorization, 5xx, or transport
failures. Those return an error, so a mistyped secret key can never look like "nobody has
premium".
Read the grants yourself
HasActivePerk 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. They are every perk the
customer can use now.
grants, err := client.Persons.Entitlements.GrantsByDistinctID(ctx, "user_123")Each grant has these fields.
| Field | Type | Description |
|---|---|---|
PerkId | string | The perk this grant is for. Match it against the perk you care about. |
PerkSlug | *string | The perk's slug. |
ExpiresAt | *string | An ISO timestamp. nil means the grant never expires. |
Source | "subscription"|"purchase"|"manual" | How the customer obtained the grant. |
SourceId | *string | The subscription or purchase behind the grant. nil when there is no backing source. |
SourcePersonId | string | The person who holds the source. On shared plans this differs from the person you asked about. |
Unlike HasActivePerk, this call treats an unknown DistinctID as an error. That leaves the
decision to you: whether "never seen" and "seen, but bought nothing" should mean the same thing.
To look the person up yourself, use Persons.GetByDistinctID:
person, err := client.Persons.GetByDistinctID(ctx, "user_123")Person is intentionally thin. It carries Id, DistinctId, Email, Name and IsIdentified,
because it identifies the customer. It does not describe what they paid for.
Handle errors deliberately
When the API rejects a call, the error is a *voidhash.APIError. It carries the HTTP status and
the API's error body: branch on Type and Code, never on Message.
Param names the field at fault, and RequestID is the response's Request-Id header, which
identifies the request when you contact support. Transport failures (DNS, TLS, timeouts) return a
plain error that wraps *url.Error instead.
The example below treats an unknown person as "no access", treats a bad key as your own bug, returns every other API error as is, and treats a transport failure as an unknown answer.
grants, err := client.Persons.Entitlements.GrantsByDistinctID(ctx, "user_123")
if err != nil {
var apiErr *voidhash.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.Code == "person_not_found":
// Never identified from a client: nothing was ever bought.
return false, nil
case apiErr.Type == "unauthenticated", apiErr.Type == "permission_denied":
// Our key is wrong. Our bug, not the customer's. Do not lock them out.
return false, fmt.Errorf("voidhash secret key is invalid or lacks access: %w", apiErr)
default:
return false, err
}
}
// Transport failure: unknown, not "no access".
// Serve the last known good value or fail the request.
return false, err
}
hasPremium := false
for _, grant := range grants {
if grant.PerkId == premiumPerkID {
hasPremium = true
}
}The types you are most likely to see are unauthenticated (401), permission_denied (403),
not_found (404, such as person_not_found) and invalid_request (400, with Param naming the
field).
Production and test data
The secret key decides which grants you get. Every key belongs to production or test for its whole life.
| Secret key | Grants returned |
|---|---|
Production (vh_sk_…) | Production purchases, plus store sandbox purchases while they unlock production apps (default). |
Test (vh_sk_test_…) | Store sandbox and test purchases, such as Stripe test mode. Never production purchases. |
Store sandbox purchases count for production keys so that TestFlight, Google Play testers, and App Review get access in your published app. A project can turn that off. Then a production key returns no store sandbox grants. Test purchases never unlock production.
Use a test secret key in staging and tests, and a production key in production. Test grants include the simulated purchases of debug builds in development mode, which use the test publishable key.
The request always reads the key's own environment, and an x-environment header is ignored. A
production key never returns test grants. Choose the key per deployment.
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 single-flight wrapper
(golang.org/x/sync/singleflight) in front of the cache collapses concurrent checks for the same
person into one request.
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.