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. Single-resource responses
  10. Errors
  11. Idempotency
  12. Health check
  13. Versioning
  14. 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

Provisional — the real host is whatever gets decided alongside Decisions → Identity provider, but every example on this site uses this value so they’re copy-pasteable and consistent with each other.

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
ord_ Order
evt_ Audit Event
whk_ Webhook subscription
key_ API Key — see API Keys
uid_ UserIdentity — see Users & Organizations
ssc_ SSOConnection — see Users & Organizations

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: a refresh token is rtk_… (see Auth), a webhook signing secret is whsec_… (see Webhooks), and an API key’s secret value is satk_live_… (see API Keys). Never log these or echo them back after their initial issuance.

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.

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.

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.

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. 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: validation_failed, version_conflict, rate_limited. Everything else — entitlement_required above included — belongs to, and is defined on, one specific resource page.

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 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.
429 Rate limited. See Rate limiting.

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 UUID>

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

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.