---
title: "Media"
description: "The media library. Two step upload, then registration."
canonical: "https://writavo.com/docs/api/media"
last-updated: "2026-09-30"
---

# Media

The media library. Two step upload, then registration.

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

### GET /media

List media assets

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

The media library, newest first.

`unused=true` lists only the files nothing on this Site uses: no article (in any status,
or any of its saved revisions), author, branding, entry or entry revision references the
file by its URL or its id. It is computed live on every page, so a file you just put in an
article drops out of the next page. When in doubt a file counts as used, never the other
way round. Pass the ids to `POST /media/bulk-delete` to remove them in one call.

**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`. |
| `bucket` | query | MediaBucket | no |  |
| `unused` | query | boolean | no | `true`: only files nothing on this Site references. Computed live, and slower than the plain list on a large Site. |
| `fields` | query | string | no | Comma separated field allow list. Default projection: `id, bucket, url, file_name, mime_type, size_bytes, width, height, alt_text, created_at`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Page | A page of media assets. |
| 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. |
| 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 https://api.writavo.com/v1/media \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /media

Register an uploaded file

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

Step three of the upload. Call this after your `PUT` to the presigned URL succeeds.

You send the `upload_id` you were given, not a path. The server already knows where the
file went, which is what stops a client naming an arbitrary storage location.

The server inspects the stored bytes here: it reads the real content type from the file
itself and checks the size and image dimensions. A file whose actual type is not an
allowed image is rejected with `422 VALIDATION_FAILED` and deleted from storage, so a
rejected upload leaves nothing behind. The declared type you sent to `/media/upload-url`
is treated as a hint only and never trusted.

If you never call this, the uploaded object is swept and the reservation expires. A file
with no `media_assets` row is not part of your library.

**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 |
|---|---|---|---|
| `upload_id` | uuid | yes | From `POST /media/upload-url`. |
| `alt_text` | string | null | no | Accessibility text. Worth sending. It is what screen readers announce and what search engines read, and there is no way to generate it for you. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | MediaAsset | The asset is registered and in your library. |
| 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. |
| 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/media \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "upload_id": "3f1b0c7a-0000-4000-8000-000000000001"
}'
```

### POST /media/upload-url

Request an upload URL

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

Step one of the upload. Returns a short lived presigned URL and an `upload_id`.

The upload is a three step handshake:

1. `POST /media/upload-url` with the filename and declared content type.
2. `PUT` the raw bytes to `upload_url`. Send no authorization header; the signature in
   the URL is the credential.
3. `POST /media` with the `upload_id` to register the asset.

The storage location is chosen by the server and namespaced to your Site. You cannot
influence it, and no other Site's key can produce a URL that writes into your namespace.

The URL expires. If your `PUT` is slow or fails, request a new one; do not retry an
expired signature.

`upload_url` is a bearer credential, so it is **not replayed**: a retry with the same
`Idempotency-Key` returns the same `upload_id` with `upload_url: null` and
`upload_url_replayable: false`. Request a fresh reservation instead.

**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 |
|---|---|---|---|
| `file_name` | string | yes | The original filename. It is sanitised before use and never becomes a path on its own, so traversal sequences are stripped rather than rejected. |
| `content_type` | string | yes | The MIME type you believe you are uploading. A hint only. The real type is read from the bytes at registration and it is that check, not this field, that decides whether the file is accepted. |
| `size_bytes` | integer | yes | Declared size in bytes (required, at most 10 MB), so an over limit upload can be refused before the bytes move. Also verified after the fact: registration refuses an upload larger than what was declared here. |
| `bucket` | MediaBucket | no | Which library the asset belongs to. `blog-images` for article imagery, `author-avatars` for byline portraits. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | object | The upload was reserved. |
| 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/media/upload-url \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "file_name": "hero.webp",
  "content_type": "image/webp",
  "size_bytes": "<size_bytes>"
}'
```

### POST /media/bulk-delete

Delete many media assets

- **Operation id**: `bulkDeleteMedia`
- **Scope**: `media:write`
- **Permission**: `media.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **AI agent approval**: `media.delete`, asked while approvals are on
- **Publishable key may call it**: no

Deletes up to 500 files in one call: the catalog rows and the stored bytes, exactly as
`DELETE /media/{id}` does for each (an audit entry and a `media.deleted` webhook per file).

**Only unused files by default.** With `only_unused` left `true`, a file that anything on
this Site still uses (an article in any status or one of its saved revisions, an author,
the branding, an entry) is not deleted and is listed in `in_use`. `GET /media?unused=true`
lists the candidates. Send `only_unused: false` to delete named files even though they are
in use; articles are not rewritten, so each of those becomes a broken image.

An id that names no file on this Site, whether it never existed or belongs to another Site,
is listed in `not_found`. A repeated id counts once.

**When an AI agent asks.** A key approved for an AI assistant (a device or OAuth sign-in),
or any request from an MCP client, gets `428 APPROVAL_REQUIRED` on the first try while the
organisation requires approvals, with `error.approval.url` for a person to approve or deny
in the dashboard. ONE approval covers the whole list: the person sees how many files and
their total size. Retry the same request with `Writavo-Approval: <error.approval.id>` once
they have. The approval is for the exact files the call would delete when it was asked: if
one of them has since come into use or been deleted, the retry is `409 APPROVAL_INVALID`
and you ask again. When none of the ids is a file on this Site, no approval is asked for
and the report comes back directly. Other callers are unaffected.

**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. |
| `Writavo-Approval` | header | uuid | no | The `error.approval.id` from a `428 APPROVAL_REQUIRED`, sent on the retry once a person has approved it. Only meaningful for a key approved for an AI agent; every other caller can leave it out. It is single use and bound to the key, to the exact request that asked for it (method, path and body) and to what that request would do: anything else, including a request whose effect changed since it was approved, is `409 APPROVAL_INVALID`. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `ids` | uuid[] | yes | The media ids to delete. A repeated id counts once. |
| `only_unused` | boolean | no | `true` (the default): delete only the files nothing on this Site uses, and list the rest in `in_use`. `false`: delete every named file, even one an article still shows. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | MediaBulkDeleteResult | What happened to each id. Every id sent appears in exactly one list. |
| 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. |
| 428 | - | `APPROVAL_REQUIRED` or `APPROVAL_PENDING`. Only ever returned to an AI agent: a key from a device or OAuth sign-in, or any request an MCP client makes (it carries `Writavo-Mcp-Tool`) whatever key it holds. For an operation marked `x-writavo-approval-always` it is returned whatever the organisation's approvals switch says, and for the six content actions while approvals are on. Nothing has happened yet. Show `error.approval.url` to a person; once they approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. Do not change the body or the path: the approval is bound to the exact request, and a different one is `409 APPROVAL_INVALID`. |
| 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/media/bulk-delete \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "ids": [
    "4f6b2c1e-8a3d-4e5f-9b7a-1c2d3e4f5a6b",
    "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
  ]
}'
```

### GET /media/{id}

Read one media asset

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

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | MediaAsset | The media asset. |
| 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 https://api.writavo.com/v1/media/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### PATCH /media/{id}

Update a media asset

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

Only `alt_text` is editable. The bytes are immutable: to replace an image, upload a new
one and repoint whatever referenced the old one.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `If-Match` | header | string | no | The `ETag` from your last read. If the object changed since, you get `412 PRECONDITION_FAILED` and nothing is written. Optional in v1; without it the last write wins. Send it. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `alt_text` | string | null | no |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | MediaAsset | The updated media asset. |
| 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. |
| 412 | - | `PRECONDITION_FAILED`. Your `If-Match` no longer matches: someone edited the object. Nothing was written. Re-read, merge, and retry with the new `ETag`. |
| 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 PATCH https://api.writavo.com/v1/media/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H 'If-Match: W/"1767225600000"' \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /media/{id}

Delete a media asset

- **Operation id**: `deleteMediaAsset`
- **Scope**: `media:write`
- **Permission**: `media.manage`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **AI agent approval**: `media.delete`, asked while approvals are on
- **Publishable key may call it**: no

**Deleting several?** Use `bulkDeleteMedia` (POST .../bulk-delete): up to 500 media files in one
call, and an AI agent needs ONE approval for the whole list instead of one per item.

Removes the catalog row and the stored bytes.

**Articles are not rewritten.** If a published article embeds this image, or uses it as
its `featured_image_url`, that reference becomes a broken image on your live blog. Check
before you delete: `GET /media?unused=true` lists the files nothing on this Site uses, and
`POST /media/bulk-delete` removes many of them under one approval.

**When an AI agent asks.** A key approved for an AI assistant (a device or OAuth sign-in),
or any request from an MCP client, gets `428 APPROVAL_REQUIRED` on the first try while the
organisation requires approvals, with `error.approval.url` for a person to approve or deny
in the dashboard. Retry the same request with `Writavo-Approval: <error.approval.id>` once
they have. Other callers are unaffected.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `Writavo-Approval` | header | uuid | no | The `error.approval.id` from a `428 APPROVAL_REQUIRED`, sent on the retry once a person has approved it. Only meaningful for a key approved for an AI agent; every other caller can leave it out. It is single use and bound to the key, to the exact request that asked for it (method, path and body) and to what that request would do: anything else, including a request whose effect changed since it was approved, is `409 APPROVAL_INVALID`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 204 | - | Deleted. 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. |
| 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. |
| 428 | - | `APPROVAL_REQUIRED` or `APPROVAL_PENDING`. Only ever returned to an AI agent: a key from a device or OAuth sign-in, or any request an MCP client makes (it carries `Writavo-Mcp-Tool`) whatever key it holds. For an operation marked `x-writavo-approval-always` it is returned whatever the organisation's approvals switch says, and for the six content actions while approvals are on. Nothing has happened yet. Show `error.approval.url` to a person; once they approve, repeat the identical request with `Writavo-Approval: <error.approval.id>`. Do not change the body or the path: the approval is bound to the exact request, and a different one is `409 APPROVAL_INVALID`. |
| 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/media/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```
