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
  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 — 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/v1

This is the production base URL. Two endpoints deliberately live outside /v1, at the host root: GET /.well-known/jwks.json and GET /.well-known/openid-configuration (see Auth → Signing keys), because standard JWT and OAuth libraries look for them there. So does the MCP Server at /mcp. The Stripe webhook sink at /internal/stripe/webhook is internal to Substratal and not part of the public contract.

Every request and response body is application/json; charset=utf-8. The one exception is POST /v1/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, excluded from webhooks firing to any other caller’s live subscriptions, and from rate-limit/analytics counters. 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 opaque ULIDs — generated, never reused, never recomputed from other fields, and not meant to be parsed for meaning beyond the prefix:

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

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 ord_…-style values for readability only. See Orders & Audit → Order references.

Role is the deliberate exception. A Role’s id is a human-readable slug (role_timetrack_admin, role_platform_member), not a random ULID — Roles are commonly referenced from code and config (seed scripts, permission checks), where a stable, meaningful id is more useful than an opaque one. 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. All are stored hashed (SHA-256), never in plaintext.

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 /v1/users/me, GET /v1/users/me/entitlements, GET /v1/users/me/apps/{appId}/settings, PATCH /v1/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.

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.

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 by default. A list endpoint doesn’t return a soft-deleted User, a revoked Entitlement, or a suspended API Key unless the caller explicitly asks for it (?status=revoked, or a resource-specific ?include_deleted=true where noted on that page). 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.

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 — same rule as Filtering above — but remains fetchable by id for an authorized caller, and 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. 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 — 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; already-queued deliveries still attempt, nothing new is enqueued.
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. PII is scrubbed from custom/overrides in place by the hard-delete cascade’s step 3; the row itself is 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 through the API, by anyone, under any permission — the one exception is the scheduled archival job moving an aged-out event to cold storage, which is an infrastructure process, not a caller-facing DELETE. 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
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.
idempotency_key_required 400 A POST that requires Idempotency-Key was sent without one.
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.
destructive_operation_restricted 403 A restrict_destructive API Key attempted a destructive operation — see API Keys.
subscription_required 402 The owning Tenant is restricted after a lapsed subscription, and this write would add usage — see Pricing → Subscription lapse.
not_found 404 Generic not-found for a path that matches no route. Resource pages define their own <resource>_not_found codes.
version_conflict 409 If-Match didn’t match the current ETag.
idempotency_key_reused 409 Same Idempotency-Key, different request body.
plan_limit_reached 409 The owning Tenant is at a plan limit — see Pricing → Enforcement.
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 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.
402 Payment required — the owning Tenant’s subscription has lapsed, and this write would add usage. subscription_required.
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 soft-deleted or never-existed resource; see Filtering for why a soft-deleted record 404s rather than returning a deleted status. 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, plan_limit_reached, 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”). plan_limit_reached is 409, not 422, for exactly this reason — the request is well-formed, it just can’t be satisfied against the owning Tenant’s current count.

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.”

Idempotency

Any POST that creates or transitions an Entitlement- or Order-linked record 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 /v1/users/{id}/entitlements, POST /v1/organizations/{id}/entitlements, and POST /v1/tenants/{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 /v1/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.
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

/v1 is the only version today. Additive changes (new optional fields, new endpoints) ship without a version bump; breaking changes get a new prefix and a deprecation window for the old one. See Non-Functional Requirements → Versioning.

Timestamps

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


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.