Entitlements
Gate features from purchases and subscriptions. Entitlements are derived on the fly from the benefits a customer's paid purchases and active subscriptions grant — there is no entitlement table.
Entitlements answer one question your app asks all the time: does this customer
currently have access to feature X? You define benefits in the
catalogue, attach them to
products, sell those products through
subscriptions and
one-time purchases, and then read back a
flattened list of the benefits a customer holds — keyed by a stable lookupKey
you gate on.
Entitlements are derived, not stored
There is no entitlement table. Every read derives the answer on the fly, per request, by walking the customer's entitling subscriptions and paid purchases down to their active benefits. Nothing is written when you read; nothing goes stale.
Each entitlement is benefit-shaped — it carries only the benefit's
lookupKey, display name, and optional metadata. It never leaks the
subscription, invoice, or price the derivation walked to produce it.
Because the answer is computed, not cached, it always reflects the current state of the customer's subscriptions, purchases, and benefit activation — you never reconcile a denormalised copy.
Where entitlements come from
The active set is the union of two sources, both scoped to the authenticating app and mode:
- Entitling subscriptions — every subscription in status
TRIALINGorACTIVE. Each grants the benefits of the product it bills. - Paid one-time purchases — every invoice that is
PAID, carries aprice_id, and has nosubscription_id. Theprice_idresolves to its product, which grants its benefits.
Each distinct product's active benefits are gathered, then flattened and deduplicated.
Derivation rules
| Rule | Behaviour |
|---|---|
Dedupe by lookup_key | If two products grant the same lookupKey, it appears once. First occurrence wins. |
Only benefit.active gates | A deactivated benefit is excluded — even when it came from a paid purchase. Product status and price-active flags are not re-checked. |
| Paid purchases are durable | Once an invoice reaches PAID, its entitlements persist; the derivation never re-checks whether the price is still active or the product still published. |
| Deterministic order | The list is sorted by lookupKey ascending, so repeated reads and tests see a stable order. |
The single active flag that affects a derived entitlement is benefit.active.
Archiving a product or a price does not revoke entitlements already granted —
to remove a capability across the board, deactivate the benefit in the
benefit catalogue.
List a customer's entitlements
GET /api/v1/entitlements returns every active entitlement the customer holds.
This is an app-authenticated read. Send your API key and secret; the
mode (TEST or LIVE) is derived from the API credential that authenticated the
request — never from the request body. Every read is
scoped to your app and mode, so one app never sees another's customers
(app isolation).
Every endpoint returns the standard envelope —
{ data, meta, pagination } (see
Responses & errors). The rest of this page
shows just data.
Query parameters
| Field | Type | Required | Notes |
|---|---|---|---|
customerReference | string (query) | Yes | Your app's external customer id. An unknown id returns an empty list — never a 404. Omitting it is a 400. |
EntitlementView (response item)
| Field | Type | Required | Notes |
|---|---|---|---|
lookupKey | string | Always | The app's stable gating handle. Sort key for the list (ascending). |
name | string | Always | The benefit's display name. |
metadata | string | null | Optional | The benefit's raw jsonb value echoed verbatim as a JSON string (e.g. "{\"seats\": 10}"), or null when the benefit has none. It is never interpreted or expanded into a nested object. |
curl -X GET "http://localhost:8080/api/v1/entitlements?customerReference=cust_88f2a1" \
-H "X-Api-Key: oi_test_4f3c2b1a09d8e7f6" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6g7h8"A customer with a paid purchase and an active subscription:
{
"data": [
{ "lookupKey": "priority_support", "name": "Priority support", "metadata": null },
{ "lookupKey": "pro_reports", "name": "Pro reports", "metadata": null },
{ "lookupKey": "team_seats", "name": "Team seats", "metadata": "{\"seats\": 10}" }
]
}The list is not paginated — pagination is null and data is the full set.
Unknown customers return an empty list
A customerReference your app has never transacted against is not an error — it
simply holds no entitlements:
{
"data": []
}Check a single entitlement
GET /api/v1/entitlements/check is the fast gate: pass a customerReference and
a lookupKey and get a yes/no answer plus an echo of what you asked.
Query parameters
| Field | Type | Required | Notes |
|---|---|---|---|
customerReference | string (query) | Yes | Your app's external customer id. Unknown id ⇒ entitled = false. |
lookupKey | string (query) | Yes | The benefit gating handle to test. A key the customer doesn't hold ⇒ entitled = false. |
EntitlementCheckDto (response)
| Field | Type | Required | Notes |
|---|---|---|---|
entitled | boolean | Always | Whether the customer holds the entitlement keyed by lookupKey. |
lookupKey | string | Always | Echoes the lookupKey asked about, so you can correlate the answer. |
customerReference | string | Always | Echoes the customerReference asked about. |
curl -X GET "http://localhost:8080/api/v1/entitlements/check?customerReference=cust_88f2a1&lookupKey=team_seats" \
-H "X-Api-Key: oi_test_4f3c2b1a09d8e7f6" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6g7h8"{
"data": { "entitled": true, "lookupKey": "team_seats", "customerReference": "cust_88f2a1" }
}An unknown customer, or a benefit the customer doesn't hold, reads
entitled = false — never a 404:
{
"data": { "entitled": false, "lookupKey": "enterprise_sso", "customerReference": "cust_88f2a1" }
}The check is a full-derivation membership test: under the hood it derives
the customer's entire active set and then tests whether your lookupKey is in
it. It does the same work as the list — treat it as an ergonomic single-question
call, not a cheaper or short-circuiting path. Don't rely on it being faster than
fetching the list.
List vs. check
Use the list
Rendering an account or settings page, or syncing all of a customer's capabilities at once. One call returns everything.
Use the check
Gating one feature on a single lookupKey at a decision point. One call returns a clean boolean plus an echo to correlate.
Staying in sync
You usually want to cache the gate result in your app and refresh it when it
changes, rather than calling on every request. The service emits a
subscription.entitlements_updated webhook whenever a
customer's derived active set changes, carrying the customer's current active
lookup keys in its entitlements array.
The active set changes in exactly two situations:
A transition that does not cross the boundary fires no webhook — for example
TRIALING → ACTIVE (both are entitling), so the derived set is unchanged. Listen
for subscription.entitlements_updated, then re-read the list (or the relevant
check) to refresh your cache.
The webhook is delivered per app and mode like every other event, and its
payload carries the app's external customer_reference and the current
entitlements (the active lookup keys). See
Webhooks for the envelope, signature verification, and
delivery semantics.
Worked example: a purchase plus a subscription
Sell the products
Customer cust_88f2a1 buys a Lifetime Pro product through a
one-time purchase (granting the pro_reports
benefit) and separately starts a Team Plan
subscription on a recurring price (granting
team_seats with metadata {"seats": 10} and priority_support).
Both settle
The purchase invoice reaches PAID; the subscription is ACTIVE. Each settlement
that crosses an entitling boundary publishes subscription.entitlements_updated,
so your app knows to refresh.
Read the merged set
A single list call returns the union, deduped by lookup_key and sorted by
lookupKey ascending:
{
"data": [
{ "lookupKey": "priority_support", "name": "Priority support", "metadata": null },
{ "lookupKey": "pro_reports", "name": "Pro reports", "metadata": null },
{ "lookupKey": "team_seats", "name": "Team seats", "metadata": "{\"seats\": 10}" }
]
}The pro_reports entitlement is durable — even if you later archive the
Lifetime Pro price, cust_88f2a1 keeps it. Only deactivating the pro_reports
benefit removes it.
Gate a feature
To unlock the seat-management UI, check the one key you care about:
curl -X GET "http://localhost:8080/api/v1/entitlements/check?customerReference=cust_88f2a1&lookupKey=team_seats" \
-H "X-Api-Key: oi_test_4f3c2b1a09d8e7f6" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6g7h8"{
"data": { "entitled": true, "lookupKey": "team_seats", "customerReference": "cust_88f2a1" }
}Notes
- Auth and scope. Both endpoints require your
X-Api-Key+X-Api-Secret. The mode (TESTorLIVE) is derived from the API credential that authenticated the request — never from the request body — and every read is scoped to your app and that mode. A test key never sees live entitlements, and vice versa. - Reads, not mutations. These are
GETs; there is nothing to make idempotent, so noIdempotency-Keyapplies. metadatais rawjsonb, echoed as a string. It is stored as-is and returned verbatim as a JSON string (e.g."{\"seats\": 10}"); the service never interprets, merges, or expands it into a nested object. Parse it yourself if you need the structured value.- Never a 404 for an unknown customer. Unknown
customerReference⇒ empty list (/entitlements) orentitled = false(/entitlements/check).
Products & prices
Define products and the benefits they grant.
Benefits (admin)
Author benefits and their lookup keys; activate or deactivate them.
One-time purchases
Sell a product outright — a paid purchase grants durable entitlements.
Subscriptions
Recurring access — TRIALING and ACTIVE subscriptions entitle.
Webhooks
React to subscription.entitlements_updated and refresh your cache.
Catalog & subscriptions
How products, prices, benefits, and subscriptions fit together.
Sell a one-time product
Charge once for a product through a ONE_TIME price — it issues an invoice with the bought price pinned and a payable link settled by the v1 payment flow, then grants entitlements when paid.
Invoicing
Create itemized invoices, issue them with a per-app number, mint a hosted payable link, brand the PDF, and track installments through to paid.