API Keys
How a service — billing, an Application’s own backend, an internal tool — authenticates to this API without a user behind it. See Conventions → Authentication for how an API key’s bearer token differs from a user access token.
- What an API Key is
- Endpoints
- Per-key rate limits
POST /api-keys- Agent keys &
restrict_destructive - Test vs. live
- App-confined permissions
GET /api-keys/{id},PATCH /api-keys/{id}POST /api-keys/{id}/rotateDELETE /api-keys/{id}- Errors specific to this resource
What an API Key is
A long-lived credential scoped to either one Application (for an app’s own backend calling back into the hub — reading its users’ effective permissions via Roles & Permissions, writing AppProfile/AppSettings) or the platform as a whole (for billing, internal tooling). Unlike a user access token, it doesn’t expire on a short TTL and isn’t tied to a login session — it’s rotated deliberately, not refreshed automatically.
| Field | Type | Notes |
|---|---|---|
id |
string | key_ prefix. |
name |
string | Human label, e.g. "billing-webhook-handler". |
scope |
string | "platform" or an application_id — same shape as Role scope. |
permissions |
array of strings | Permission keys this key carries. For an app-scoped key, limited to that Application’s own app.<slug>.* keys plus the five app-confined platform permissions. |
mode |
enum | live | test — see Test vs. live below. Fixed at creation; a key can’t switch modes, only be replaced. |
intended_use |
enum | service | agent — see Agent keys & restrict_destructive below. Metadata with one real effect: it sets the default for restrict_destructive. |
restrict_destructive |
boolean | Default follows intended_use (true for agent, false for service) unless set explicitly. See below. |
secret_hint |
string | Last 4 characters of the current secret, e.g. "…6c8e", so an operator can tell keys apart without the secret. |
expires_at |
timestamp, nullable | Optional hard expiry, set at creation. After it, the key behaves as revoked (401). |
rate_limit_per_minute |
integer, nullable | This key’s own request limit, 1–10,000 per minute, in place of the default 100. null (the default) means the default. Only platform api_keys.manage may set it. See Per-key rate limits. |
created_by |
object | { "type": "user", "id": "usr_…" }. Always a User: an API Key can never create API Keys (see below). |
last_used_at |
timestamp, nullable | Updated at most once a minute. |
created_at |
timestamp | |
revoked_at |
timestamp, nullable |
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api-keys |
List keys in scope for the caller. Filter by scope, mode, intended_use, q (a case-insensitive prefix of name), and include_inactive (include revoked and expired keys). |
POST |
/api-keys |
Create a key. Returns the secret once. |
GET |
/api-keys/{id} |
Fetch one key (never the secret). |
PATCH |
/api-keys/{id} |
Change name, permissions, restrict_destructive, or rate_limit_per_minute. |
POST |
/api-keys/{id}/rotate |
Issue a new secret for the same key record, invalidating the old one. |
DELETE |
/api-keys/{id} |
Revoke permanently. |
Who may manage which keys:
Key’s scope |
Who can create, read, change, rotate, and revoke it |
|---|---|
platform |
Platform api_keys.manage only. Creating a key never grants more than the creator holds: every permission on a new platform key must be one the creator holds (403 role_escalation_forbidden). |
an application_id |
Platform api_keys.manage, or the Application’s owner (its owner_user_id, or an org_admin of owner_organization_id). That’s what lets a developer onboard without Substratal staff in the loop for every key. |
An API Key can never create, change, or rotate API Keys, including itself (400 user_token_required). Credential management is always done by a person. Every create, change, rotate, and revoke writes an Audit Event (api_key.created, api_key.updated, api_key.rotated, api_key.revoked).
Per-key rate limits
A high-volume service integration, billing say, gets a higher limit on its own key rather than an exemption from limiting (NFR → Rate limiting). rate_limit_per_minute sets it, on POST /api-keys or PATCH /api-keys/{id}:
// PATCH /api-keys/key_01JAGE5F6G7H8J9K011M2N304P — the billing integration's key
{ "rate_limit_per_minute": 2000 }
- What it replaces: the default 100 requests/minute for that key, and nothing else. An operation with its own ceiling, such as
POST /usersat 20/minute orPOST /users/{id}/notificationsat 600/minute, keeps that ceiling for this key too. An override can also be lower than the default, to throttle a key that’s misbehaving without revoking it. - Range: 1 to 10,000. Outside it is
422 validation_failed(out_of_range).nullrestores the default. - Who may set it: platform
api_keys.manageonly, on any key. An Application’s owner manages their app-scoped keys but can’t set this field, or they could raise their own key’s limit: sending it at all, evennull, is403 forbidden. They can still read it. - Audit: a change writes
api_key.updatedwith the key before and after. A key created with an override records it inapi_key.created. - It takes effect on the key’s next request. Requests already counted in the current minute still count.
POST /api-keys
// Request
{
"name": "timetrack-backend",
"scope": "app_timetrack",
"permissions": ["app.timetrack.export"],
"mode": "live",
"intended_use": "service",
"expires_at": "2027-10-04T00:00:00Z"
}
// Response — 201
{
"id": "key_01JAGE5F6G7H8J9K011M2N304P",
"name": "timetrack-backend",
"scope": "app_timetrack",
"permissions": ["app.timetrack.export"],
"mode": "live",
"intended_use": "service",
"restrict_destructive": false,
"secret": "satk_live_9f2a1c7e4b3d8f0a2c5e7b1d9f3a6c8e",
"secret_hint": "…6c8e",
"expires_at": "2027-10-04T00:00:00Z",
"rate_limit_per_minute": null,
"created_by": { "type": "user", "id": "usr_01JAG0SYSTEM00000000000000" },
"last_used_at": null,
"created_at": "2026-10-04T09:30:00Z",
"revoked_at": null
}
name, scope, and permissions are required. mode defaults to live, intended_use to service, and restrict_destructive to the intended_use default. expires_at is optional and must be in the future.
Creating a platform-scoped key is a step-up operation: the session needs an MFA check in the last 10 minutes, or it gets 403 reauthentication_required. App-scoped keys don’t need one. Changing a key’s rate_limit_per_minute is a step-up operation too.
secret is returned only in this response — store it immediately; it’s not retrievable afterward, only rotatable. The key is used exactly like a user access token: Authorization: Bearer satk_live_....
Agent keys & restrict_destructive
An LLM agent calling this API (directly, or through the MCP Server) has a different risk shape from deterministic service code making the same call. The platform’s enforcement is just as strong: Access Control doesn’t know or care whether its caller is an agent. What changes is that the decision to call a destructive operation at all is made by something a prompt can influence, possibly via untrusted data it has read (a disabled_reason, or an AppProfile.custom field written by someone else). A service integration’s call sites are fixed when it’s written.
That’s addressed with two fields on the existing API Key, not a new key type (which would be a parallel, redundant scoping system):
intended_use: "service" | "agent", set at creation. It’s metadata with one real effect: it sets the default of the field below, steering an agent-facing key toward the safer posture without forcing it.restrict_destructive: boolean, which defaults totrueforagentandfalseforservice. Whentrue, any request this key authorizes that classifies as destructive is rejected server-side, whatever permissions the key otherwise carries.
// Request — a key meant for an MCP/agent integration
{
"name": "support-assistant-mcp",
"scope": "platform",
"permissions": ["audit.view", "users.list", "entitlements.manage"],
"mode": "live",
"intended_use": "agent"
}
// Response — 201, restrict_destructive defaulted to true from intended_use
{
"id": "key_01JAGF6G7H8J9K011M2N304P5Q",
"name": "support-assistant-mcp",
"scope": "platform",
"permissions": ["audit.view", "users.list", "entitlements.manage"],
"mode": "live",
"intended_use": "agent",
"restrict_destructive": true,
"secret": "satk_live_...",
"...": "..."
}
This key carries entitlements.manage — it can toggle an Entitlement off — but with restrict_destructive: true, a call that would set status to disabled/revoked on the Entitlements toggle is rejected before the permission check even matters:
// Response — 403
{
"error": {
"code": "destructive_operation_restricted",
"message": "This API Key has restrict_destructive enabled and cannot perform this operation.",
"details": { "key_id": "key_01JAGF6G7H8J9K011M2N304P5Q" }
}
}
“Destructive” is one rule, not a per-endpoint list maintained twice: the same classification MCP Server → Tool annotations & safety uses to derive destructiveHint for a tool call. The complete list of destructive operations is maintained there once, and marked on each operation in openapi.yaml as x-substratal-destructive. Some operations are always destructive (every DELETE, for example). Others are destructive only for certain request bodies: PATCH /entitlements/{id} only when it sets status to disabled or revoked, so the same agent key can still extend a trial or convert one to a purchase. openapi.yaml marks these x-substratal-destructive: conditional, with the condition in x-substratal-destructive-when, and the server evaluates the condition against each request. A restrict_destructive key can still read everything its permissions allow and call every non-destructive write; it’s blocked from exactly that set, regardless of what permissions it otherwise carries — the permission check and the destructive-operation check are independent gates, both have to pass.
Setting restrict_destructive: false explicitly on an intended_use: "agent" key — overriding the safer default — writes an Audit Event (api_key.restrict_destructive_disabled), since it’s the deliberate exception worth a record, not the default worth none.
The result: an agent integration is safer by default without being less capable by default. A read-only or toggle-only assistant never needs restrict_destructive: false. One that genuinely does (an assistant whose whole job is disabling compromised accounts, say) sets it explicitly, and that choice is audited.
Test vs. live
Uniqueness constraints include test_mode (e.g. unique (email, test_mode) where deleted_at is null), so a test signup can’t collide with a live one. A test-mode credential sees only test-mode rows and a live credential only live rows, enforced by the same Row-Level Security mechanism as tenant_id. Test-mode rows older than 30 days are purged nightly. Test-mode Users are also time-limited: each stops working at its expires_at, 24 hours after creation unless the caller chooses otherwise (up to 30 days). See Auth → Test-mode Users → Lifetime.
mode: "test" produces a satk_test_… secret instead of satk_live_… — same permissions and scope, but every resource it creates (Users, Entitlements, anything) is tagged test_mode: true:
- Invisible to live credentials, and vice versa: a live key or live User token never reads or writes test rows, under any parameter, and a test credential never sees live rows. There’s no flag that crosses the line.
- Webhook deliveries from test-mode data only reach webhook subscriptions that were themselves created with a
testkey — aliveintegration never receives test traffic. - Subject to periodic cleanup (test data isn’t held to the same retention requirements as live data).
This is how an Application’s developer integration-tests against the real API without a separate sandbox deployment or risk to live data — see Conventions → Authentication.
Walkthrough: a test run end to end
- Create a test key (as the Application’s owner):
POST /api-keyswith"scope": "app_timetrack","mode": "test","permissions": ["entitlements.manage", "users.list"]. The response’ssecretstartssatk_test_. Applications are shared catalog rows, visible to test and live credentials alike (an Application’s owntest_modeflag only marks one created for testing, which is what allowslocalhostredirect URIs). Everything the test key writes is taggedtest_mode: true. - Create a test User with that key:
POST /users{"email": "qa+1@example.com", "status": "active", "send_invitation": false}. The response has"test_mode": trueand anexpires_at24 hours away; add"expires_at"to the request for a shorter or longer run. A live User with the same email can exist alongside it without conflict. - Grant access:
POST /users/{id}/entitlementswith anIdempotency-Keyand{"application_id": "app_timetrack", "source": "trial", "ends_at": "…"}. The Entitlement istest_mode: true, and theentitlement.granted/access.grantedwebhooks go only to subscriptions created with a test key. - Sign in as that User: set a password through
POST /auth/password/forgot{"email": "qa+1@example.com", "test_mode": true}, thenPOST /auth/loginwith"test_mode": true(see Auth → Test-mode Users). The access token and every app token carry"test_mode": true. - Check isolation:
GET /users?email=qa+1@example.comwith a live key returns an empty list. The test User doesn’t exist as far as live credentials are concerned. - Clean up, or don’t: test rows older than 30 days are purged nightly.
App-confined permissions
Why app-scoped keys can carry these permissions at all: a developer’s backend has to reflect its own billing outcomes into Entitlements (see Billing system of record). Without app confinement it would have no safe credential to do that with. An app-scoped key couldn’t hold entitlements.manage, and a platform-scoped key that could would reach every Application. Confinement keeps the safety property: a compromised app key still can’t touch a different Application or escalate anyone’s platform standing. A platform-run “billing bridge” for developers to post outcomes to was considered and not chosen, because it adds a component without reducing risk.
An app-scoped key may carry its Application’s own app.<slug>.* keys, plus five platform permissions in confined form. The server automatically restricts each one to rows belonging to the key’s own Application:
| Permission | What the app key can do with it |
|---|---|
entitlements.manage |
Grant, toggle, revoke, and delete-in-error Entitlements to its own Application, personal and org-wide. This is how a developer’s backend reflects the outcome of their own billing. |
roles.manage |
Define its own Application’s Roles, and assign or remove them on users. |
users.list |
List and read Users with any access path to its Application, and read their global Profile (read-only). |
audit.view |
Read Audit Events whose application_id is its Application. |
notifications.send |
Send notifications from its own Application, only to users with active access to it. |
Without any extra permission, every app-scoped key can also read and write its own users’ AppProfile and AppSettings, read their effective permissions for its Application, manage its Application’s webhooks, register and manage its users’ devices for its Application, read their inbox for its Application, and read (never change) its push credentials.
An app-scoped key can never carry users.manage, applications.manage, organizations.manage, api_keys.manage, tenants.manage, webhooks.manage, billing.manage, billing.refund, operations.view, or operations.manage. Asking for one returns 422 permission_not_grantable_to_scope. Any attempt to touch another Application’s rows returns 404, as though the row didn’t exist. That’s what makes it safe for an Application’s backend, or a third party’s, to hold one: a compromised key can’t grant access to a different app, disable another app’s Entitlements, or change anyone’s platform standing.
GET /api-keys/{id}, PATCH /api-keys/{id}
// PATCH request — tighten an agent key
{ "permissions": ["audit.view", "users.list"], "restrict_destructive": true }
// Response — 200, the key (no secret)
{
"id": "key_01JAGF6G7H8J9K011M2N304P5Q",
"name": "support-assistant-mcp",
"scope": "platform",
"permissions": ["audit.view", "users.list"],
"mode": "live",
"intended_use": "agent",
"restrict_destructive": true,
"secret_hint": "…a41f",
"expires_at": null,
"rate_limit_per_minute": null,
"created_by": { "type": "user", "id": "usr_01JAG9STAFF000000000000000" },
"last_used_at": "2026-10-05T11:58:12Z",
"created_at": "2026-10-04T09:45:00Z",
"revoked_at": null
}
scope, mode, and intended_use are fixed at creation (422 read_only_field), so replace the key to change them. A permission change takes effect on the key’s next request.
POST /api-keys/{id}/rotate
// Request
{}
// Response — 200
{
"id": "key_01JAGE5F6G7H8J9K011M2N304P",
"secret": "satk_live_2b8e4a1f9c3d7e0b5a8f1c4e9b2d7a0f",
"...": "..."
}
The old secret stops working immediately — there’s no overlap window, so rotating is a coordinated action (update the caller’s stored secret in the same change), not a background migration. For a zero-downtime rotation, create a second key, cut the caller over, then revoke the first, rather than rotating in place.
DELETE /api-keys/{id}
// Response — 204
Immediate and permanent — sets revoked_at, and any request bearing that secret afterward gets 401. Does not touch Entitlements, Roles, or any data the key was used to write; it only removes the credential itself.
Errors specific to this resource
| Code | Status | When |
|---|---|---|
permission_not_grantable_to_scope |
422 | POST/PATCH includes a permission the given scope isn’t allowed to hold (see App-confined permissions). |
unknown_permission |
422 | A permission key that doesn’t exist, or another Application’s app.* key. |
role_escalation_forbidden |
403 | A platform key with a permission its creator doesn’t hold. |
forbidden |
403 | rate_limit_per_minute sent without platform api_keys.manage (see Per-key rate limits). |
user_token_required |
400 | An API Key tried to create, change, rotate, or revoke an API Key. |
read_only_field |
422 | A PATCH tried to change scope, mode, or intended_use. |
api_key_not_found |
404 | {id} doesn’t resolve, or isn’t visible to the caller. A revoked key is still fetchable by id (with revoked_at set), per Conventions → Filtering. |
api_key_revoked |
409 | PATCH, rotate, or DELETE on a key that’s already revoked or past expires_at. |
destructive_operation_restricted |
403 | A restrict_destructive: true key attempted an operation classified as destructive — see Agent keys & restrict_destructive. Not specific to key creation; this is returned by any endpoint the key calls. |