# API keys

The workspace's API keys, and what each may do.

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 `dropped` and the key is still created; nothing left at all is `403`;
- **a key never outlives the key that made it.** `expires_in_days` is 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) |

```json
{
  "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`.

<!-- https://boltclaw.ai/docs/api/api-keys as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
