Entitlements
The on/off switch for a user’s access to an app. See Domain Model → Entitlements for the full field reference and status lifecycle.
- Endpoints
- The Entitlement object
GET /v1/users/{id}/entitlementsPOST /v1/users/{id}/entitlementsGET /v1/entitlementsGET /v1/entitlements/{id}PATCH /v1/entitlements/{id}— the toggleDELETE /v1/entitlements/{id}- Errors specific to this resource
Endpoints
| Method | Path | Requires | Purpose |
|---|---|---|---|
GET |
/v1/users/{id}/entitlements |
self, entitlements.manage, or app-confined entitlements.manage/users.list |
A user’s entitlements, personal and org-sourced — what a dashboard renders as “your apps.” |
POST |
/v1/users/{id}/entitlements |
entitlements.manage (platform or app-confined), or the Application’s owner |
Grant a personal Entitlement. |
GET |
/v1/entitlements |
entitlements.manage (platform or app-confined), or the Application’s owner |
Cross-user list: “who has app X.” |
GET |
/v1/entitlements/{id} |
self, a member of the holding Organization, entitlements.manage, or the Application’s owner |
Fetch one Entitlement. |
PATCH |
/v1/entitlements/{id} |
entitlements.manage (platform or app-confined), or the Application’s owner; an org admin may change only status (active ⇄ disabled) and member_scope/member_overrides on their own Organization’s org_seat row |
The toggle. Change status, term, or org-grant scope. |
DELETE |
/v1/entitlements/{id} |
entitlements.manage (platform or app-confined), or the Application’s owner |
Hard-remove a grant made in error. |
Org-wide grants are created through POST /v1/organizations/{id}/entitlements. Once created, they’re read and changed through /v1/entitlements/{id} like any other Entitlement.
App-confined callers. An app-scoped API Key holding entitlements.manage can do everything on this page, but only for Entitlements whose application_id is its own Application. That includes its own Application’s org-wide grants (a developer’s backend reflecting a team purchase). Anything else returns 404 entitlement_not_found, so other apps’ rows aren’t revealed. This is how an Application developer reflects their own billing outcome. See Workflows → Purchase → access.
The Application’s owner (its owner_user_id, or an org_admin of owner_organization_id) can do the same with their own User token, confined to their Application in exactly the same way. Wherever this page says entitlements.manage, the owner is included for their own Application’s rows. That’s what lets a developer fix a grant by hand from the dashboard without minting a key.
The Entitlement object
{
"id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_timetrack",
"status": "active",
"source": "purchase",
"order_id": "sub_1Q2w3E4r5T6y7U8i",
"starts_at": "2026-01-14T18:05:00Z",
"ends_at": null,
"disabled_reason": null,
"member_scope": null,
"member_overrides": null,
"test_mode": false,
"created_at": "2026-01-14T18:05:00Z",
"updated_at": "2026-01-14T18:05:00Z"
}
order_id is the developer’s own opaque reference (up to 255 characters, often their Stripe subscription id). It isn’t a resource of this API. See Orders & Audit → Order references.
GET /v1/users/{id}/entitlements
// Response — 200
{
"data": [
{
"id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_timetrack",
"status": "active",
"source": "purchase",
"order_id": "sub_1Q2w3E4r5T6y7U8i",
"starts_at": "2026-01-14T18:05:00Z",
"ends_at": null
},
{
"id": "ent_01JAG9F4Q1W2E3R4T5Y6V7J809",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_invoicer",
"status": "disabled",
"source": "purchase",
"order_id": "sub_1Q9z8X7c6V5b4N3m",
"starts_at": "2026-03-02T09:00:00Z",
"ends_at": null,
"disabled_reason": "billing_dispute"
},
{
"id": "ent_01JAGD4E5F6G7H8J9K011M2N30",
"user_id": null,
"organization_id": "org_01JAFZ8Y7X6W5V4V3T2S1R0Q9P",
"application_id": "app_payroll",
"status": "active",
"source": "org_seat",
"order_id": null,
"starts_at": "2026-04-02T10:00:00Z",
"ends_at": null,
"granted_via": { "organization_id": "org_01JAFZ8Y7X6W5V4V3T2S1R0Q9P", "member_decision": "included" }
}
],
"page": { "next_cursor": null, "has_more": false }
}
- Returns the user’s personal rows, plus every org-wide row held by an Organization they’re an
activemember of, each annotated withgranted_via. Org rows that exclude this user (member_decision: "excluded"or"seat_limit") are still listed, so the user can see why they don’t have access; they’re never an access path. See Domain Model → Entitlements → Attribution. revokedandexpiredrows are excluded by default. Pass?status=revoked, or?include_inactive=truefor everything.- Filters:
application_id,status,source. - An app-confined caller sees only rows for its own Application.
The question “does this user have access to app X right now?” is answered by effective-permissions, not by scanning this list.
POST /v1/users/{id}/entitlements
// Request — an admin comp
{ "application_id": "app_invoicer", "source": "admin_grant" }
// Request — a developer's backend reflecting a purchase in their own billing system (app-scoped key)
{ "application_id": "app_invoicer", "source": "purchase", "order_id": "sub_1Q9z8X7c6V5b4N3m" }
// Request — a 14-day trial
{ "application_id": "app_invoicer", "source": "trial", "ends_at": "2026-10-19T12:00:00Z" }
// Response — 201
{
"id": "ent_01JAGA1B2C3D4E5F6G7H8J9K01",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_invoicer",
"status": "active",
"source": "admin_grant",
"order_id": null,
"starts_at": "2026-10-05T12:00:00Z",
"ends_at": null,
"disabled_reason": null,
"test_mode": false,
"created_at": "2026-10-05T12:00:00Z",
"updated_at": "2026-10-05T12:00:00Z"
}
| Field | Rules |
|---|---|
application_id |
Required. The Application must be approved and visible to the caller (see Applications); internal apps only accept grants from platform entitlements.manage. |
source |
Required: purchase, trial, or admin_grant. org_seat is rejected here (422 invalid_source), because org grants go through Organizations. |
order_id |
Optional, up to 255 characters. Strongly recommended for purchase. |
starts_at |
Optional, defaults to now. It may be in the future: the row is created active, but access doesn’t resolve until starts_at passes (resolved status scheduled, then access.granted with reason: entitlement_started). Can’t be more than 1 year ahead (422 validation_failed, out_of_range). |
ends_at |
Required for trial (422 ends_at_required). Optional for the others. Must be after starts_at. |
- Requires an
Idempotency-Keyheader. A developer reflecting a purchase should key it on their own order or event id, so a retried billing webhook on their side never double-grants. See Conventions → Idempotency. - One live personal row per (user, app): if the user already has an
activeordisabledpersonal Entitlement to this Application, the call returns409 entitlement_already_existswithdetails.existing_entitlement_idanddetails.status. Re-enable the existing one withPATCHinstead. Anexpiredorrevokedrow doesn’t block a new grant. - The user must exist and not be
deleted. Granting to aninviteduser is allowed: access starts when they activate. - Plan checks on the Application’s Tenant (independent of the caller’s permission): a grant that would add a new seat beyond Starter’s 1,000 returns
409 plan_limit_reached(resource: "seats"). On arestrictedTenant, a grant that would add a new seat returns402 subscription_required. See Pricing → Enforcement. - Effects, in one transaction: writes Audit Event
entitlement.granted, emits theentitlement.grantedwebhook, and emitsaccess.grantedif this flips the user’s resolved access to the app on.
GET /v1/entitlements
GET /v1/entitlements?application_id=app_invoicer&status=active
// Response — 200
{
"data": [
{
"id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_invoicer",
"status": "active",
"source": "purchase",
"order_id": "sub_1Q2w3E4r5T6y7U8i",
"starts_at": "2026-01-14T18:05:00Z",
"ends_at": null
}
],
"page": { "next_cursor": null, "has_more": false }
}
Filters: application_id, status, user_id, organization_id, source, order_id (exact match, for a developer reconciling against their own billing), ends_before (find trials about to lapse), and include_inactive. This is the support-dashboard query (“who currently has app X”, “every Entitlement disabled in the last billing dispute”). The per-user list above doesn’t support it. Org-wide grants appear as single rows here (user_id: null), not expanded per member.
GET /v1/entitlements/{id}
Returns the Entitlement object. Self may fetch their own personal rows, plus org rows of Organizations they belong to (with granted_via). An org admin may fetch their Organization’s rows.
PATCH /v1/entitlements/{id} — the toggle
// Request — turn off
{ "status": "disabled", "disabled_reason": "billing_dispute" }
// Request — turn back on
{ "status": "active" }
// Request — the developer's customer refunded
{ "status": "revoked", "disabled_reason": "refunded" }
// Request — extend a trial
{ "ends_at": "2026-11-02T12:00:00Z" }
// Request — convert a trial to a purchase
{ "source": "purchase", "order_id": "sub_1Q9z8X7c6V5b4N3m", "ends_at": null }
// Request — on an org-wide grant, narrow who it applies to
{ "member_scope": "denylist", "member_overrides": ["usr_01JAG3Z9X8QS3F6K2M4N5P6R7S"] }
// Response — 200, the full updated Entitlement (here, after "turn off")
{
"id": "ent_01JAG9F4Q1W2E3R4T5Y6V7J809",
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"organization_id": null,
"application_id": "app_invoicer",
"status": "disabled",
"source": "purchase",
"order_id": "sub_1Q9z8X7c6V5b4N3m",
"starts_at": "2026-03-02T09:00:00Z",
"ends_at": null,
"disabled_reason": "billing_dispute",
"member_scope": null,
"member_overrides": null,
"test_mode": false,
"created_at": "2026-03-02T09:00:00Z",
"updated_at": "2026-09-30T16:22:41Z"
}
Status transitions. Anything not listed here returns 409 invalid_status_transition.
| From → to | Allowed for | Notes |
|---|---|---|
active → disabled |
entitlements.manage; org admin on org_seat |
disabled_reason required (422 disabled_reason_required), 1–255 characters. Reversible. |
disabled → active |
same | disabled_reason is cleared automatically. |
active/disabled/expired → revoked |
entitlements.manage only (not org admins) |
Terminal. disabled_reason is required and records why (“refunded”, “chargeback”, “tos_violation”). |
expired → active |
entitlements.manage only |
Renewal. Only valid together with an ends_at in the future, or ends_at: null. Returns 409 entitlement_already_exists (with details.existing_entitlement_id) if a newer live row now exists for the same holder and Application. |
active → expired |
system only | Happens when ends_at passes. A sweep runs every 5 minutes, and access resolution also treats ends_at <= now as expired immediately, so expiry is never late. |
disabled → expired |
system only | ends_at passed while the row was disabled. Same sweep. disabled_reason is kept. No access.* event, since a disabled row wasn’t granting access. |
revoked → * |
nobody | Grant a new Entitlement instead. |
Other writable fields:
| Field | Rule |
|---|---|
ends_at |
Changeable on active/disabled/expired rows. Setting it in the past on an active row expires it on the next sweep. |
source |
Only trial → purchase (a conversion). Any other change returns 422 immutable_field. |
order_id |
Settable while null. Changing an existing value returns 422 immutable_field, so it stays a stable reconciliation key. |
member_scope, member_overrides |
org_seat rows only (422 not_an_org_grant otherwise). Every listed user must be a current member (422 member_override_not_a_member). |
starts_at, user_id, organization_id, application_id |
Read-only (422 read_only_field). |
Effects: every change writes an Audit Event and emits a webhook in the same transaction. The action/event is the target status (entitlement.granted, entitlement.disabled, entitlement.revoked), or entitlement.updated for term/source/order changes, or entitlement.member_scope_changed for scope changes. Separately, access.revoked / access.granted fire once per affected user whose resolved access to the Application actually flips. For an org grant that can mean many users. A user who still has another active path, such as a personal Entitlement, gets no access.* event. See Webhooks → Event types.
disabled/revoked transitions are destructive for MCP / restrict_destructive purposes. Send If-Match when two operators might act on the same row. See Conventions → Concurrency.
revoked is terminal. You can’t PATCH a revoked entitlement back to active; grant a new one instead. disabled is the reversible switch, and re-enabling restores exactly the prior state (Roles, AppProfile, AppSettings untouched).
DELETE /v1/entitlements/{id}
// Response — 204
Removes the record entirely. It’s reserved for correcting a grant made by mistake (wrong user, wrong app, duplicate). For every other case, such as a real purchase ending or a real admin decision to cut off access, use PATCH with status: revoked or disabled so the history survives.
- Requires
entitlements.manage(platform or app-confined). - Rejected with
409 entitlement_order_linkedwheneverorder_idis set, whoever the caller is, whateverrestrict_destructiveis set to. An order-linked Entitlement is a real financial record, never “a mistake” in this endpoint’s sense. - Rejected with
409 entitlement_too_oldif the row is more than 24 hours old. After a day it has very likely been relied on, so revoke it instead. - Writes Audit Event
entitlement.deleted(the audit trail of the correction survives, even though the row doesn’t), emits theentitlement.deletedwebhook, and emitsaccess.revokedif it was the user’s only active path. - Destructive: a
restrict_destructivekey gets403 destructive_operation_restricted.
Errors specific to this resource
| Code | Status | When |
|---|---|---|
entitlement_not_found |
404 | {id} doesn’t resolve, or isn’t visible to the caller. |
entitlement_already_exists |
409 | A live (active/disabled) personal row already exists for this user and app. |
invalid_status_transition |
409 | See the transition table. |
invalid_source |
422 | source: org_seat on the personal grant endpoint. |
ends_at_required |
422 | A trial without ends_at. |
disabled_reason_required |
422 | disabled/revoked without a reason. |
immutable_field |
422 | Changing source (other than trial → purchase) or an existing order_id. |
not_an_org_grant |
422 | member_scope/member_overrides on a personal row. |
member_override_not_a_member |
422 | A member_overrides id isn’t a current member. |
application_not_available |
409 | The Application isn’t approved, or is internal and the caller isn’t platform. |
plan_limit_reached |
409 | The grant would add a seat beyond the Application’s Starter Tenant’s cap; see Conventions → Plan limit errors. |
subscription_required |
402 | The grant would add a seat on a restricted Tenant. |
idempotency_key_required |
400 | POST without an Idempotency-Key. |
read_only_field |
422 | PATCH touches starts_at, user_id, organization_id, or application_id. |
tenant_suspended |
403 | Any write when the Application’s Tenant is suspended. |
validation_failed |
422 | For example starts_at more than 1 year ahead, or ends_at not after starts_at. |
entitlement_order_linked |
409 | DELETE on a row with order_id. |
entitlement_too_old |
409 | DELETE on a row older than 24 hours. |