Conventions

Read once, applies to every page in this section.

  1. Machine-readable
  2. Base URL
  3. Authentication
  4. IDs
  5. Addressing yourself: me
  6. Pagination
  7. Filtering
  8. Delete semantics: hard vs. soft
  9. Partial updates (PATCH)
  10. Concurrency (ETag / If-Match)
  11. Size and length limits
  12. Single-resource responses
  13. Errors
    1. Plan limit errors
  14. Idempotency
  15. Health check
  16. Response headers on every request
  17. Versioning
  18. Timestamps

Machine-readable

Everything on this and the following pages is also available as a single OpenAPI 3.1 document, browsable in the Interactive Reference and explained in OpenAPI Specification — generate a client, import into an API tool, or diff it against an implementation to check drift. The prose here is authoritative when the two disagree; the YAML is kept in sync by hand, not generated from this site.

Base URL

https://api.substratalapps.com
Substratal-Version: 1

This is the production base URL. Every path is at the host root, with no version prefix: the API version goes in the Substratal-Version request header instead (see Versioning). GET /.well-known/jwks.json and GET /.well-known/openid-configuration (see Auth → Signing keys) are there because standard JWT and OAuth libraries look for them there, and so is the MCP Server at /mcp. The Stripe webhook sink at /internal/stripe/webhook is internal to Substratal and not part of the public contract. /.well-known, /mcp, and /internal are reserved, so no resource will ever be named after them.

Every request and response body is application/json; charset=utf-8. The one exception is POST /auth/oauth/token, which also accepts application/x-www-form-urlencoded for OAuth-library compatibility.

Authentication

Authorization: Bearer <token>

User-facing requests carry a user access token (issued at login, see Auth). Service-to-service requests (billing, an app’s backend) carry a scoped API Key. Both go in the same header — the token type is distinguishable server-side by prefix, not by a different header name.

Test vs. live: every API Key is created with satk_test_… or satk_live_… (see API Keys) — there is no separate sandbox deployment to point at. A test key operates against the same database, but every record it creates is tagged test_mode: true. Test and live rows are invisible to each other: a test credential reads and writes only test rows, a live credential only live rows, and no query parameter crosses that line. Test-mode events only reach test-mode webhook subscriptions, and test traffic is excluded from analytics counters. A User created in test mode signs in with "test_mode": true on the auth endpoints (see Auth → Test-mode Users), and every token issued to them carries test_mode: true, which confines it to test rows the same way. This is cheaper to build and run than a parallel environment, and it’s the right call at the current scale (see Deployment Architecture) — a true isolated sandbox is a Scale-out-trigger-shaped decision, not a day-one one.

IDs

Every resource ID is prefixed by type. Most are the prefix plus a ULID (26 Crockford base32 characters, so never I, L, O, or U): generated, never reused, never recomputed from other fields, and not meant to be parsed for meaning beyond the prefix. Application and Role ids are the two exceptions, described below the table.

Prefix Entity
usr_ User
org_ Organization
tnt_ Tenant — see Domain Model → Tenancy
tcr_ Tenant tier-change request — see Tenancy
app_ Application
role_ Role
ent_ Entitlement
evt_ Audit Event
whk_ Webhook subscription
key_ API Key — see API Keys
uid_ UserIdentity — see Users & Organizations
ssc_ SSOConnection — see Users & Organizations
ses_ Session — see Auth → Sessions
wev_ Webhook event (one delivered event payload) — see Webhooks. Distinct from evt_, an Audit Event.
dlv_ Webhook delivery attempt — see Webhooks
dvc_ Device (a push-notification endpoint) — see Push Notifications
pcr_ Push credential — see Push Notifications
ntf_ Notification — see Push Notifications
ndl_ Notification delivery (one push to one device) — see Push Notifications

An Erasure request has no id of its own: there’s at most one per User, so it’s addressed by the User’s id (/users/{id}/erasure-requests/current).

order_id on an Entitlement is not a resource ID of this API. It’s an opaque reference string the developer supplies from their own billing system (for example, their own Stripe subscription id), up to 255 characters. Examples on this site use Stripe-style subscription ids (sub_…), the most common case. See Orders & Audit → Order references.

Application and Role are the deliberate exceptions. Both are commonly referenced from code and config (seed scripts, permission checks, launch URLs), where a stable, meaningful id is more useful than an opaque one, so both ids are derived rather than random:

  • An Application’s id is app_ plus its slug, verbatim (app_timetrack, app_time-track). slug is immutable, so the id is too. See Applications.
  • A Role’s id is role_platform_<name> or role_<slug>_<name>, with the Application’s slug verbatim (role_timetrack_admin, role_time-track_admin, role_platform_member). A slug never contains _, so the first _ after role_ always ends the slug, and two different (slug, name) pairs can never derive the same id. The slug platform is reserved for the same reason. See Domain Model → Roles & Permissions.

Credential strings are not resource IDs and follow their own prefix conventions, since they’re secrets rather than addressable resources. Never log these or echo them back after their initial issuance. None is stored in plaintext. Tokens and API Key secrets are stored as SHA-256 hashes, since the API only ever needs to verify them. A webhook signing secret is stored KMS-encrypted instead, because the API has to use it to sign every delivery, and a hash can’t sign. MFA recovery codes are stored as Argon2id hashes, since they’re short enough to brute-force against a fast hash.

Prefix Credential Lifetime
rtk_ Refresh token (platform or app) — see Auth 30 days idle / 90 days absolute, single-use
mfa_ MFA challenge token 5 minutes, single-use
ac_ OAuth authorization code 60 seconds, single-use
emv_ Email-verification token 24 hours, single-use
pwr_ Password-reset token 1 hour, single-use
inv_ Invitation token 7 days, single-use
whsec_ Webhook signing secret — see Webhooks Until rotated
satk_live_ / satk_test_ API Key secret — see API Keys Until revoked

atk_ and apt_ appear only as jti values inside JWTs (platform access token and app token respectively), never as standalone credentials.

Addressing yourself: me

Anywhere a path takes a {id} for a User, you may pass the literal string me instead of the caller’s own usr_… id — GET /users/me, GET /users/me/entitlements, GET /users/me/apps/{appId}/settings, PATCH /users/me/profile, and so on. This resolves server-side from the auth token, so a client never needs to know its own user id just to read or update its own data. me only means something for a User’s token. An API Key has no User behind it, so me with an API Key returns 400 user_token_required.

Pagination

List endpoints take limit (default 25, max 100) and cursor, and return:

{
  "data": [ /* ... */ ],
  "page": { "next_cursor": "eyJpZCI6Im9yZ18wMUoi...", "has_more": true }
}

Pass next_cursor back as cursor to get the next page. has_more: false means next_cursor is null and there’s nothing further. Cursors are opaque and valid for 24 hours; an expired or tampered cursor returns 400 invalid_cursor. A cursor is only valid with the same filters it was issued under.

Counts (include=total). Lists don’t count their rows unless asked. Add ?include=total and page also carries total, the number of rows matching the current filters across every page, and total_capped:

"page": { "next_cursor": "eyJpZCI6Im9yZ18wMUoi...", "has_more": true, "total": 1204, "total_capped": false }

The count stops at 10,000 so it can never become an expensive full scan: above that, total is 10000 and total_capped is true (show “10,000+”). It counts the rows the caller can see under the filters, ignoring cursor, so every page of the same list reports the same total. A cursor works with or without include=total, and the parameter isn’t part of the filters a cursor is bound to. Any other include value is 422 validation_failed (enum_mismatch). Without the parameter, total and total_capped are absent. Supported on: GET /users, /entitlements, /users/{id}/entitlements, /applications, /organizations, /organizations/{id}/members, /audit-events, /webhooks, /webhooks/{id}/deliveries, /webhook-deliveries, /api-keys, /roles, /subscriptions, and /push-credentials.

Ordering: unless a page says otherwise, lists are ordered newest first by created_at, with ties broken by id. Audit Events are ordered by timestamp, newest first. There’s no caller-selectable sort, except sort on GET /applications.

Filtering

List endpoints that support filtering take plain query parameters named after the field being matched (?status=active, ?application_id=app_timetrack) — there’s no separate filter DSL. Each resource page’s endpoint table states which fields are filterable; passing an unsupported filter parameter is ignored rather than erroring, so adding a new filterable field later is never a breaking change.

Soft-deleted and terminal-state records are excluded from lists by default. A list endpoint doesn’t return a soft-deleted User, a revoked or expired Entitlement, or a revoked API Key unless the caller explicitly asks for it, with an explicit ?status=… or with ?include_inactive=true on the lists that support it. This is the default precisely so “list my entitlements” doesn’t require every caller to remember to filter out the ones that don’t matter anymore.

Fetching by id follows one rule:

  • A terminal-state record (a revoked or expired Entitlement, a revoked API Key, a cancelled tier-change request) is still fetchable by id by anyone allowed to see it, and shows its terminal state. Write operations on it return 409 with the resource’s own code.
  • A soft-deleted User returns 404 user_not_found to everyone except users.manage holders, so a deleted account’s existence isn’t leaked. users.manage can still fetch it (status: deleted), restore it, and manage its erasure request. See Users → DELETE.

Boolean scoping parameters. A few list filters aren’t named after a field because they widen or narrow the default set rather than match a value: include_inactive=true (include terminal-state rows) and owned=true (only what the caller owns, on Applications). They’re the only two, and they mean the same thing wherever they appear.

Delete semantics: hard vs. soft

“Delete” doesn’t mean the same thing on every resource, and this site doesn’t pick one convention and force every resource into it — it picks the right one per resource and documents which, here, once, instead of leaving a caller to infer it from each page’s own wording.

A soft delete flips a status (or sets a deleted_at/revoked_at timestamp) rather than removing the row. The record is excluded from default list results, and whether it’s still fetchable by id follows the rule in Filtering above. Every other field is untouched. Nothing about a soft delete scrubs, redacts, or moves data anywhere; it’s purely a status change. Where a soft delete is reversible, reversing it restores exactly the prior state, nothing re-provisioned.

A hard delete removes the row entirely. Where this site allows it at all, it’s reserved for correcting a mistake, not for the ordinary lifecycle of a real record — see each resource’s own endpoint description for the specific line it draws.

Resource DELETE behavior
User Soft. status: deleted; 404s afterward to everyone but users.manage, who can restore it until the erasure cascade (if one was requested) completes. A separate, two-stage hard-delete cascade exists for right-to-erasure requests specifically — see Non-Functional Requirements → Hard-delete cascade. Not triggered by this call.
API Key Soft, but irreversible. Sets revoked_at; the row and its usage history are retained, but unlike every other soft delete on this list, there is no un-revoke — a replacement means creating a new key.
Entitlement Hard — but only for a grant with no order_id. Reserved for correcting a mistake (wrong user, wrong app, duplicate); real revocations use PATCH status: revoked/disabled instead, so the history survives. See Entitlements → DELETE.
Organization member Hard. The OrganizationMembership join row is removed outright, whether it was pending or active. Membership has no “soft-removed” state of its own.
Role assignment Hard. The UserRoleAssignment join row is removed outright; idempotent (removing an already-gone assignment still returns 204).
Webhook subscription Hard. Unsubscribing removes the subscription and its delivery log. Deliveries already in the retry queue are dropped, and nothing new is enqueued.
Device Hard. Unregistering removes the Device and drops its pending deliveries. Past deliveries keep their record with device_id: null.
Push credential Hard. The encrypted key material is deleted. Devices are kept, except web_push devices, which become inactive because their subscriptions are bound to the deleted key.
Notification No DELETE endpoint. A User archives one with PATCH state: archived, and every notification is deleted 90 days after it was sent.
Application, Organization, Role (the definition), Tenant No DELETE endpoint at all. Each has its own terminal-but-not-deleted state instead — review_status: suspended/rejected for an Application, status: suspended for an Organization (via PATCH) — because removing the catalog/definition entry itself would orphan everything that still references it (Entitlements, Role assignments, Roles scoped to it).
Profile No DELETE endpoint. Deleted outright, but only as step 2 of the hard-delete cascade above — never independently.
AppProfile / AppSettings No DELETE endpoint. The hard-delete cascade’s step 3 clears personal data in place: all of an AppProfile (display_handle set to null, custom to {}), and only the x-pii-marked keys of AppSettings overrides. The rows themselves are retained even then.
Settings (global) No DELETE endpoint, and not touched by the hard-delete cascade either — none of its fields (locale, timezone, theme, notifications) are personally identifying, so there’s nothing on it the erasure right reaches.
Audit Event Never deletable or editable through the API, by anyone, under any permission. Two scheduled infrastructure jobs are the only exceptions, and both only ever reduce an event to its shape: the archival job moves an aged-out event to cold storage, and the erasure cascade redacts before/after on a deleted User’s events. See Non-Functional Requirements → Audit log lifecycle.

Partial updates (PATCH)

Every PATCH body is a partial object: send only the fields to change.

  • An omitted field is left unchanged.
  • An explicit null clears a nullable field. Sending null for a non-nullable field is 422 validation_failed.
  • Free-form object fields (AppProfile.custom, AppSettings.overrides, Settings .notifications) are merged one level deep. Each key you send replaces that key, a key sent as null is removed, and keys you don’t send are untouched. Nested objects inside them are replaced wholesale, not merged recursively.
  • Arrays are always replaced wholesale (for example member_overrides, permissions, events, redirect_uris).
  • Read-only fields (id, created_at, tenant_id, …) sent in a PATCH body are rejected with 422 read_only_field, not silently ignored, so a client bug can’t hide.

Every successful PATCH returns 200 with the full updated resource.

Concurrency (ETag / If-Match)

Every single-resource GET and every successful write returns an ETag header, a weak validator over the row’s version counter (ETag: W/"7"). Any PATCH or DELETE may send If-Match: W/"7". If the resource has changed since, the write is rejected with 409 version_conflict and details.current_etag. Without If-Match the write is last-write-wins. Admin tooling and agents should always send it on Entitlement, Role, Settings, and AppSettings writes. See Non-Functional Requirements → Concurrency control.

Size and length limits

Thing Limit Error
Request body 1 MB 413 payload_too_large
Any string field, unless stated otherwise 255 characters 422 validation_failed (too_long)
description-style free text, review_notes, reason 2,000 characters same
Application.settings_schema 64 KB serialized, ≤ 200 declared properties same
AppProfile.custom, AppSettings.overrides 16 KB serialized each same
member_overrides 1,000 user ids same
redirect_uris 10 entries same
A notification’s title / body / data 120 characters / 1,000 characters / 20 string values in 2 KB, and the whole push payload in 4 KB 422 validation_failed, or 422 notification_too_large for the payload
Webhook subscriptions’ events every event type, no duplicates same
List limit 1–100 (default 25) values above 100 are clamped to 100, not rejected

Single-resource responses

A single resource is returned as a bare JSON object — no envelope:

{ "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S", "email": "jordan@example.com", "...": "..." }

Errors

{
  "error": {
    "code": "entitlement_required",
    "message": "This user has no active entitlement to app_invoicer.",
    "details": { "application_id": "app_invoicer" }
  }
}

code is a stable, machine-matchable string — build logic against it, not against message, which is for humans and can change wording without notice.

There’s no single global code catalog for resource-specific codes. Each resource’s own API Reference page carries an “Errors specific to this resource” table — that table is the authoritative source for the codes that resource returns, the same way Orders & Audit → Action catalog is authoritative for action values. A handful of codes are cross-cutting enough to define once, here, instead of repeating identically on every page. Everything else, entitlement_required above included, belongs to and is defined on one specific resource page.

Code Status When
invalid_request 400 Body isn’t valid JSON, or a required field is missing.
invalid_cursor 400 Pagination cursor expired, tampered with, or reused with different filters.
user_token_required 400 An endpoint that acts as a User (me, app-tokens, password change, …) was called with an API Key.
api_key_in_browser 400 An API Key was sent from a browser (the request carries an Origin header). Call from your backend, or use the signed-in User’s own token. See CORS.
idempotency_key_required 400 A POST that requires Idempotency-Key was sent without one.
api_version_required 400 No Substratal-Version header. details.supported lists the versions served. See Versioning.
unsupported_api_version 400 Substratal-Version names a version this API doesn’t serve. details.supported lists the ones it does.
unauthenticated 401 No Bearer token, a malformed or expired one, or a revoked API Key.
session_revoked 401 The token’s session was logged out or revoked.
forbidden 403 Authenticated, but the caller lacks the required permission. details.required_permission names it.
tenant_suspended 403 The Tenant that owns the data being written (or the Application an app token is requested for) is suspended by the platform. See Tenancy.
destructive_operation_restricted 403 A restrict_destructive API Key attempted a destructive operation — see API Keys.
mfa_enrollment_required 403 A staff session whose User has no TOTP called something other than enrollment and self-service. See Auth → Staff MFA.
reauthentication_required 403 An operation marked x-substratal-step-up without an MFA check in the last 10 minutes. details.max_age_seconds is 600. Call POST /auth/reauthenticate, then retry. See Auth → Step-up.
not_found 404 Generic not-found for a path that matches no route. Resource pages define their own <resource>_not_found codes.
method_not_allowed 405 The path exists, but not for this method. The Allow header and details.allowed list the methods it does support.
version_conflict 409 If-Match didn’t match the current ETag.
idempotency_key_reused 409 Same Idempotency-Key, different request body.
idempotency_key_in_flight 409 The first request with this Idempotency-Key is still being processed. Retry after a second.
plan_limit_reached 409 Reserved for the limits of business and enterprise plans, which aren’t offered yet. Nothing returns it today; see Plan limit errors below.
entitlement_platform_managed 409 A change to a free or subscription Entitlement other than disabling or re-enabling it. See Entitlements → Platform-managed.
payload_too_large 413 Body over 1 MB.
validation_failed 422 One or more fields fail validation. Uses details.fields — see below.
read_only_field 422 A PATCH body included a read-only field.
rate_limited 429 See Rate limiting.
internal_error 500 Unexpected server error. The response carries X-Request-Id; quote it to support.
service_unavailable 503 Planned maintenance (for example, a Tenant mid-migration on a write path, or the platform in read-only maintenance, with details.reason: "read_only") or a dependency outage. Retry-After is set.

HTTP status follows a fixed mapping from error class, not a judgment call per endpoint:

Status Class Example
400 Malformed request — the body isn’t valid JSON, or is missing a required field with no sensible default. Missing application_id on a grant.
401 Missing or invalid auth — no Bearer token, an expired one, or a revoked API Key’s secret. A revoked key’s secret used after revoked_at.
403 Authenticated, but not permitted — a real permission or ownership check failed. moderation_field_forbidden, destructive_operation_restricted.
404 Not found — including a never-existed resource, one the caller isn’t allowed to see, and a soft-deleted User; see Filtering for which records 404 after deletion and which stay fetchable. entitlement_not_found.
409 Conflict — the request is individually valid, but the current state of the resource makes it impossible to apply as-is. entitlement_already_exists, version_conflict, subscription_exists, entitlement_platform_managed, an idempotency key reused with a different body.
422 Semantically invalid — the request is well-formed but violates a declared rule beyond basic shape. A settings_schema violation, validation_failed.
413 Body too large. payload_too_large.
429 Rate limited. See Rate limiting.
5xx Server-side failure; safe to retry idempotent requests with backoff. internal_error, service_unavailable.

The dividing line between 409 and 422 worth internalizing: 409 is about state (“this would conflict with something that already exists or already happened”), 422 is about the request’s own content (“this value, on its own, doesn’t satisfy a rule”). subscription_exists is 409, not 422, for exactly this reason — the request is well-formed, it just can’t be satisfied while a subscription is already in force.

Multiple field errors (a 422 from a request that fails validation on more than one field at once) use a structured details.fields array instead of forcing the client to parse message:

{
  "error": {
    "code": "validation_failed",
    "message": "2 fields failed validation.",
    "details": {
      "fields": [
        { "field": "email", "code": "invalid_format" },
        { "field": "available_app_roles", "code": "too_long", "max": 20 }
      ]
    }
  }
}

A single-field error (like entitlement_required above) skips the array and puts the relevant IDs directly in details — the array form is specifically for “more than one thing wrong with this request body.”

Each entry’s code is one of a small fixed set: required, invalid_format, too_short, too_long, out_of_range, enum_mismatch, not_unique, unknown_value, undeclared_key. Entries carry whatever bound applies (min, max, allowed, pattern). A few resource pages show examples for their own rules: Applications, Profiles, and Settings.

Plan limit errors

409 plan_limit_reached is reserved. Nothing in the API returns it today: the Applications, AppRoles, webhook, and seat caps that used to produce it belonged to developer plans, which Pricing retired. Business and enterprise plans may bring limits of their own, and will report them with this code and these details, so a client can render one “upgrade to continue” screen:

{
  "error": {
    "code": "plan_limit_reached",
    "message": "This plan's limit has been reached.",
    "details": { "resource": "…", "limit": 0, "current": 0, "plan": "…" }
  }
}

Idempotency

Any POST that creates or transitions an Entitlement accepts:

Idempotency-Key: <client-generated string, 1–255 characters; a UUID v4 is recommended>

A repeated key with an identical body returns the original response (same status code, same body) instead of creating a duplicate. A repeated key with a different body returns 409. Keys are remembered for 24 hours, scoped per API key/caller — after that window a repeated key is treated as new. A request that arrives while the first one with the same key is still in flight gets 409 idempotency_key_in_flight; retry after a second. Responses replayed from the store carry Idempotent-Replayed: true.

Endpoints that require Idempotency-Key: POST /users/{id}/entitlements, POST /organizations/{id}/entitlements, and both billing Checkout endpoints (POST /users/me/billing/checkout-sessions and POST /organizations/{id}/billing/checkout-sessions). Every other POST accepts it optionally and honors it the same way. See Non-Functional Requirements → Idempotency & retries for why this is mandatory rather than optional on those endpoints — billing webhooks retry, and a duplicate Entitlement is a real-money bug, not a cosmetic one.

Implementation note: store (caller_id, idempotency_key) → (request_body_hash, response_status, response_body, expires_at), written in the same transaction as the mutation it guards (see Non-Functional Requirements → Transaction boundaries) so a crash between “wrote the Entitlement” and “recorded the idempotency key” can’t produce a duplicate on retry. Compare the stored hash, not the raw body, to decide same-vs-different.

Health check

GET /health
{ "status": "ok" }

Unauthenticated, uncached, for uptime monitoring and load balancer health checks — not a dependency check (it doesn’t query the database). Always 200 unless the service itself can’t respond.

Response headers on every request

Header Meaning
X-Request-Id Echoes the caller’s X-Request-Id if sent (≤ 128 characters), otherwise a generated one. Quote it in support requests.
Substratal-Version The API version that served the request. See Versioning.
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset The caller’s current token bucket — see Rate limiting.
ETag On single-resource responses — see Concurrency.
Deprecation, Sunset Only on a deprecated version — see Versioning.

Versioning

Every request names the API version it was written against, in a header:

Substratal-Version: 1

Versions are whole numbers, and 1 is the only one today. The URL never carries a version, so a resource’s path stays the same across versions.

  • Required. A request without the header is rejected with 400 api_version_required, and an unknown value with 400 unsupported_api_version. Both errors list the served versions in details.supported. There’s no default version, so a client is never moved to a new one without asking for it. The check comes before authentication and every other validation.
  • Echoed. Every response to a versioned request carries Substratal-Version set to the version that served it.
  • Exempt: GET /health, so uptime monitors can call it as is; POST /auth/oauth/token, which standard OAuth libraries call without custom headers; the /.well-known endpoints; and SCIM under /scim/v2, because identity providers can’t add the header and SCIM versions itself in the path. They behave the same in every version. MCP clients don’t send it either: the MCP server calls the API with the version its tools describe.
  • Browsers: Substratal-Version is allowed in CORS requests and exposed on CORS responses.
  • Changes. Additive changes (new optional fields, new endpoints, new enum values) ship within a version. A breaking change ships as the next number, and the previous version keeps working for at least 6 months, with Deprecation and Sunset headers on every response to a request that names it. After its Sunset date it’s retired, and requests naming it get 400 unsupported_api_version. See Non-Functional Requirements → Versioning for what counts as breaking.
  • Not the webhook version. A webhook subscription’s api_version (a date, such as 2026-10-05) fixes the shape of the payloads it receives, and is versioned separately. See Webhooks → Payload versions.
  • In openapi.yaml: every versioned operation declares the header as the shared ApiVersion parameter, and documents the 400 it returns without one, so generated clients know to send it. The exempt operations don’t declare it.

Timestamps

ISO 8601, UTC, always with a Z suffix: 2026-09-30T16:22:41Z.