This is a conceptual guide: prose, not a runnable console. For the live endpoints, code samples, and a test console, see API reference → Entitlements.
Signed entitlement tokens (§11)
What this is. A way to check what a customer is allowed to use without calling PayKit on every check. You resolve a customer's entitlements once, get back a short-lived signed token (a JWT), cache it, and verify it locally (inside your own service) each time you gate a feature.
Why bother. The direct Read entitlements API is simpler, but it's a network round-trip every time. If you gate features on a hot path (every request, every page load), a cached token removes that round-trip while staying trustworthy: the token is signed with our private key, so you can read it but never forge one.
Rule of thumb: use the direct read when checks are occasional; reach for tokens when the per-check latency of an API call would hurt.
It's three steps: mint a token on your backend, fetch our public keys once, then verify locally on every check.
1. Mint a token (from your backend)
Call this server-side; it needs the customers.read permission, so never run it from
the browser:
POST /v1/clients/{clientId}/projects/{projectId}/customers/{externalRef}/entitlement-tokens
Authorization: Bearer <caller token> # customers.read
Content-Type: application/json
{ "ttlSeconds": 300 } # optional · default 300 · max 3600
You get back the token plus everything you need to cache and verify it:
{
"token": "<jws>",
"tokenType": "Bearer",
"alg": "ES256",
"kid": "sA-bqfjk…",
"expiresAt": "2026-07-10T12:05:00.000Z",
"entitlements": [
{ "featureCode": "seats", "kind": "limit", "boolValue": null, "limitValue": 10 }
],
"addons": [],
"balances": {},
"addonsTruncated": false
}
Mint one token per customer and cache it until expiresAt, then mint a fresh one.
Keep the TTL short: a token can't be revoked, so it stays valid until it expires.
2. Fetch the public keys (once, then cache)
Verification uses our public keys, published as a JWKS. Fetch them once and cache them; they rarely change:
GET /.well-known/jwks.json # public · no auth · Cache-Control: max-age=300
The response is { "keys": [ … ] }. Every token's header carries a kid (key id);
match it to the JWK with the same kid. Keys can rotate, so if you ever see a kid you
don't have, refresh the JWKS and try again.
3. Verify locally on every check
Now each feature check is local, with no PayKit call. Verify the token's signature with
the matching public key, then confirm it hasn't expired (exp), was issued by us
(iss = paykit), and was meant for you (aud = your clientId). Then read
entitlements.
Any JWKS-aware JWT library does all of that in one call. With
jose:
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Create this once and reuse it — it fetches and caches the JWKS for you.
const JWKS = createRemoteJWKSet(new URL('https://api.paykit.co.za/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'paykit',
audience: clientId,
});
// payload.entitlements → [{ featureCode, kind, boolValue, limitValue }]
const seats = payload.entitlements.find((e) => e.featureCode === 'seats');
if (!seats || seats.limitValue < wanted) denyAccess();
The token is a plain JWS (ES256 for an EC key, RS256 for RSA), so you're not tied to
jose; any verifier that supports JWKS + ES256/RS256 works.
What's inside a token
The claims you can read once a token is verified:
| Claim | Meaning |
|---|---|
iss | paykit, the token's issuer |
aud / cid | your client id (dash-less) |
sub / ref | the customer's externalRef |
pid | project id (dash-less) |
iat / exp | issued-at / expiry (unix seconds) |
entitlements | resolved entitlements, same shape as the read API |
addons | active add-on grants that grant no features and carry no balance, capped at 50 |
balances | map of add-on code to the units left (a string) on the grants that carry a balance |
addonsTruncated | true when more than 50 such grants existed and addons holds only the first 50. Fall back to the direct read |
Metered entries
A feature priced by a metered price appears in entitlements as a metered entry:
{
"featureCode": "bandwidth_bytes",
"kind": "metered",
"boolValue": null,
"limitValue": "26843545600",
"includedQuantity": "26843545600",
"usedQuantity": "1288490188",
"periodStart": "2026-09-30T22:00:00.000Z",
"periodEnd": "2026-10-31T22:00:00.000Z",
"unitLabel": "GiB"
}
limitValue repeats includedQuantity. usedQuantity in a token is the value at the
moment the token was issued, so it ages as the token does: do not use a cached token as a
live meter. Use the direct read for a current figure. Going over includedQuantity is
overage, billed when the period is rated, and does not remove access. Quantities are
strings, so compare them as big integers.
Operator configuration
For the platform operator setting this up (not app integrators):
ENTITLEMENT_JWT_PRIVATE_KEY_REF: a secret reference (env:NAMElocally, Secret Manager in prod) pointing to a PEM signing key. The algorithm follows the key type: EC P-256 → ES256, RSA → RS256. While it's unset, minting returns501and the JWKS is empty.- Key rotation. The
kidis the key's RFC 7638 JWK thumbprint. To rotate, swap in a new signing key and keep the old public key in the JWKS until every outstanding token has expired (only a few minutes, given the short TTLs).