---
title: "Articles"
description: "The content spine. Create, edit, organise, publish and schedule."
canonical: "https://writavo.com/docs/api/articles"
last-updated: "2026-09-30"
---

# Articles

The content spine. Create, edit, organise, publish and schedule.

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

### GET /articles

List and search articles

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

Cursor paginated, newest updated first.

**Search with `q`.** `GET /articles?q=meeting notes` matches the words in the title,
excerpt and body (with stemming, `"exact phrase"` and `-excluded`), or a part of the title.
It is a filter, so it combines with `status`, `category_id`, `tag_id`, `author_id` and
pagination, and a headless site can search its blog without downloading every summary.

The default projection deliberately omits `content`. Article bodies are large, and a
list endpoint that returns every body is the classic way to make a content API slow and
expensive. Fetch bodies one at a time with `GET /articles/{id}`, or ask for them
explicitly with `fields=id,title,content` and a small `limit`.

There is no way to ask for every field. `fields` is an allow list, not a wildcard.

A publishable key (`wv_pub_`) sees only articles at `status: published`. A secret key
sees everything, including drafts.

**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`. |
| `fields` | query | string | no | Comma separated field allow list. Any field of the Article schema may be named. Omit for the default projection, which is: `id, status, article_kind, title, slug, excerpt, featured_image_url, featured_image_alt, category_id, author_id, format_id, published_at, scheduled_publish_at, created_at, updated_at`. `id` is always returned whether or not you name it. |
| `status` | query | ArticleStatus[] | no | Filter by status. Repeat the parameter to match several. A publishable key may only ask for `published`, and any other value is rejected with `403 INSUFFICIENT_SCOPE`. |
| `article_kind` | query | string | no | `page` lists the pages (About, Contact); `article` the posts (news stories included); `news` only news stories. |
| `category_id` | query | uuid | no | Articles filed under this category, as their primary category or one of several. |
| `author_id` | query | uuid | no |  |
| `tag_id` | query | uuid[] | no | Return only articles carrying this tag. Repeat the parameter (or separate ids with commas) to match articles carrying ANY of the tags, at most 20. An article carrying several of them is still returned once. |
| `q` | query | string | no | Text search. Matches the words in the title, excerpt and body (English stemming, so `transcribing` finds `transcription`; web search syntax, so `"exact phrase"` and `-excluded` work), or a substring of the title, for the partial word a stemmer cannot match. It is a filter, not a ranking: results keep the `order` sort, so cursor pagination stays exact. It combines with every other filter, including `status`, so a secret key can search drafts; a publishable key only ever searches published articles. For ranked, best match first search of published posts, use the blog's `/api/v1/search`. |
| `slug` | query | string | no | Exact slug match. Slugs are unique within a Site, so this returns at most one article. |
| `external_id` | query | string | no | Exact `external_id` match. Unique within a Site, so this returns at most one article. An importer calls this before writing, to update an article it brought in on an earlier run rather than create a second copy. |
| `updated_since` | query | date-time | no | Return only articles updated at or after this instant. This is the incremental sync parameter: store the greatest `updated_at` you have seen and pass it back next time. |
| `order` | query | string | no |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Page | A page of articles. |
| 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. |
| 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/articles \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /articles

Create an article

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

Creates an article at `status: draft`. Always. There is no request field that can make
it public, and supplying `status` is a validation error rather than a silent ignore, so
a client written against a different CMS fails loudly instead of quietly leaving content
unpublished.

Nothing here is required. An empty body creates an untitled, unslugged draft you can
fill in later. `title`, `slug` and `content` do become required at publish time, and
`POST /articles/{id}/publish` returns `422` with a field level breakdown if any is
missing.

If you supply a `title` and no `slug`, a slug is derived from the title. Supply `slug`
explicitly if the URL matters to you, because a derived slug is not guaranteed stable
across versions.

**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**

| Field | Type | Required | Description |
|---|---|---|---|
| `article_kind` | string | no | `page` makes it a page (About, Contact) rather than a post. Defaults to `article` on create. `news` is set only by a news recipe. |
| `title` | string | null | no |  |
| `slug` | string | null | no | Derived from `title` when omitted. Supply it if the URL matters. |
| `external_id` | string | null | no | Your id for this article in the system it came from. Unique within the Site. |
| `content` | string | null | no | Markdown. Embed a YouTube, Vimeo or X post with a line of its own, `::embed{url="https://..."}`. |
| `excerpt` | string | null | no |  |
| `featured_image_url` | uri | null | no |  |
| `featured_image_alt` | string | null | no | Alt text for `featured_image_url`. Blank is stored as null, and your blog then uses the title. |
| `seo_title` | string | null | no |  |
| `seo_description` | string | null | no |  |
| `seo_keywords` | array | null | no |  |
| `faqs` | array | null | no |  |
| `key_takeaways` | array | null | no |  |
| `howto_steps` | object | null | no | `{ name?, description?, steps: [{ name, text?, image_url? }] }`, or a bare array of steps. Stored as the object. |
| `comparison` | object | null | no | `{ title?, headers: [string], rows: [[string]] }`. |
| `source` | any | no | Private provenance for this article. See `ArticleSourceWrite`. |
| `category_id` | uuid | null | no | The PRIMARY category. Sent alone it files the article under that category too; an article that was in only its old primary category is moved, one in several keeps them. |
| `category_ids` | uuid[] | no | Every category the article is filed under: a full replacement, not a merge (send `[]` to clear). With `category_id` too, the primary is put first if missing; without it the current primary stays when listed, otherwise the first becomes primary. |
| `author_id` | uuid | null | no |  |
| `format_id` | uuid | null | no |  |
| `tag_ids` | uuid[] | no |  |
| `original_published_at` | date-time | null | no | The date this article was originally published in the system you are importing it from. Must be in the past and on or after 1990-01-01. Used as `published_at` on the article's first publish from any path (dashboard, API, MCP, schedule or pipeline), so you can import as a draft and publish later without the post being dated today. No effect on an article that has already been published. |
| `original_content_updated_at` | date-time | null | no | When the imported article's content last changed at its source. Must be in the past, needs `original_published_at`, and may not be earlier than it. Becomes the article's `content_updated_at` (its sitemap `lastmod`) until it is edited in Writavo. |
| `canonical_url` | uri | null | no | The canonical URL override, an absolute http(s) URL. Set it when this article's primary copy lives at another address (a syndicated post). Blank or `null` means the article's own URL. An article whose canonical points anywhere else is left out of your sitemap. |
| `noindex` | boolean | no | `true` asks search engines not to index the page (`robots: noindex`) and leaves it out of your sitemap. The page stays reachable, listed on your blog and in your RSS feed. |
| `og_title` | string | null | no | The social-share title (`og:title`). Null means `seo_title`, then `title`. |
| `og_description` | string | null | no | The social-share description. Null means `seo_description`, then `excerpt`. |
| `og_image_url` | uri | null | no | The social-share image, an absolute http(s) URL. Null means `featured_image_url`. |
| `og_image_alt` | string | null | no | Alt text for `og_image_url`. Null means `featured_image_alt`, then the title. |
| `custom_fields` | object | null | no | The values of the Site's custom article fields, keyed by field API id, sent WHOLE (what you send replaces what is stored; null clears them). Each value is checked against `GET /content-types/article`; a bad one is `422` with `error.fields` naming it (`custom_fields.rating`). |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | Article | The draft was created. |
| 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/articles \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "How to choose a headless CMS",
  "slug": "how-to-choose-a-headless-cms",
  "content": "## Start with your delivery model\n\nThe first question is not which CMS...",
  "excerpt": "A practical framework for picking a headless CMS without regretting it.",
  "seo_title": "How to choose a headless CMS (2026 guide)",
  "seo_description": "A practical framework for picking a headless CMS.",
  "seo_keywords": [
    "headless cms",
    "content api",
    "jamstack"
  ],
  "category_id": "0f5f1f4e-9c2a-4f7b-9a11-3b5c9d8e7a01",
  "author_id": "6a1c8b22-0d4e-4a9f-8c33-77e2f1a4b5c6",
  "tag_ids": [
    "9d3e2c11-5b6a-4d8e-9f01-2a3b4c5d6e7f"
  ]
}'
```

### GET /articles/{id}

Read one article

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

Returns the full article including `content`. A publishable key may only read an article
at `status: published`; anything else returns 404, for the same no disclosure reason that
governs cross Site access.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `fields` | query | string | no | Comma separated field allow list. Omit to receive every readable field. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Article | The article. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### PATCH /articles/{id}

Update an article

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

A partial update. Only the fields you send are touched. Send `null` to clear a nullable
field; omit it to leave it alone.

`status` is not updatable here. Use the lifecycle endpoints. Sending `status` returns
`422 VALIDATION_FAILED`, which is what stops an API client from pushing an article into
the generation engine.

You may edit a published article. The edit goes live on your blog as soon as the CDN
cache for that post is purged, which happens as part of this request.

**Send `If-Match`.** Pass the `ETag` you received from your last read. If someone else
changed the article since then you get `412 PRECONDITION_FAILED` instead of silently
overwriting their work. `If-Match` is optional in v1 for compatibility, and omitting it
means last write wins, which is almost never what you want on shared content.

**A PATCH that changes nothing writes nothing.** When every field you send already
matches what is stored, the article is left exactly as it is: no new revision, no
`updated_at` change, no `article.updated` webhook and no cache purge. The response is
the stored article with `Writavo-Unchanged: true`, so a sync or an import re-run can
send whole articles without turning each one into an edit.

**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 |
|---|---|---|---|
| `article_kind` | string | no | `page` makes it a page (About, Contact) rather than a post. Defaults to `article` on create. `news` is set only by a news recipe. |
| `title` | string | null | no |  |
| `slug` | string | null | no | Changing the slug of a published article changes its live URL. The old URL keeps working: a 301 redirect to the article is added automatically (`origin: slug_change` in `GET /redirects`), and it follows any later slug change too. |
| `external_id` | string | null | no | Your id for this article in the system it came from. `null` clears it. |
| `content` | string | null | no | Markdown. Embed a YouTube, Vimeo or X post with a line of its own, `::embed{url="https://..."}`. |
| `excerpt` | string | null | no |  |
| `featured_image_url` | uri | null | no | `null` removes the featured image, and its `featured_image_alt` with it unless this request also sends one. |
| `featured_image_alt` | string | null | no | Alt text for `featured_image_url`. Blank or `null` clears it, and your blog then uses the title. |
| `seo_title` | string | null | no |  |
| `seo_description` | string | null | no |  |
| `seo_keywords` | array | null | no |  |
| `faqs` | array | null | no |  |
| `key_takeaways` | array | null | no |  |
| `howto_steps` | object | null | no | `{ name?, description?, steps: [{ name, text?, image_url? }] }`, or a bare array of steps. Stored as the object. |
| `comparison` | object | null | no | `{ title?, headers: [string], rows: [[string]] }`. |
| `source` | any | no | Private provenance for this article. See `ArticleSourceWrite`. |
| `category_id` | uuid | null | no | The PRIMARY category. Sent alone it files the article under that category too; an article that was in only its old primary category is moved, one in several keeps them. |
| `category_ids` | uuid[] | no | Every category the article is filed under: a full replacement, not a merge (send `[]` to clear). With `category_id` too, the primary is put first if missing; without it the current primary stays when listed, otherwise the first becomes primary. |
| `author_id` | uuid | null | no |  |
| `format_id` | uuid | null | no |  |
| `tag_ids` | uuid[] | no | Full replacement, not a merge. Send `[]` to clear. |
| `original_published_at` | date-time | null | no | The date this article was originally published in the system you are importing it from. Must be in the past and on or after 1990-01-01. Used as `published_at` on the article's first publish from any path (dashboard, API, MCP, schedule or pipeline), so you can import as a draft and publish later without the post being dated today. `null` clears it. No effect on an article that has already been published. |
| `original_content_updated_at` | date-time | null | no | When the imported article's content last changed at its source. Must be in the past, needs `original_published_at`, and may not be earlier than it. Becomes the article's `content_updated_at` (its sitemap `lastmod`) until it is edited in Writavo. |
| `canonical_url` | uri | null | no | The canonical URL override, an absolute http(s) URL. Set it when this article's primary copy lives at another address (a syndicated post). Blank or `null` means the article's own URL. An article whose canonical points anywhere else is left out of your sitemap. |
| `noindex` | boolean | no | `true` asks search engines not to index the page (`robots: noindex`) and leaves it out of your sitemap. The page stays reachable, listed on your blog and in your RSS feed. |
| `og_title` | string | null | no | The social-share title (`og:title`). Null means `seo_title`, then `title`. |
| `og_description` | string | null | no | The social-share description. Null means `seo_description`, then `excerpt`. |
| `og_image_url` | uri | null | no | The social-share image, an absolute http(s) URL. Null means `featured_image_url`. |
| `og_image_alt` | string | null | no | Alt text for `og_image_url`. Null means `featured_image_alt`, then the title. |
| `custom_fields` | object | null | no | The values of the Site's custom article fields, keyed by field API id, sent WHOLE (what you send replaces what is stored; null clears them). Each value is checked against `GET /content-types/article`; a bad one is `422` with `error.fields` naming it (`custom_fields.rating`). |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Article | The updated article (or, with `Writavo-Unchanged`, the stored one). |
| 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. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H 'If-Match: W/"1767225600000"' \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /articles/{id}

Delete an article

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

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

Permanent. The row, its tag assignments and the redirects that point at it are removed,
and if the article was published its URL starts returning 404 on your blog once the cache
is purged.

There is no trash and no undo in v1. If you only want to take a post off the web, use
`POST /articles/{id}/unpublish`, which keeps everything and is reversible.

Deleting an article that does not exist returns 404 rather than succeeding, so a
double delete is visible to you rather than silent.

**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 |  |
| `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. |
| `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. |
| 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`. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H 'If-Match: W/"1767225600000"'
```

### POST /articles/{id}/publish

Publish an article

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

Makes the article public immediately, at `status: published`.

Requires a non empty `title`, `slug` and `content`. If any is missing you get
`422 VALIDATION_FAILED` with a `fields` map naming each one, so you can point a user at
the exact problem rather than showing a generic failure.

`published_at` is set to now only if it was not already set. It records when the article
was **first** made public and is the ordering key for your blog, so republishing after an
unpublish does not move the post to the top of the feed. If the article carries a recorded
`original_published_at` (set on create or update, typically by an import), its first
publish uses that date instead of now, and this is true of every way an article goes live.

**Importing an article that was already public somewhere else?** Send its original
`published_at` in the body, and optionally `content_updated_at`. Both must be in the past
(and on or after 1990-01-01), and `content_updated_at` may not be earlier than
`published_at`. They are honoured on the article's **first** publish only: once an article
has a `published_at`, sending a different one is `422 VALIDATION_FAILED`, and sending the
same one is accepted so a retried import stays a no-op. A future date is refused; use
`/schedule` to go live later. Dates sent here win over the recorded
`original_published_at` / `original_content_updated_at` and replace them.

Publishing clears any pending schedule.

This is free. It makes no external call, so it passes no entitlement check, spends no
credits and is unaffected by your spend cap. An editor who is not allowed to run the AI
pipeline can still publish their own writing.

Safe to repeat: publishing an already published article is a no-op that returns the
current state.

**Parameters**

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

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `published_at` | date-time | no | The date the article was ORIGINALLY published, for an article imported from another system. First publish only; must be in the past. Omit it to publish now. |
| `content_updated_at` | date-time | no | When the imported article's content last changed at its source. First publish only; must be in the past and not earlier than `published_at`. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleLifecycleState | The article is public. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001/publish \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### GET /articles/{id}/revisions

List an article's revisions

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

Every saved version of the article, newest first: each change a reader would see, from the
dashboard, this API, the AI pipeline or an import, with who made it (`actor_user_id`, and
`actor_api_key_id` for a key) and from where (`source`). The first edit of an article made
before revisions existed keeps its original as `baseline`. Autosaves are kept 30 days,
pipeline revisions 180; an article keeps at most 300.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `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 | object | A page of revisions (without their bodies). |
| 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. |

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

### GET /articles/{id}/revisions/{revision_id}

Read one revision

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

The version in full, every field it recorded, to compare with the article or another revision.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleRevision | The revision. |
| 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. |

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

### POST /articles/{id}/revisions/{revision_id}/restore

Put a revision back

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

Copies the revision's title, body, excerpt, images, SEO fields, structured blocks, category
and author back onto the article, as a new change (itself a revision, `source: restore`, so
it can be undone the same way). The status never changes: restoring a published article
changes the live page, and its cache is cleared. Emits `article.updated`.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Article | The article as it now is. |
| 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. |

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

### GET /articles/{id}/preview-links

List an article's preview links

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

Every preview link made for the article, newest first, with whether it still works. Never the token.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The links. |
| 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. |

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

### POST /articles/{id}/preview-links

Share a preview of an unpublished article

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

A link that shows the article as your blog will render it, to anyone who has the link, before
it is published. The `token` and `url` come back ONCE, in this response; Writavo keeps only a
hash. The link works on your Site's own blog only, expires (7 days by default, at most 30)
and can be revoked. It is never indexed or cached. At most 20 live links per article and 200
per Site (`409 CONFLICT` past either). `url` is null until the Site's blog has a verified
address; build it as `<your blog>/preview/<token>`.

**Parameters**

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

**Request body**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | object | The new link. |
| 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. |

```bash
curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/preview-links \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /articles/{id}/preview-links/{link_id}

Revoke a preview link

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

The link stops working at once. Revoking twice is the same as once.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 204 | - | 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 | - | `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. |

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

### POST /articles/{id}/schedule-unpublish

Take a live article down at a set time

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

The article stays live until `scheduled_unpublish_at`, then goes back to draft on its own,
with the same audit, cache purge and `article.unpublished` event as an unpublish. Only a
published article; the time must be in the future (`422`). Sending it again moves the time.
Like an unpublish, an AI agent needs a person's approval for it.

**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`. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `scheduled_unpublish_at` | date-time | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleLifecycleState | The article's state. |
| 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. |
| 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. |

```bash
curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/schedule-unpublish \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "scheduled_unpublish_at": "<scheduled_unpublish_at>"
}'
```

### POST /articles/{id}/cancel-unpublish

Keep a live article up

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

Cancels a scheduled unpublish. Nothing happens (and nothing is emitted) when none is set.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleLifecycleState | The article's state. |
| 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. |

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

### POST /articles/cost-history

Set the imported cost history of many articles

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

`setArticleCostHistory` for up to 100 articles in one call, so a migrated blog whose
articles are already live backfills what they cost without one request per article. Each
item names ONE article of this Site by `external_id` (what an import sets), or by `slug`
or `id`, and carries the same `entries` as the single route.

Per article it behaves exactly like the single route: the entries REPLACE that article's
imported history (sending the same call again changes nothing, an empty list clears it),
each entry is checked on its own, and it is history for display only, never billed or
counted against a spend cap or credits.

An item that matches no article of this Site is reported `not_found` and the others are
still written; an item that is malformed, or names an article an earlier item in the call
already names, is `invalid`. `dry_run: true` resolves every item and checks every entry
and writes nothing. Read one article's result back with `GET /insights/articles/{id}/costs`.

**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 |
|---|---|---|---|
| `dry_run` | boolean | no | Resolve and check everything, write nothing. |
| `items` | object[] | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | What happened to each item, in the order sent. |
| 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. |

```bash
curl -X POST https://api.writavo.com/v1/articles/cost-history \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "external_id": "blogify-1041",
      "entries": [
        {
          "stage": "generate",
          "provider": "openai",
          "cost_usd": 0.0421,
          "occurred_on": "2026-05-10"
        }
      ]
    },
    {
      "external_id": "blogify-1042",
      "entries": [
        {
          "stage": "images",
          "provider": "replicate",
          "cost_usd": 0.06,
          "calls": 3
        }
      ]
    }
  ]
}'
```

### POST /articles/bulk-delete

Delete many articles

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

Deletes up to 100 articles in one call, permanently, exactly as `DELETE /articles/{id}`
does for each: the row, its tag assignments and the redirects that point at it go, and a
published article's URL starts returning 404 on your blog once the cache is purged. There
is no trash and no undo; to take posts off the web and keep them, unpublish them instead.

Each id is deleted exactly as the single `DELETE` does it, with its own audit entry and
webhook. An id that names no article on this Site, whether it never existed, was already
deleted or belongs to another Site, is listed in `not_found`. A repeated id counts once. If
one article cannot be deleted it is listed in `failed` with the error the single delete
would have returned, and the rest of the list still runs.

`dry_run: true` deletes nothing and returns the same report: `deleted` then lists the ids
that would be deleted.

**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 articles and how many of them are published.
Retry the same request with `Writavo-Approval: <error.approval.id>` once they have. The
approval is for the exact articles that existed on this Site when it was asked: if one of
them has since been deleted, the retry is `409 APPROVAL_INVALID` and you ask again. When
none of the ids is an article on this Site, or on a dry run, 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 ids to delete. A repeated id counts once. |
| `dry_run` | boolean | no | `true`: delete nothing, and report what the call would do. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | BulkDeleteResult | 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/articles/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"
  ]
}'
```

### POST /articles/{id}/link-rel

Set one link in an article to dofollow, nofollow or sponsored

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

Say whether one link in an article passes ranking credit. `rel` is `follow` (dofollow),
`nofollow`, `sponsored` (a paid or swapped link), `ugc`, or `null` to clear the choice so the
Site's rule decides again. Every link in the article to `target_url` is set. Find the link
and its exact `target_url` with `listArticleLinks`.

The rule, which the hosted blog and `content_html` both follow: what is set here wins; else a
link to the Site's own pages is followed; else a link to another site takes the Site's
`external_link_rel` (`PATCH /site/settings`).

The choice is stored in the link's Markdown title, as `[text](https://example.com "rel:nofollow")`,
and is taken out again when the page is rendered. So it is an edit to the article: it makes a
revision, and on a published article it is live at once. Writing that title yourself with
`updateArticle` does the same thing. `changed` is how many links were rewritten; 0 means the
article already said so.

**Parameters**

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

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `target_url` | string | yes | The link's address, exactly as `listArticleLinks` returns it (a full URL or a path). |
| `rel` | string | null | yes | `null` clears the choice. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | What was changed. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001/link-rel \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "target_url": "<target_url>",
  "rel": "follow"
}'
```

### POST /articles/{id}/cost-history

Set an article's imported cost history

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

Records what producing the article cost in the system it was imported from (its API
spend), for the record. The list REPLACES every entry imported for this article before, so
sending the same list again changes nothing, and an empty list clears it.

This is history for display, kept apart from Writavo's own cost ledger: it is shown beside
Writavo's measured costs in `GET /insights/articles/{id}/costs` (as `imported_total_usd` and
`imported`) and in the dashboard, and it is **never** billed, counted against a spend cap,
or added to credits, usage or the cost overview.

Each entry is checked on its own: problems come back in `problems` and valid entries are
written. `import_content` sends each article's `cost_history` here.

**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** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `entries` | object[] | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The article's imported cost history, as now stored. |
| 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. |

```bash
curl -X POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/cost-history \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "entries": [
    {
      "stage": "generate",
      "provider": "openai",
      "cost_usd": 0.0421,
      "calls": 3,
      "tokens": 18250,
      "occurred_on": "2026-05-10"
    }
  ]
}'
```

### POST /articles/{id}/unpublish

Unpublish an article

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

Takes the article off the web and returns it to `status: draft`. The URL starts
returning 404 on your blog once the cache is purged.

`published_at` is deliberately left intact. It is the original publication date and your
blog's ordering key, so a post that goes back up keeps its place in the archive.

Nothing is deleted and the operation is fully reversible with `POST /articles/{id}/publish`.

**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 |
|---|---|---|
| 200 | ArticleLifecycleState | The article is no longer public. |
| 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 POST https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001/unpublish \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```

### POST /articles/{id}/schedule

Schedule an article

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

Moves the article to `status: scheduled` and records when it should go live. A cron
publishes it within a few minutes of that time, whether or not the AI pipeline is
switched on for your Site.

`scheduled_publish_at` must be in the future. A past or present timestamp is rejected
with `422 VALIDATION_FAILED`, because silently publishing immediately is the wrong
answer to a clock skew bug.

The same `title`, `slug` and `content` requirements as publishing apply, and are checked
now rather than at the scheduled moment, so a scheduled post cannot fail silently at
two in the morning.

Rescheduling is just another call to this endpoint. Calling it on an already scheduled
article replaces the time.

A published article cannot be scheduled: that would take it off the web until the
scheduled time. It is refused with `409 CONFLICT` ("This article is live. Unpublish it
first."). Unpublish it first if that is what you mean.

**Parameters**

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

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `scheduled_publish_at` | date-time | yes | ISO 8601. Include an offset. A timestamp without one is read as UTC. The Site's timezone (`GET /site/settings`) is informational and is not applied. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleLifecycleState | The article is scheduled. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001/schedule \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "scheduled_publish_at": "2026-09-01T09:00:00Z"
}'
```

### POST /articles/{id}/cancel-schedule

Cancel a scheduled publish

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

Returns a scheduled article to `status: draft` and clears `scheduled_publish_at`. The
content is untouched. Calling this on an article that is not scheduled, including a
published one, is a no-op that returns the current state unchanged: it never takes a
live article down. Use `POST /articles/{id}/unpublish` for that.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | ArticleLifecycleState | The schedule was cancelled. |
| 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/articles/3f1b0c7a-0000-4000-8000-000000000001/cancel-schedule \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"
```
