---
title: "Comments"
description: "Reader comments on articles. Off until someone turns them on (`PATCH /comments/settings`). The hosted blog shows a comment form by itself; a site that renders its own pages reads `GET /comments/posts/{slug}` (publishable key) and posts readers' comments with `POST /comments` from its own server. Moderators approve, hold, mark as spam, reply and delete, here or in the dashboard. Plain text only, one level of replies, the email address is never shown publicly, and a reader's IP is kept only as a hash under a salt that changes daily, for rate limits. `comments:read` and `comments:write` need the comments.moderate permission, because the moderation queue carries email addresses."
canonical: "https://writavo.com/docs/api/comments"
last-updated: "2026-09-30"
---

# Comments

Reader comments on articles. Off until someone turns them on (`PATCH /comments/settings`). The
hosted blog shows a comment form by itself; a site that renders its own pages reads
`GET /comments/posts/{slug}` (publishable key) and posts readers' comments with
`POST /comments` from its own server. Moderators approve, hold, mark as spam, reply and delete,
here or in the dashboard. Plain text only, one level of replies, the email address is never
shown publicly, and a reader's IP is kept only as a hash under a salt that changes daily, for
rate limits. `comments:read` and `comments:write` need the comments.moderate permission,
because the moderation queue carries email addresses.

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

### GET /comments/posts/{slug}

Read an article's approved comments

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

The approved comments on one published article, oldest first, for a site that renders its own
pages: `parent_id` is null for a comment and the comment's id for a reply (one level of
replies). `enabled` says whether the Site has comments on, `open` whether this article still
takes new ones (a Site can close comments some days after publishing), and `require_email`
whether a new comment needs an email address. With comments off, `enabled` is false and the
list is empty even if comments were approved earlier. Email addresses are never included.
Safe for a publishable key. An unknown or unpublished slug is `404`.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The article's slug. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | PostComments | The article's comments. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### GET /comments

List comments for moderation

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

Every comment on the Site, newest first, including ones waiting for review (`pending`) and
ones marked as spam, with the commenter's email address. Filter by `status` and by article
`slug`. `counts` is the whole Site's number of comments in each status, whatever the filter.
Spam is deleted automatically 30 days after it was marked.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `cursor` | query | string | no | The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or construct one; its encoding is not part of this contract and will change. |
| `limit` | query | integer | no | Page size, 1 to 100. Default 50. |
| `status` | query | string | no | Only comments in this status. |
| `slug` | query | string | no | Only comments on the article with this slug. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CommentPage | A page of comments. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### POST /comments

Submit a reader's comment

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

For a site that renders its articles itself: post a reader's comment from your own server
(never from the browser: this needs a secret key). Send the reader's IP as `reader_ip` so the
per-reader limit applies to the reader and not to your server; it is stored only as a hash
under a salt that changes daily. Plain text only; one level of replies (a reply to a reply
joins the same thread).

Whether it is published at once depends on the Site's `moderation` setting (`status` in the
answer: `approved` or `pending`). The same comment sent twice within a day returns the first
one with `duplicate: true` instead of a second copy. Fires the `comment.created` webhook.

Refusals, each with its own `error.code` (branch on the code, not the status):
`404 COMMENTS_DISABLED` when the Site has comments off, `404 NOT_FOUND` when the slug is not a
published article, `404 PARENT_NOT_FOUND` when `parent_id` is not an approved comment on it,
`422 COMMENTS_CLOSED` when comments have closed on that article, and `429
RATE_LIMIT_EXCEEDED` with `error.limit` naming the limit: `reader` (5 comments per reader in
10 minutes), `site` (300 per Site per hour) or `queue` (2,000 waiting for review).

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The published article's slug. |
| `body` | string | yes | Plain text. Line breaks are kept. |
| `author_name` | string | yes |  |
| `author_email` | string | null | no | Required when the Site's `require_email` is on. Never shown publicly. |
| `parent_id` | uuid | null | no | Reply to this approved comment. |
| `reader_ip` | string | null | no | The reader's IPv4 or IPv6 address as your server saw it. Used only, hashed under a daily salt, for the per-reader limit (5 comments in 10 minutes) and duplicate detection. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | SubmittedComment | Received. `status` says whether it is published or waiting for review. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/comments \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "slug": "how-to-choose-a-headless-cms",
  "body": "Thanks, the comparison table saved me a week.",
  "author_name": "Sam",
  "author_email": "sam@example.org",
  "reader_ip": "203.0.113.7"
}'
```

### GET /comments/settings

Read the comment settings

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

Whether readers can comment (`enabled`, off until someone turns it on), how new comments are
moderated, whether an email address is required, and after how many days from publishing an
article stops taking comments (`null`: never).

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CommentSettings | The settings. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with 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. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### PATCH /comments/settings

Change the comment settings

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

Send any of the four settings; the rest are left alone. `close_after_days: null` keeps
comments open for good. Turning comments off hides every comment on the live blog and in
`GET /comments/posts/{slug}` until they are turned on again; nothing is deleted. Recorded in
the audit log.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | no |  |
| `moderation` | string | no |  |
| `require_email` | boolean | no |  |
| `close_after_days` | integer | null | no |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CommentSettings | The settings after the change. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X PATCH https://api.writavo.com/v1/comments/settings \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": true,
  "moderation": "hold_links"
}'
```

### POST /comments/erase

Erase everything one commenter wrote

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

Delete every comment on this Site written under one email address, with the replies to them,
for a reader's erasure request (GDPR). The address is matched without regard to case. Returns
how many comments were deleted; the audit log records the count, never the address. Cannot be
undone.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `author_email` | string | yes | The commenter's email address. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CommentEraseResult | How many comments were deleted. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/comments/erase \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "author_email": "<author_email>"
}'
```

### POST /comments/import

Import comments

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

Bring a blog's comments to Writavo when it migrates, or restore them from a Site export
(`GET /export` writes them in exactly this shape). Each comment names its article by the
article's `post_external_id` (as imported) or its `post_slug` on this Site, in any status.
Call it from a server with a secret key.

**SET on `external_id`.** `external_id` is your old system's comment id, unique on the Site.
A row whose `external_id` is already on the Site replaces that comment (article, parent,
status, author, text and date); a new one is created. Sending the same rows again changes
nothing, so a retried or repeated import never duplicates.

**Parents before replies.** A reply names its parent by `parent_external_id`, which must be a
comment imported earlier: in an earlier call, or earlier in the same call. Writavo has one
level of replies, so a reply to a reply joins its parent's thread. A parent on a different
article is a problem for that row.

**Dry run first.** `dry_run` defaults to true: every row is checked and matched, `written`
says how many comments would be created and updated, and nothing is stored. Send
`dry_run: false` to write. A row with a problem is skipped and listed in `problems`; the
valid rows are still written. At most 2,000 rows per call.

Imported comments keep their dates and status. They are stored whether or not comments are
switched on for the Site, and appear on the blog when they are (approved ones only). No
`comment.created` webhook fires for imported rows. Each applied import is recorded in the
audit log with its counts.

**Parameters**

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

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `dry_run` | boolean | no | Check and report without writing. Defaults to true; send false to write. |
| `comments` | CommentImportRow[] | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | CommentImportResult | The report. With `dry_run` it describes what would be written. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/comments/import \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "comments": [
    {
      "external_id": "wp-comment:41",
      "post_external_id": "wp:12",
      "author_name": "Sam",
      "author_email": "sam@example.org",
      "body": "Thanks, the comparison table saved me a week.",
      "created_at": "2024-03-02T09:15:00Z"
    },
    {
      "external_id": "wp-comment:42",
      "post_external_id": "wp:12",
      "parent_external_id": "wp-comment:41",
      "author_name": "The Example team",
      "author_kind": "staff",
      "body": "Glad it helped.",
      "created_at": "2024-03-02T11:40:00Z"
    }
  ]
}'
```

### GET /comments/{id}

Read one comment

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

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Comment | The comment. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### PATCH /comments/{id}

Approve a comment, hold it or mark it as spam

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

Set `status` to `approved` (shown on the blog), `pending` (hidden, waiting for review) or
`spam` (hidden, deleted after 30 days). Recorded in the audit log.

**Parameters**

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

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | Comment | The comment after the change. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### DELETE /comments/{id}

Delete a comment

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

Deletes the comment and every reply to it. Recorded in the audit log.

**Parameters**

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

**Responses**

| Status | Data | Description |
|---|---|---|
| 204 | - | Deleted. No body. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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

### POST /comments/{id}/replies

Reply to a comment as the Site's team

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

Publish a reply at once, shown with a team badge (`author_kind: staff`). Replying to a comment
that is waiting for review approves it, as in WordPress. A reply to a reply joins the same
thread. `author_name` defaults to the key creator's name, else the Site's name.

A retry is safe: the same reply (same text) to the same thread within 10 minutes returns the
reply already made instead of posting a second one, with or without an `Idempotency-Key`.
Send a key as well when your client retries automatically.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | uuid | yes |  |
| `Idempotency-Key` | header | string | no | Optional on this operation. Send a unique key per logical operation (1 to 255 printable ASCII characters; a UUID is the obvious choice) and a retry with the same key and the same body replays the original response instead of doing the work again. Same key with a different body is `409 IDEMPOTENCY_KEY_CONFLICT`; same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`. Keys are scoped to the Site and the endpoint and kept for 24 hours. Without a key the request runs as usual. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `body` | string | yes |  |
| `author_name` | string | null | no | Defaults to the key creator's name, else the Site's name. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | Comment | The reply. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| 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` has three limits of its own on top of its class, and its 429 names the one it hit in `error.limit`: `reader` (5 comments per reader in 10 minutes, by `reader_ip`), `site` (300 comments per Site per hour) and `queue` (2,000 comments waiting for review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

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