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.

  1. Endpoints
  2. GET /v1/users/{id}/entitlements
  3. POST /v1/users/{id}/entitlements
  4. GET /v1/entitlements
  5. GET /v1/entitlements/{id}
  6. PATCH /v1/entitlements/{id} — the toggle
  7. DELETE /v1/entitlements/{id}
  8. Errors specific to this resource

Endpoints

Method Path Purpose
GET /v1/users/{id}/entitlements List a user’s entitlements — this is what the hub dashboard renders as “your apps.”
POST /v1/users/{id}/entitlements Grant access to an app (admin_grant, or internal use by the billing webhook handler for purchase).
GET /v1/entitlements Admin/support view. List across all users, filterable by application_id, status, user_id, source. The “who has app X enabled” query.
GET /v1/entitlements/{id} Fetch one entitlement by its own ID.
PATCH /v1/entitlements/{id} Change status. This is the on/off toggle.
DELETE /v1/entitlements/{id} Hard-remove a grant made in error. Distinct from setting status: revoked — see below.

GET /v1/users/{id}/entitlements

// Response — 200
{
  "data": [
    {
      "id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
      "application_id": "app_timetrack",
      "status": "active",
      "source": "purchase",
      "order_id": "ord_01JAG5D1C2E3F4G5H6J7K8L9M0",
      "starts_at": "2026-01-14T18:05:00Z",
      "ends_at": null
    },
    {
      "id": "ent_01JAG9F4Q1W2E3R4T5Y6U7I8O9",
      "application_id": "app_invoicer",
      "status": "disabled",
      "source": "purchase",
      "order_id": "ord_01JAG8B3N4M5K6J7H8G9F0D1S2",
      "starts_at": "2026-03-02T09:00:00Z",
      "ends_at": null
    },
    {
      "id": "ent_01JAGD4E5F6G7H8J9K0L1M2N3O",
      "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_01JAFZ8Y7X6W5V4U3T2S1R0Q9P", "member_decision": "included" }
    }
  ],
  "page": { "next_cursor": null, "has_more": false }
}

granted_via appears only on an org_seat-sourced row — see Domain Model → Entitlements → Attribution for what member_decision (included/excluded) means and why it’s computed per member rather than stored on the org-wide grant itself.

POST /v1/users/{id}/entitlements

// Request
{
  "application_id": "app_invoicer",
  "source": "admin_grant"
}
// Response — 201
{
  "id": "ent_01JAGA1B2C3D4E5F6G7H8J9K0L",
  "application_id": "app_invoicer",
  "status": "active",
  "source": "admin_grant",
  "order_id": null,
  "starts_at": "2026-10-03T12:00:00Z",
  "ends_at": null
}

Requires Idempotency-Key — see Conventions → Idempotency. Requires a platform role with entitlements.manage for source: admin_grant; the billing webhook handler calls this same endpoint internally (via a service API key scoped to entitlements.manage) with source: "purchase" and order_id set, keyed on the order ID so a retried webhook never double-grants.

GET /v1/entitlements

GET /v1/entitlements?application_id=app_invoicer&status=active
// Response — 200
{
  "data": [
    {
      "id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
      "user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
      "application_id": "app_invoicer",
      "status": "active",
      "source": "purchase",
      "order_id": "ord_01JAG5D1C2E3F4G5H6J7K8L9M0",
      "starts_at": "2026-01-14T18:05:00Z",
      "ends_at": null
    }
  ],
  "page": { "next_cursor": null, "has_more": false }
}

Requires a platform role with entitlements.manage. This is the endpoint a support dashboard calls to answer “who currently has app X” or “show me every disabled entitlement from the last billing dispute” — the per-user list above doesn’t support that cross-user query.

GET /v1/entitlements/{id}

// Response — 200
{
  "id": "ent_01JAG6R2N7HX0K9T4V5W6Y7Z8A",
  "user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "application_id": "app_timetrack",
  "status": "active",
  "source": "purchase",
  "order_id": "ord_01JAG5D1C2E3F4G5H6J7K8L9M0",
  "starts_at": "2026-01-14T18:05:00Z",
  "ends_at": null,
  "disabled_reason": null
}

Self (for one’s own entitlement), or a platform role with entitlements.manage.

PATCH /v1/entitlements/{id} — the toggle

// Request — turn off
{ "status": "disabled", "disabled_reason": "billing_dispute" }
// Response — 200
{ "id": "ent_01JAG9F4Q1W2E3R4T5Y6U7I8O9", "status": "disabled", "disabled_reason": "billing_dispute", "...": "..." }
// Request — turn back on
{ "status": "active", "disabled_reason": null }
// Request — on an org-wide grant, narrow who it applies to
{ "member_scope": "denylist", "member_overrides": ["usr_01JAG3Z9X8QS3F6K2M4N5P6R7S"] }

Requires a platform role with entitlements.manage (e.g. support or superadmin — see Roles & Permissions), or — for an org_seat row specifically — org-admin standing on that Organization (see Organizations → Delegated admin). Writes an Audit Event and emits entitlement.disabled / entitlement.granted on a status transition. Changing only member_scope/member_overrides (no status change) is a distinct, narrower write — it emits entitlement.member_scope_changed instead, since no member’s resolved access path was necessarily added or removed by this call alone; see Domain Model → Orders & Audit → Action catalog.

status transitions are not unrestricted — revoked is terminal; you cannot PATCH a revoked entitlement back to active. Grant a new one instead. See Domain Model → Entitlements → Status transitions.

DELETE /v1/entitlements/{id}

Requires a platform role with entitlements.manage. Removes the record entirely — reserved for correcting a grant made by mistake (wrong user, wrong app, duplicate), where no Audit trail of a real access change should persist. For every other case — a real purchase ending, a real admin decision to cut off access — use PATCH with status: revoked or disabled instead, so the history survives in the Audit log.

This is enforced server-side, not left to caller discipline: rejected with 409/code: "entitlement_order_linked" whenever order_id is set, regardless of the calling key’s restrict_destructive setting — an order-linked Entitlement is never hard-deletable by anyone, because it’s never “a mistake” in the sense this endpoint exists for; it’s a real financial record. This is a universal gate on top of, not a replacement for, API Keys → Agent keys’s restrict_destructive check — a restrict_destructive: true key is blocked from calling this endpoint at all; a restrict_destructive: false or service-use key is still blocked from this one specific case.

Errors specific to this resource

Code When
entitlement_already_exists A POST would create a duplicate active grant for the same (user_id, application_id).
invalid_status_transition e.g. attempting to reactivate a revoked entitlement.
entitlement_not_found {id} doesn’t resolve.
entitlement_order_linked A DELETE targets an Entitlement with order_id set — use PATCH with status: revoked instead.

Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.