Everything the Settings → Developers screen does, with keys:read and
keys:write. This is what makes "rotate every key every 90 days" a cron job
instead of a reminder.
Two rules bound everything here, and both are intersections applied before anything is minted:
- a key never carries more than the key that made it. The scopes you ask
for are cut down to what the calling key itself carries, and then to what the
person who created the calling key still may grant. Anything left off comes
back in
droppedand the key is still created; nothing left at all is403; - a key never outlives the key that made it.
expires_in_daysis clamped to the calling key's own expiry rather than refused, so a client that omits it keeps working.
A key you create this way has origin: "api".
What a key cannot do to keys
- Read a key. Only a hash is stored. No endpoint, no support request and no database access can produce one.
- Reach another workspace's. Every id is looked up with the calling key's
own workspace, so another workspace's key id is
404. - Grant itself more. Both rules at the top of this section are applied before the write, in the database.
Operations
GET /v1/keys
List API keys
Scope keys:read.
Every key this workspace holds, newest first, including revoked and expired ones: the row is the record that a credential was issued here and stopped working. Never the keys themselves, which are not stored.
Read lost_scopes on each row. Anything in it means the person who created that key no
longer holds the permission a scope stands on, so the key is quietly doing less than it
was issued to do.
Parameters
| Name | In | Type | |
|---|---|---|---|
limit |
query | integer |
Page size, 1 to 100. Above 100 is clamped; anything else is the default. (default 50; >= 1; <= 100) |
Answers
| Status | Body | |
|---|---|---|
| 200 | ApiKeyList |
The workspace's keys. Never paged; next_cursor is always null. |
| 400 | ErrorEnvelope |
VALIDATION_ERROR: a body that is not JSON, a field that breaks its rule (named in fields), a bad query value or cursor, or a malformed Idempotency-Key. |
| 401 | ErrorEnvelope |
UNAUTHORIZED: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | ErrorEnvelope |
INSUFFICIENT_SCOPE: the key lacks this operation's scope. Or ORGANISATION_SUSPENDED: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 429 | ErrorEnvelope |
RATE_LIMITED: too many requests. Wait for Retry-After seconds. |
| 500 | ErrorEnvelope |
INTERNAL_ERROR: ours. Safe to retry; send the same Idempotency-Key on a POST. |
POST /v1/keys
Create an API key
Scope keys:write. Idempotency-Key is required.
Issues a key for this workspace and returns it ONCE, as secret. Only a hash is stored,
so no endpoint can produce it again: store it where your servers read their secrets, and
rotate it if you lose it.
Idempotency-Key is required. Minting a credential is the one operation where a retried
network timeout cannot be told apart from a request that never landed, and the only safe
move without a key would be to create a second credential. A replay answers the same body
with secret: null.
What you asked for is not necessarily what you get. The scopes are bounded twice: by the
scopes the calling key itself carries, and by the permissions the person who created the
calling key still holds. Anything left off is listed in dropped, and the key is still
created. Nothing left at all is 403 INSUFFICIENT_SCOPE.
The expiry is clamped to the calling key's own, so a child never outlives its parent. The
organisation's plan sets how many live keys a workspace may hold (402 PLAN_LIMIT, and
the message names the number).
Request body (required)
| Field | Type | |
|---|---|---|
name |
string |
What this key is for, so it can be told apart from the next one in a list. (required; min 1; max 60) |
scopes |
Scope[] |
Asked for, not granted. A scope the calling key does not itself carry, or whose permission its creator does not hold, is left off and listed in dropped. (required; >= 1 items) |
expires_in_days |
integer |
Omit for a key that does not expire. Clamped to the calling key's own expiry, so a child never outlives its parent. (>= 1; <= 365) |
{
"name": "<name>",
"scopes": []
}
Answers
| Status | Body | |
|---|---|---|
| 201 | ApiKeyCreated |
Created. secret is the key, and this is the only response that carries it. |
| 400 | ErrorEnvelope |
VALIDATION_ERROR: a body that is not JSON, a field that breaks its rule (named in fields), a bad query value or cursor, or a malformed Idempotency-Key. |
| 401 | ErrorEnvelope |
UNAUTHORIZED: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | ErrorEnvelope |
PLAN_LIMIT: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | ErrorEnvelope |
INSUFFICIENT_SCOPE: the key lacks this operation's scope. Or ORGANISATION_SUSPENDED: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 409 | ErrorEnvelope |
CONFLICT: the request clashes with the object's current state (the message says how). IDEMPOTENCY_KEY_CONFLICT / IDEMPOTENCY_KEY_IN_FLIGHT: the Idempotency-Key was used with another request, or its first request is still running. |
| 413 | ErrorEnvelope |
PAYLOAD_TOO_LARGE: the body is over 64 KB. |
| 429 | ErrorEnvelope |
RATE_LIMITED: too many requests. Wait for Retry-After seconds. |
| 500 | ErrorEnvelope |
INTERNAL_ERROR: ours. Safe to retry; send the same Idempotency-Key on a POST. |
PATCH /v1/keys/{id}
Rename a key, or change its scopes
Scope keys:write. Another workspace's answers 404.
Changes the name, the scopes, or both. The key itself is untouched, so whatever holds it keeps working: narrowing a key no longer means replacing it everywhere it is deployed.
scopes is the new set in full, not a list to add. Adding one is bounded exactly as
creating a key is, and anything left off is in dropped. Removing one is always allowed.
Parameters
| Name | In | Type | |
|---|---|---|---|
id |
path | uuid |
An id this API gave you. Anything that is not one of yours is 404. (required) |
Request body (required)
| Field | Type | |
|---|---|---|
name |
string |
(min 1; max 60) |
scopes |
Scope[] |
The new set, in full. The key itself does not change, so whatever holds it keeps working. (>= 1 items) |
Answers
| Status | Body | |
|---|---|---|
| 200 | ApiKeyUpdated |
The key, as it now is. |
| 400 | ErrorEnvelope |
VALIDATION_ERROR: a body that is not JSON, a field that breaks its rule (named in fields), a bad query value or cursor, or a malformed Idempotency-Key. |
| 401 | ErrorEnvelope |
UNAUTHORIZED: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | ErrorEnvelope |
INSUFFICIENT_SCOPE: the key lacks this operation's scope. Or ORGANISATION_SUSPENDED: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | ErrorEnvelope |
NOT_FOUND: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | ErrorEnvelope |
PAYLOAD_TOO_LARGE: the body is over 64 KB. |
| 429 | ErrorEnvelope |
RATE_LIMITED: too many requests. Wait for Retry-After seconds. |
| 500 | ErrorEnvelope |
INTERNAL_ERROR: ours. Safe to retry; send the same Idempotency-Key on a POST. |
DELETE /v1/keys/{id}
Revoke a key
Scope keys:write. Another workspace's answers 404.
Stops the key working, at once and for good. The row stays, so a credential that turns up in a log next month is still identifiable as one that was issued here and switched off.
Revoking a key twice is 404, like a key that does not exist and like another
workspace's: the second call would otherwise move the revocation time and erase when it
happened.
Parameters
| Name | In | Type | |
|---|---|---|---|
id |
path | uuid |
An id this API gave you. Anything that is not one of yours is 404. (required) |
Answers
| Status | Body | |
|---|---|---|
| 200 | ApiKey |
The key, now revoked. |
| 401 | ErrorEnvelope |
UNAUTHORIZED: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | ErrorEnvelope |
INSUFFICIENT_SCOPE: the key lacks this operation's scope. Or ORGANISATION_SUSPENDED: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | ErrorEnvelope |
NOT_FOUND: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | ErrorEnvelope |
RATE_LIMITED: too many requests. Wait for Retry-After seconds. |
| 500 | ErrorEnvelope |
INTERNAL_ERROR: ours. Safe to retry; send the same Idempotency-Key on a POST. |
POST /v1/keys/{id}/rotate
Rotate a key
Scope keys:write. Idempotency-Key is required. Another workspace's answers 404.
Issues a replacement with the same scopes and the same expiry, and retires this one. The
replacement is returned ONCE, as secret.
THE REPLACEMENT HAS A NEW ID. A caller that stores the id as well as the value has to
store both again. rotated_from on the new key names the old one.
grace_seconds keeps the old key working for a while, so the new one can reach every
deployment before anything breaks. Zero, the default, stops it at once, which is what you
want when you are rotating because the old value leaked. The answer says exactly when the
old key stops, in previous_valid_until.
Idempotency-Key is required, for the same reason it is on creating a key. A replay
answers secret: null.
Parameters
| Name | In | Type | |
|---|---|---|---|
id |
path | uuid |
An id this API gave you. Anything that is not one of yours is 404. (required) |
Request body (optional)
| Field | Type | |
|---|---|---|
grace_seconds |
integer |
How long the old key keeps working, so the new one can reach every deployment first. Zero, the default, stops it at once, which is what you want when the old value leaked. (>= 0; <= 86400) |
Answers
| Status | Body | |
|---|---|---|
| 201 | ApiKeyRotated |
The replacement. secret is the new key, and the only response carrying it. |
| 400 | ErrorEnvelope |
VALIDATION_ERROR: a body that is not JSON, a field that breaks its rule (named in fields), a bad query value or cursor, or a malformed Idempotency-Key. |
| 401 | ErrorEnvelope |
UNAUTHORIZED: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | ErrorEnvelope |
INSUFFICIENT_SCOPE: the key lacks this operation's scope. Or ORGANISATION_SUSPENDED: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | ErrorEnvelope |
NOT_FOUND: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | ErrorEnvelope |
CONFLICT: the request clashes with the object's current state (the message says how). IDEMPOTENCY_KEY_CONFLICT / IDEMPOTENCY_KEY_IN_FLIGHT: the Idempotency-Key was used with another request, or its first request is still running. |
| 413 | ErrorEnvelope |
PAYLOAD_TOO_LARGE: the body is over 64 KB. |
| 429 | ErrorEnvelope |
RATE_LIMITED: too many requests. Wait for Retry-After seconds. |
| 500 | ErrorEnvelope |
INTERNAL_ERROR: ours. Safe to retry; send the same Idempotency-Key on a POST. |
Shapes
The objects these operations send and answer.
ApiKey
| Field | Type | |
|---|---|---|
id |
uuid |
(required) |
name |
string | null |
(required; max 60) |
prefix |
string | null |
The first 8 characters of the key's random body, so a key is recognisable in a list. Never the key. (required) |
scopes |
Scope[] |
What the key was issued to do. (required) |
effective_scopes |
Scope[] |
What the key may do right now: scopes, minus anything whose permission the person who created it no longer holds. This is what a request is judged against. (required) |
lost_scopes |
Scope[] |
scopes minus effective_scopes. Normally empty. Anything here means the person who created the key lost the permission behind it, or left the workspace: restore their permission, or issue the key again from somebody who holds it. (required) |
origin |
ApiKeyOrigin |
How the key came to exist: dashboard in the BoltClaw dashboard, api minted by another key through this API, device or oauth approved for an AI assistant. Never an input to what a key may do. (required; one of dashboard, api, device, oauth) |
client_name |
string | null |
The client a person approved for an assistant's key. Null for every other key. (required; max 120) |
last_used_at |
date-time | null |
(required) |
last_used_ip |
string | null |
The address the key was last seen from, recorded at most once a minute. For your own eyes: nothing reads it back, and no decision depends on it. (required) |
expires_at |
date-time | null |
When the key stops working, or null for a key that does not expire. (required) |
rotated_from |
uuid | null |
The key this one replaced. Rotation makes a new key, with a new id. (required) |
revoked_at |
date-time | null |
When the key stops working. A value in the FUTURE is a rotation grace window that is still open: the key works until then. (required) |
created_at |
date-time |
(required) |
ApiKeyCreate
| Field | Type | |
|---|---|---|
name |
string |
What this key is for, so it can be told apart from the next one in a list. (required; min 1; max 60) |
scopes |
Scope[] |
Asked for, not granted. A scope the calling key does not itself carry, or whose permission its creator does not hold, is left off and listed in dropped. (required; >= 1 items) |
expires_in_days |
integer |
Omit for a key that does not expire. Clamped to the calling key's own expiry, so a child never outlives its parent. (>= 1; <= 365) |
ApiKeyCreated
| Field | Type | |
|---|---|---|
id |
uuid |
(required) |
name |
string | null |
(required; max 60) |
prefix |
string | null |
The first 8 characters of the key's random body, so a key is recognisable in a list. Never the key. (required) |
scopes |
Scope[] |
What the key was issued to do. (required) |
effective_scopes |
Scope[] |
What the key may do right now: scopes, minus anything whose permission the person who created it no longer holds. This is what a request is judged against. (required) |
lost_scopes |
Scope[] |
scopes minus effective_scopes. Normally empty. Anything here means the person who created the key lost the permission behind it, or left the workspace: restore their permission, or issue the key again from somebody who holds it. (required) |
origin |
ApiKeyOrigin |
How the key came to exist: dashboard in the BoltClaw dashboard, api minted by another key through this API, device or oauth approved for an AI assistant. Never an input to what a key may do. (required; one of dashboard, api, device, oauth) |
client_name |
string | null |
The client a person approved for an assistant's key. Null for every other key. (required; max 120) |
last_used_at |
date-time | null |
(required) |
last_used_ip |
string | null |
The address the key was last seen from, recorded at most once a minute. For your own eyes: nothing reads it back, and no decision depends on it. (required) |
expires_at |
date-time | null |
When the key stops working, or null for a key that does not expire. (required) |
rotated_from |
uuid | null |
The key this one replaced. Rotation makes a new key, with a new id. (required) |
revoked_at |
date-time | null |
When the key stops working. A value in the FUTURE is a rotation grace window that is still open: the key works until then. (required) |
created_at |
date-time |
(required) |
dropped |
Scope[] |
Scopes that were asked for and not granted. Compare it with scopes. (required) |
secret |
string | null |
The key itself: bc_live_ and 43 letters and digits. SHOWN ONCE. Only a hash is stored and no endpoint can produce it again. Null on an idempotent replay, which tells you the key was already created; rotate it if you lost the value. (required) |
ApiKeyList
| Field | Type | |
|---|---|---|
data |
ApiKey[] |
(required) |
next_cursor |
NullCursor |
Always null; this list is never paged. (required) |
ApiKeyOrigin
How the key came to exist: dashboard in the BoltClaw dashboard, api minted by another
key through this API, device or oauth approved for an AI assistant. Never an input to
what a key may do.
One of: dashboard, api, device, oauth.
ApiKeyRotate
| Field | Type | |
|---|---|---|
grace_seconds |
integer |
How long the old key keeps working, so the new one can reach every deployment first. Zero, the default, stops it at once, which is what you want when the old value leaked. (>= 0; <= 86400) |
ApiKeyRotated
| Field | Type | |
|---|---|---|
id |
uuid |
(required) |
name |
string | null |
(required; max 60) |
prefix |
string | null |
The first 8 characters of the key's random body, so a key is recognisable in a list. Never the key. (required) |
scopes |
Scope[] |
What the key was issued to do. (required) |
effective_scopes |
Scope[] |
What the key may do right now: scopes, minus anything whose permission the person who created it no longer holds. This is what a request is judged against. (required) |
lost_scopes |
Scope[] |
scopes minus effective_scopes. Normally empty. Anything here means the person who created the key lost the permission behind it, or left the workspace: restore their permission, or issue the key again from somebody who holds it. (required) |
origin |
ApiKeyOrigin |
How the key came to exist: dashboard in the BoltClaw dashboard, api minted by another key through this API, device or oauth approved for an AI assistant. Never an input to what a key may do. (required; one of dashboard, api, device, oauth) |
client_name |
string | null |
The client a person approved for an assistant's key. Null for every other key. (required; max 120) |
last_used_at |
date-time | null |
(required) |
last_used_ip |
string | null |
The address the key was last seen from, recorded at most once a minute. For your own eyes: nothing reads it back, and no decision depends on it. (required) |
expires_at |
date-time | null |
When the key stops working, or null for a key that does not expire. (required) |
rotated_from |
uuid | null |
The key this one replaced. Rotation makes a new key, with a new id. (required) |
revoked_at |
date-time | null |
When the key stops working. A value in the FUTURE is a rotation grace window that is still open: the key works until then. (required) |
created_at |
date-time |
(required) |
dropped |
Scope[] |
Scopes that were asked for and not granted. Compare it with scopes. (required) |
secret |
string | null |
The key itself: bc_live_ and 43 letters and digits. SHOWN ONCE. Only a hash is stored and no endpoint can produce it again. Null on an idempotent replay, which tells you the key was already created; rotate it if you lost the value. (required) |
previous_valid_until |
date-time | null |
When the key this replaced stops working. Null when it stopped at once. (required) |
ApiKeyUpdate
| Field | Type | |
|---|---|---|
name |
string |
(min 1; max 60) |
scopes |
Scope[] |
The new set, in full. The key itself does not change, so whatever holds it keeps working. (>= 1 items) |
ApiKeyUpdated
| Field | Type | |
|---|---|---|
id |
uuid |
(required) |
name |
string | null |
(required; max 60) |
prefix |
string | null |
The first 8 characters of the key's random body, so a key is recognisable in a list. Never the key. (required) |
scopes |
Scope[] |
What the key was issued to do. (required) |
effective_scopes |
Scope[] |
What the key may do right now: scopes, minus anything whose permission the person who created it no longer holds. This is what a request is judged against. (required) |
lost_scopes |
Scope[] |
scopes minus effective_scopes. Normally empty. Anything here means the person who created the key lost the permission behind it, or left the workspace: restore their permission, or issue the key again from somebody who holds it. (required) |
origin |
ApiKeyOrigin |
How the key came to exist: dashboard in the BoltClaw dashboard, api minted by another key through this API, device or oauth approved for an AI assistant. Never an input to what a key may do. (required; one of dashboard, api, device, oauth) |
client_name |
string | null |
The client a person approved for an assistant's key. Null for every other key. (required; max 120) |
last_used_at |
date-time | null |
(required) |
last_used_ip |
string | null |
The address the key was last seen from, recorded at most once a minute. For your own eyes: nothing reads it back, and no decision depends on it. (required) |
expires_at |
date-time | null |
When the key stops working, or null for a key that does not expire. (required) |
rotated_from |
uuid | null |
The key this one replaced. Rotation makes a new key, with a new id. (required) |
revoked_at |
date-time | null |
When the key stops working. A value in the FUTURE is a rotation grace window that is still open: the key works until then. (required) |
created_at |
date-time |
(required) |
dropped |
Scope[] |
Scopes that were asked for and not granted. (required) |
NullCursor
Always null; this list is never paged.
any | null.