# API keys

Key management. Specified here, implemented in API-2.

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 it or construct one; its encoding is not part of this contract and will change. |
| `limit` | query | integer | no | Page size. Values above the maximum are clamped rather than rejected, so a client asking for a thousand rows gets 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`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `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 you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. |

**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` keys are prefixed `wv_pub_` and are safe in client code: read only, limited to published content, and admitted only to the operations marked `x-publishable: true`. Anything else returns `403 INSUFFICIENT_SCOPE`, whatever scopes the key carries. `secret` keys are prefixed `wv_sk_` and are 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`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `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 scopes, and returns it
once.

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 you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. |

**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`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `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`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. This is the **only** 403 in the API. In particular it is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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