OI Payments Docs
Guides

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:

  1. Entitling subscriptions — every subscription in status TRIALING or ACTIVE. Each grants the benefits of the product it bills.
  2. Paid one-time purchases — every invoice that is PAID, carries a price_id, and has no subscription_id. The price_id resolves to its product, which grants its benefits.

Each distinct product's active benefits are gathered, then flattened and deduplicated.

Derivation rules

RuleBehaviour
Dedupe by lookup_keyIf two products grant the same lookupKey, it appears once. First occurrence wins.
Only benefit.active gatesA 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 durableOnce an invoice reaches PAID, its entitlements persist; the derivation never re-checks whether the price is still active or the product still published.
Deterministic orderThe 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

FieldTypeRequiredNotes
customerReferencestring (query)YesYour app's external customer id. An unknown id returns an empty list — never a 404. Omitting it is a 400.

EntitlementView (response item)

FieldTypeRequiredNotes
lookupKeystringAlwaysThe app's stable gating handle. Sort key for the list (ascending).
namestringAlwaysThe benefit's display name.
metadatastring | nullOptionalThe 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

FieldTypeRequiredNotes
customerReferencestring (query)YesYour app's external customer id. Unknown id ⇒ entitled = false.
lookupKeystring (query)YesThe benefit gating handle to test. A key the customer doesn't hold ⇒ entitled = false.

EntitlementCheckDto (response)

FieldTypeRequiredNotes
entitledbooleanAlwaysWhether the customer holds the entitlement keyed by lookupKey.
lookupKeystringAlwaysEchoes the lookupKey asked about, so you can correlate the answer.
customerReferencestringAlwaysEchoes 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 (TEST or LIVE) 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 no Idempotency-Key applies.
  • metadata is raw jsonb, 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) or entitled = false (/entitlements/check).

On this page