# API keys

> Every api keys endpoint, generated from the API's own schemas.

Base URL `https://pidgeon.ai/api/v1`. Every request carries `Authorization: Bearer pgn_live_…`. The machine-readable version of this page is https://pidgeon.ai/openapi.json.

## GET /api-keys

List the keys in this workspace

Names, prefixes, what each reaches and when it was last used — never a secret. The list is what you read before revoking something.

**Responses**

- `200` A page of keys.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /api-keys

Mint a key

`identity_ids` is required and must be a subset of what the calling key already reaches: a key cannot delegate reach it does not have. The `token` is in this response and nowhere else, ever.

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |
| `identity_ids` | uuid[] | no |  |
| `pod_id` | uuid | no | The pod this belongs to — your account’s default when you name none. A pod is a sending boundary: mail from it lands on its own reputation, so one of your customers cannot damage another’s delivery. It does not change what an address can do, and no key is scoped to one. |
| `access` | enum | no | What a key may do. Give an agent the narrowest one that works. One of: `read`, `send`, `admin`. |
| `expires_at` | date-time \| null | no | When it stops accepting mail. Expiry suspends; it never deletes, so the correspondence survives. |
| `test` | boolean | no | A test-mode key (`pgn_test_`): everything works, nothing is sent outside Pidgeon, and nothing counts against the plan. A test key mints only test keys. |

**Responses**

- `201` The key, with its token.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `402` Your plan does not include this. `error.message` says what does.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## GET /api-keys/{id}

Fetch one key

What it reaches and when it was last used. Never the secret — that is shown once at creation and nothing can produce it again.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

- `200` The key.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## PATCH /api-keys/{id}

Rename a key

Its name and its secret (`POST /api-keys/{id}/rotate`) are the only things about a key that can change. Not its scope — a key's reach is what somebody granted when they made it, and widening that in place would let a credential already in circulation reach further than whoever issued it agreed to.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes |  |

**Responses**

- `200` The key.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## DELETE /api-keys/{id}

Revoke a key, and everything it minted

Transitive, and that is the point: a leaked key would otherwise mint a second one and survive its own rotation. A key may revoke itself and keys it made — not a sibling, and not its parent.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Responses**

- `200` Revoked.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.

## POST /api-keys/{id}/rotate

Give a key a new secret

A new secret for the same key id. Everything that hangs off the id is unchanged — its scope, its ceiling, its expiry, and **every key it minted**, which keep working — so rotating an admin key does not take its children down. Revoking is still how a key and its children end together.

The old secret stops now, or after `grace_period_seconds` (at most 86400, a day), so the servers that hold it can move over. One previous secret at a time: rotating again inside the window ends the older one immediately.

A key may rotate itself and keys it minted; a person must own the workspace. The `token` is in this response and nowhere else, ever.

**Parameters**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `grace_period_seconds` | number | no |  |

**Responses**

- `200` The key, with its new token.
- `400` The request is not valid. `error.field` names the input at fault when there is one.
- `401` No API key, or one that is not valid.
- `403` Refused. `error.code` says why: `forbidden` when this key may not do this or the account is on hold, `abuse_blocked` when a limit says no.
- `404` No such resource, or one this key cannot reach. The two answer the same, so treat a 404 as “not available to you” rather than “does not exist”.
- `409` It conflicts with what is there: it already exists, or the `etag` you sent is not the current one.
- `429` Rate limited. `X-RateLimit-Reset` says when to retry.
- `500` Something went wrong on our side. Retry; if it keeps happening, tell us.
- `503` Something this needs is not answering right now. Retry in a moment.
