---
title: "API keys"
description: "List, create, rotate and revoke API keys."
canonical: "https://writavo.com/docs/api/api-keys"
last-updated: "2026-09-30"
---

# API keys

List, create, rotate and revoke API keys.

Base URL: `https://api.writavo.com/v1`

### GET /keys

List API keys

- **Operation id**: `listApiKeys`
- **Scope**: `keys:read`
- **Permission**: `api_keys.manage`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Metadata only. The secret itself is shown once, at creation, and is never retrievable
afterwards because only a hash is stored. `key_prefix` is the displayable fragment you
use to tell keys apart.

Requires a secret key carrying `keys:read`. A publishable key can never read this.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse or build one; its encoding will change. |
| `limit` | query | integer | no | Page size. Values above the maximum are clamped, not rejected: ask for a thousand and you get a hundred and a `next_cursor`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Page | A page of keys. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `INSUFFICIENT_SCOPE`. The key lacks this operation's scope, or its creator's permissions no longer cover it. Another Site's object is always 404, never this. A 403 may also be: - `AGENT_ACCESS_DISABLED` - AI agent access is turned off. - `APPROVAL_DENIED` - a person denied this action. - `FORBIDDEN` (organisation, team, settings) - a change no scope allows, such as an AI agent changing the access of the person it acts for, anything about ownership, or a setting only a person may change. The message says what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl https://api.writavo.com/v1/keys \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /keys

Create an API key

- **Operation id**: `createApiKey`
- **Scope**: `keys:write`
- **Permission**: `api_keys.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Returns the secret exactly once, in `data.secret`. Store it immediately. There is no way
to recover it later, and support cannot retrieve it for you.

A key can never be created with more authority than its creator has. The scopes you
request are intersected with the creator's own permissions, and the granted set comes
back in `data.scopes`. If you asked for something you do not hold, the key is still
created, without it. Compare what you asked for against what you got.

A secret key's authority is also re-evaluated on every request against its creator's
live permissions, so revoking a person's access immediately narrows every key they made.

**A retry does not give you the secret again.** `Idempotency-Key` guarantees you created
one key rather than two, and a replayed response returns the key's `id`, `kind`,
`key_prefix` and `scopes` with `secret: null` and `secret_replayable: false`. The secret
is never stored anywhere, including in the idempotency record, because storing it would
put a live credential in a database backup. If you lost it, rotate the key.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | yes | A unique key per logical operation, 1 to 255 printable ASCII characters (a UUID works), scoped to the Site and endpoint and kept for 24 hours. - Same key and body: the original response is replayed, so retrying a timeout is safe. - Same key, different body: `409 IDEMPOTENCY_KEY_CONFLICT`. - Same key while the first is running: `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | A label for humans. Say where the key lives, so you know what breaks if you revoke it. |
| `kind` | KeyKind | yes | `publishable` (`wv_pub_`): safe in client code, read only, published content only, and only on operations marked `x-publishable: true`; anything else is `403 INSUFFICIENT_SCOPE`. `secret` (`wv_sk_`): server side only. |
| `scopes` | Scope[] | yes |  |
| `expires_at` | date-time | null | no | Optional expiry. After it passes the key returns `401 API_KEY_EXPIRED`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | ApiKey | The key was created. This is the only time the secret is returned. |
| 400 | - | `INVALID_REQUEST`. Bad JSON, an unusable query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `INSUFFICIENT_SCOPE`. The key lacks this operation's scope, or its creator's permissions no longer cover it. Another Site's object is always 404, never this. A 403 may also be: - `AGENT_ACCESS_DISABLED` - AI agent access is turned off. - `APPROVAL_DENIED` - a person denied this action. - `FORBIDDEN` (organisation, team, settings) - a change no scope allows, such as an AI agent changing the access of the person it acts for, anything about ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | The request collides with the current state. Read `code`: - `SLUG_CONFLICT` - the slug is taken. - `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT` - key reused with another body, or still running. - `CONFLICT` - the object changed under you. Re-read and retry. - `APPROVAL_INVALID` - the `Writavo-Approval` is expired, used, unknown, for another request, or its effect changed. Retry without it for a new approval. - `PREREQUISITE_MISSING` / `FEATURE_UNAVAILABLE` (settings, delivery, SEO) - something is not set up (the message names what sets it up), or Writavo has it switched off (the message names the free alternative). Nothing was done or charged. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/keys \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "<name>",
  "kind": "publishable",
  "scopes": "<scopes>"
}'
```

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

Rotate an API key

- **Operation id**: `rotateApiKey`
- **Scope**: `keys:write`
- **Permission**: `api_keys.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Issues a new secret for the same key record, keeping its name, and returns it once.

Only the person who created the key, an organisation owner or a platform admin may rotate
it: a rotation hands over a live credential that keeps acting with its creator's
permissions. Anyone else with `api_keys.manage` gets `403 INSUFFICIENT_SCOPE`. The new
secret's scopes are the old key's scopes limited to what the caller could grant today, and
a rotation that would leave no scopes is refused with `403 INSUFFICIENT_SCOPE`.

As with creation, a replayed retry returns `secret: null` and `secret_replayable: false`
rather than handing out the credential a second time. Rotate again if you lost it.

Set `grace_seconds` to keep the old secret working while you deploy the new one. During
the grace window both work. After it, the old one returns `401 API_KEY_REVOKED`. A grace
of `0` cuts the old secret off immediately, which is the right choice if you are rotating
because it leaked.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `Idempotency-Key` | header | string | yes | A unique key per logical operation, 1 to 255 printable ASCII characters (a UUID works), scoped to the Site and endpoint and kept for 24 hours. - Same key and body: the original response is replayed, so retrying a timeout is safe. - Same key, different body: `409 IDEMPOTENCY_KEY_CONFLICT`. - Same key while the first is running: `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. |

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `grace_seconds` | integer | no |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ApiKey | Rotated. The new secret is returned once. |
| 400 | - | `INVALID_REQUEST`. Bad JSON, an unusable query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `INSUFFICIENT_SCOPE`. The key lacks this operation's scope, or its creator's permissions no longer cover it. Another Site's object is always 404, never this. A 403 may also be: - `AGENT_ACCESS_DISABLED` - AI agent access is turned off. - `APPROVAL_DENIED` - a person denied this action. - `FORBIDDEN` (organisation, team, settings) - a change no scope allows, such as an AI agent changing the access of the person it acts for, anything about ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. No such object, or it belongs to another Site. **The two are deliberately indistinguishable and never return 403.** If the id is right, check you are using the right Site's key. |
| 409 | - | The request collides with the current state. Read `code`: - `SLUG_CONFLICT` - the slug is taken. - `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT` - key reused with another body, or still running. - `CONFLICT` - the object changed under you. Re-read and retry. - `APPROVAL_INVALID` - the `Writavo-Approval` is expired, used, unknown, for another request, or its effect changed. Retry without it for a new approval. - `PREREQUISITE_MISSING` / `FEATURE_UNAVAILABLE` (settings, delivery, SEO) - something is not set up (the message names what sets it up), or Writavo has it switched off (the message names the free alternative). Nothing was done or charged. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/keys/3f1b0c7a-0000-4000-8000-000000000001/rotate \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /keys/{id}

Revoke an API key

- **Operation id**: `revokeApiKey`
- **Scope**: `keys:write`
- **Permission**: `api_keys.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Immediate and permanent. The key record is kept, marked revoked, so the audit trail
survives, but the secret stops working at once and returns `401 API_KEY_REVOKED`.

Revoking the key you are calling with is allowed. It is the last request that key makes.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 204 | - | Revoked. No body. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `INSUFFICIENT_SCOPE`. The key lacks this operation's scope, or its creator's permissions no longer cover it. Another Site's object is always 404, never this. A 403 may also be: - `AGENT_ACCESS_DISABLED` - AI agent access is turned off. - `APPROVAL_DENIED` - a person denied this action. - `FORBIDDEN` (organisation, team, settings) - a change no scope allows, such as an AI agent changing the access of the person it acts for, anything about ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. No such object, or it belongs to another Site. **The two are deliberately indistinguishable and never return 403.** If the id is right, check you are using the right Site's key. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X DELETE https://api.writavo.com/v1/keys/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /auth/key/extend

Extend the calling key's expiry

- **Operation id**: `extendApiKey`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Pushes the expiry of the key you are calling with further out, so a sign-in that is in
regular use does not stop working after 90 days. Acts on the calling key only: there is
no id to send.

Only a key obtained through a device or OAuth sign-in can extend itself, and only while
it is live, its creator can still manage API keys on the Site, and the organisation has
not turned AI agent access off. Anything else is `403 FORBIDDEN` with the reason. A key
made in the dashboard keeps the expiry the person chose.

Nothing changes until the key is within 30 days of expiry; before that you get
`extended: false` and the current `expires_at`, so it is safe to call on every start. An
extension moves the expiry to 90 days from now, but never past 365 days from the key's
creation (`extended: false`, `reason: max_lifetime`), after which a person signs in
again. Needs no scope; a publishable key gets `403 INSUFFICIENT_SCOPE`.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The key's expiry, extended or not. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/key/extend \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /auth/key/revoke

Revoke the calling key

- **Operation id**: `revokeCurrentApiKey`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Revokes the key you are calling with, immediately. It is the last request that key makes;
every later one returns `401 API_KEY_REVOKED`. This is what signing out of a command line
tool or an MCP client should call, so the credential ends on the server rather than only
being forgotten on the machine.

Any secret key may revoke itself, whatever its scopes. Acts on the calling key only:
there is no id to send. To revoke a different key use `DELETE /keys/{id}`.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | Revoked. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/key/revoke \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### GET /auth/key/sites

Internal: the Sites an AI connection reaches

- **Operation id**: `listConnectionSites`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: read
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

**Internal. Only the Writavo MCP service can call this; anyone else gets `403`.** A hosted
MCP connection can reach several Sites: the one it was approved on (its default) and the
ones the person added on the consent screen or in Settings > AI agents, or every Site in
the organisation when they chose that. A key still belongs to one Site, so the connection
is a group of keys, one per Site. This lists the Sites of the connection the calling key
is the sign-in of, and the state of each: `default`, `connected` (its key exists), `ready`
(its key is registered on first use), `no_permission` or `agents_off`.

The calling key must be the key a hosted sign-in minted. Which Sites a connection reaches
is decided by a person in the dashboard, never through the API.

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The connection's Sites. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl https://api.writavo.com/v1/auth/key/sites \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /auth/key/sites

Internal: register an AI connection's key for one of its Sites

- **Operation id**: `mintConnectionSiteKey`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

**Internal. Only the Writavo MCP service can call this; anyone else gets `403`.** Registers
the key a hosted MCP connection uses on one of its further Sites, the first time an
assistant works there. As with sign-in, the secret never travels: the service sends its
SHA-256 hash and display prefix. The key gets the connection's permissions, limited to
what the person who approved the connection may grant on that Site.

This cannot widen a connection. The Site must already be one the person put in it (`ready`
in `GET /auth/key/sites`); any other Site is `404`. Sending the same hash again answers
`200` with the existing key.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `website_id` | uuid | yes |  |
| `key_hash` | string | yes |  |
| `key_prefix` | string | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | - | The Site already had this key. |
| 201 | - | The key was registered. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`: no key, or an unusable one. It never distinguishes "no such key" from "wrong key". |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 404 | - | `NOT_FOUND`. No such object, or it belongs to another Site. **The two are deliberately indistinguishable and never return 403.** If the id is right, check you are using the right Site's key. |
| 409 | - | The request collides with the current state. Read `code`: - `SLUG_CONFLICT` - the slug is taken. - `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT` - key reused with another body, or still running. - `CONFLICT` - the object changed under you. Re-read and retry. - `APPROVAL_INVALID` - the `Writavo-Approval` is expired, used, unknown, for another request, or its effect changed. Retry without it for a new approval. - `PREREQUISITE_MISSING` / `FEATURE_UNAVAILABLE` (settings, delivery, SEO) - something is not set up (the message names what sets it up), or Writavo has it switched off (the message names the free alternative). Nothing was done or charged. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/key/sites \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "website_id": "3f1b0c7a-0000-4000-8000-000000000001",
  "key_hash": "<key_hash>",
  "key_prefix": "<key_prefix>"
}'
```
