---
title: "Reader comments"
description: "Turn comments on, choose how they are moderated, show and accept them on a headless site, moderate, reply and erase through the API, and import or export them."
canonical: "https://writavo.com/docs/comments"
---

# Reader comments

Let readers comment on your articles, held for review until you approve them. Comments are off until you turn them on. A blog Writavo hosts shows them under each article by itself; a headless site reads and submits them through the API.

## How comments work

- **Off by default,** per Site. Nothing about comments appears anywhere until someone with the Moderate comments permission turns them on. Turning them off again hides every comment everywhere at once and keeps them stored.
- **Plain text only.** No HTML, no Markdown, no images. Line breaks are kept (at most one blank line in a row).
- **One level of replies.** A reply to a reply is attached to the top-level comment it belongs to, so a thread is never deeper than two.
- **Held for review by default.** Each comment is `pending`, `approved` or `spam`, and only `approved` comments are ever public.
- **Your team can reply** from the dashboard or the API. A team reply is published at once, carries `author_kind: staff`, and approves the comment it answers if that was still held.
- **They travel with the Site.** The Site export includes them, and an import (from WordPress, another system or an export) brings them in without duplicating.
- **Part of the CMS:** free, and the same on every plan.

## Turn comments on

In the dashboard: Settings, Site, Reader comments. Or with the API, using a key with the comments:write scope:

curl:

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

200 response:

```json
{
  "ok": true,
  "data": {
    "enabled": true,
    "moderation": "hold_links",
    "require_email": true,
    "close_after_days": 90
  }
}
```

| Setting | Default | What it does |
|---|---|---|
| `enabled` | `false` | Comments on or off for the whole Site. |
| `moderation` | `hold_all` | Which reader comments wait for review (see below). |
| `require_email` | `false` | Refuse a comment without an email address. The address is never shown publicly. |
| `close_after_days` | `null` | Stop taking comments on an article this many days after it was published, 1 to 3650. `null` never closes. Existing comments stay visible. |

Only the settings you send change, and at least one is required. Send close_after_days as null to reopen for good. An unknown setting or a bad value is 422 VALIDATION_FAILED naming the field. GET /comments/settings returns the same object.

## Moderation modes

| Mode | A reader's comment is |
|---|---|
| `hold_all` (default) | always `pending` until a moderator approves it |
| `hold_links` | `pending` when its text or name contains a link (`http://`, `https://`, `www.`, `[url` or `<a `), otherwise `approved` at once |
| `approve_all` | `approved` at once; moderate after the fact |

Changing the mode affects new comments only. Comments already pending stay pending until someone approves them.

## On a hosted blog

A blog Writavo serves (your writavo.com address, a custom domain or a reverse proxy) shows approved comments and a comment form under every article as soon as comments are on. There is nothing to build. The form asks for an email address only when require_email is on, and says the address is not shown.

## Behind a reverse proxy

When your blog is served at yoursite.com/blog through a reverse proxy, every reader reaches Writavo from your proxy's address. So that the per-reader limit and the repeat check still apply to each reader, the Cloudflare Worker and nginx snippets on the Delivery page send two extra headers with every request they forward:

the two headers the snippets add:

```
X-Writavo-Proxy-Key: wvp_...           # this connection's key, shown in the snippet
X-Writavo-Reader-IP: <the reader's address>
```

- **The key is per connection** and is already filled in in the snippet. Writavo believes the reader's address only when the key matches the connection the request came through. A header without the right key is ignored, so nobody can pick their own address by sending it.
- **One public address only.** A list of addresses, or a private, loopback or link-local address, is ignored.
- **Existing proxies keep working.** A proxy that does not send the headers is limited per Site, as before: 300 comments an hour across all readers. To get per-reader limits, copy the new snippet from Delivery and replace the old one.
- **Next.js rewrites cannot forward the reader's address,** so behind them the limits apply per Site. Use the Worker or the nginx snippet if you want per-reader limits.
- Treat the key like any other setting of your proxy: it only decides which reader a comment or reaction is counted against, on your own Site. To change it, remove the proxy connection and set it up again.

## Headless: show the comments

Read a published article's approved comments with the articles:read scope. A publishable key works, so this can run in the browser or at build time.

curl:

```bash
curl -s https://api.writavo.com/v1/comments/posts/hello-world \
  -H "Authorization: Bearer $WRITAVO_PUBLISHABLE_KEY"
```

200 response:

```json
{
  "ok": true,
  "data": {
    "slug": "hello-world",
    "enabled": true,
    "open": true,
    "require_email": false,
    "count": 2,
    "comments": [
      {
        "id": "6a1d0c3e-0000-4000-8000-000000000001",
        "parent_id": null,
        "author_name": "Sam",
        "author_kind": "reader",
        "body": "Clear and useful, thank you.\nOne question: does this work offline?",
        "created_at": "2026-10-03T14:02:11Z"
      },
      {
        "id": "6a1d0c3e-0000-4000-8000-000000000002",
        "parent_id": "6a1d0c3e-0000-4000-8000-000000000001",
        "author_name": "The Example team",
        "author_kind": "staff",
        "body": "It does, once the page has loaded once.",
        "created_at": "2026-10-03T15:20:40Z"
      }
    ]
  }
}
```

- `comments` is oldest first, up to 1,000, approved only. A reply has the top-level comment's id in `parent_id`; group replies under their parent. A reply whose parent is not approved is left out.
- `enabled` is false (with `count` 0 and no comments) while comments are off for the Site: show nothing.
- `open` is false when comments are off or the article is past `close_after_days`: show the comments but not the form.
- `require_email` tells you whether your form must ask for an email address.
- An unknown or unpublished slug is `404 NOT_FOUND`.
- `count` is the number of approved comments, the same number as `comments` on the article's engagement (see [Headless sites](/docs/headless#engagement)).

## Headless: accept a comment

Your form posts to your own server, and your server sends the comment to Writavo with a secret key that has the engagement:write scope. Never call this from the browser: a secret key in a page is a secret key anyone can copy.

curl:

```bash
curl -s -X POST https://api.writavo.com/v1/comments \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "hello-world",
    "body": "Clear and useful, thank you.",
    "author_name": "Sam",
    "author_email": "sam@example.com",
    "reader_ip": "203.0.113.7"
  }'
```

201 response:

```json
{
  "ok": true,
  "data": {
    "id": "6a1d0c3e-0000-4000-8000-000000000003",
    "parent_id": null,
    "author_name": "Sam",
    "author_kind": "reader",
    "body": "Clear and useful, thank you.",
    "created_at": "2026-10-04T10:15:00Z",
    "status": "pending",
    "duplicate": false
  }
}
```

| Field | Required | Rules |
|---|---|---|
| `slug` | yes | A published article's slug. |
| `body` | yes | Plain text, 1 to 5,000 characters after trimming. |
| `author_name` | yes | 1 to 80 characters. Shown publicly. |
| `author_email` | when `require_email` is on | A valid address, at most 254 characters. Never shown publicly or returned by a public endpoint. |
| `parent_id` | no | The id of an approved comment on the same article, to reply to it. |
| `reader_ip` | no | The reader's IPv4 or IPv6 address, as your server saw it. Anything else is a 422. |

- **`status`** is `pending` (tell the reader it is waiting for review) or `approved` (show it now), depending on the moderation mode.
- **`duplicate: true`** means the same reader already sent this exact comment on this article in the last day (a double click, a retried request). You get the first comment back, and no second one is created.
- **`404 COMMENTS_DISABLED`**: the Site has comments off. Hide the form (`GET /comments/posts/{slug}` says `enabled: false`) or turn them on with `PATCH /comments/settings`.
- **`404 NOT_FOUND`**: the slug is not a published article on this Site.
- **`404 PARENT_NOT_FOUND`**: `parent_id` is not an approved comment on that article.
- **`422 COMMENTS_CLOSED`**: the article is past the Site's `close_after_days`. Tell the reader comments are closed (`open: false` on the read).
- **`422 VALIDATION_FAILED`**: a field is wrong or unknown, and `error.fields` names it (`body`, `author_name`, `author_email`, `reader_ip`).
- **`429 RATE_LIMIT_EXCEEDED`**: `error.limit` says which limit: `reader` (5 comments per reader in 10 minutes), `site` (300 per Site per hour) or `queue` (2,000 waiting for review). Show the message and let the reader try again later.
- Branch on `error.code`, not on the status: two different 404s need two different messages.

> reader_ip is optional and is never stored. Writavo hashes it together with the Site under the day's random salt, which is deleted the next day, and uses the hash only to limit how often one reader can comment and to spot a repeat. Without it, the per-reader limit cannot apply and every comment from your server shares the Site-wide limit, so pass it when you can.

app/api/comments/route.ts (Next.js), the server side of your form:

```
export async function POST(req: Request) {
  const form = await req.json();
  const res = await fetch("https://api.writavo.com/v1/comments", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WRITAVO_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      slug: form.slug,
      body: form.body,
      author_name: form.author_name,
      author_email: form.author_email || undefined,
      parent_id: form.parent_id || undefined,

      reader_ip: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || undefined,
    }),
  });
  const body = await res.json();

  return Response.json(body, { status: res.status });
}
```

## Rendering comments safely

- **Treat `body` and `author_name` as plain text** and escape them. In React that is simply `{comment.body}`; never `dangerouslySetInnerHTML`. Keep line breaks with CSS (`white-space: pre-line`) rather than turning them into HTML.
- **If you turn addresses in a comment into links,** give each `rel="nofollow ugc"`. That tells search engines the link is user content, and it takes the value out of comment spam.
- **Show one level of replies:** top-level comments in order, each followed by the comments whose `parent_id` is its `id`.
- **Mark staff replies** (`author_kind: staff`) so readers can tell your team from other readers.
- **Re-read after submitting** rather than inserting the comment yourself when `status` is `approved`, or show it with a note when it is `pending`.

## The moderation API

Moderating needs the Moderate comments permission (comments.moderate; owners, admins and editors have it by default) and a secret key with comments:read to read the queue or comments:write to change it. Neither scope can be held by a publishable key, because the queue includes commenters' email addresses.

| Request | Scope | What it does |
|---|---|---|
| `GET /comments` | `comments:read` | The queue, newest first, with counts per status |
| `GET /comments/{id}` | `comments:read` | One comment, with its email address and article |
| `PATCH /comments/{id}` | `comments:write` | Set `status`: `approved`, `spam` or `pending` |
| `DELETE /comments/{id}` | `comments:write` | Delete a comment; its replies are deleted with it |
| `POST /comments/{id}/replies` | `comments:write` | Reply as the Site's team |
| `GET /comments/settings` | `comments:read` | The settings |
| `PATCH /comments/settings` | `comments:write` | Change the settings |
| `POST /comments/erase` | `comments:write` | Delete everything one commenter wrote, by email |
| `POST /comments/import` | `comments:write` | Import comments from another system or an export (see below) |

the queue: comments waiting for review:

```bash
curl -s "https://api.writavo.com/v1/comments?status=pending&limit=50" \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY"
```

200 response:

```json
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "6a1d0c3e-0000-4000-8000-000000000003",
        "parent_id": null,
        "author_name": "Sam",
        "author_kind": "reader",
        "body": "Clear and useful, thank you.",
        "created_at": "2026-10-04T10:15:00Z",
        "status": "pending",
        "author_email": "sam@example.com",
        "source": "api",
        "external_id": null,
        "article_id": "3f1b0c7a-0000-4000-8000-000000000001",
        "article_slug": "hello-world",
        "updated_at": "2026-10-04T10:15:00Z",
        "approved_at": null
      }
    ],
    "next_cursor": null,
    "counts": { "pending": 1, "approved": 41, "spam": 3 }
  }
}
```

- Filters: `status` (`pending`, `approved` or `spam`), `slug` (one article; an unknown slug is an empty list), `limit` (1 to 100, default 50), and `cursor` from the previous page's `next_cursor`.
- `counts` is for the whole Site, whatever the filters.
- `source` is where the comment came from: `hosted` (the hosted blog's form), `api`, `dashboard` or `import`.
- `external_id` is the id an imported comment had in the system it came from, and null for a comment made on Writavo.

approve, mark as spam, reply, delete:

```
# approve (or "spam", or back to "pending")
curl -s -X PATCH https://api.writavo.com/v1/comments/6a1d0c3e-0000-4000-8000-000000000003 \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "status": "approved" }'

# reply as the team (approves the comment too, if it was held)
curl -s -X POST https://api.writavo.com/v1/comments/6a1d0c3e-0000-4000-8000-000000000003/replies \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "body": "Thanks, Sam. It does, once the page has loaded once.", "author_name": "The Example team" }'

# delete it, with any replies
curl -s -X DELETE https://api.writavo.com/v1/comments/6a1d0c3e-0000-4000-8000-000000000003 \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY"
```

- `PATCH` answers `200` with the full comment, the reply answers `201` with the new staff comment, and `DELETE` answers `204` with no body. A comment id from another Site, or one that does not exist, is `404 NOT_FOUND`.
- A reply's `body` is 1 to 5,000 characters. `author_name` is optional: without it the reply is signed with the name of the person the key belongs to, or else the Site's name.
- A reply to a reply is attached to the top-level comment, like a reader's.
- **A retried reply is safe.** Sending the same reply to the same comment again within 10 minutes returns the reply already made instead of posting it twice. If your client retries on its own, also send an `Idempotency-Key` header (optional here): a retry with the same key replays the first response.
- Deleting cannot be undone. To hide a comment without losing it, set it back to `pending`.

## Import and export comments

Comments travel with a Site. The Site export (GET /export) includes a top-level comments array (leave it out with?comments=false), written in exactly the row shape the import reads, so an export imports back as is. To bring comments from another system, send them to POST /comments/import from your server with a secret key that has comments:write. An Idempotency-Key header is required.

curl: a dry run of a comment and its reply:

```bash
curl -s -X POST https://api.writavo.com/v1/comments/import \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "comments": [
      {
        "external_id": "wp-comment:41",
        "post_external_id": "wp:12",
        "author_name": "Sam",
        "author_email": "sam@example.com",
        "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"
      },
      {
        "external_id": "wp-comment:77",
        "post_slug": "an-article-that-was-never-imported",
        "status": "pending",
        "author_name": "Alex",
        "body": "Does this cover self-hosted options?",
        "created_at": "2025-11-20T18:02:11Z"
      }
    ]
  }'
```

200 response:

```json
{
  "ok": true,
  "data": {
    "dry_run": true,
    "written": { "created": 2, "updated": 0 },
    "problems": [
      {
        "index": 2,
        "external_id": "wp-comment:77",
        "problem": "no article on this Site with that post_slug"
      }
    ]
  }
}
```

Nothing was stored: dry_run is true unless you send "dry_run": false, and written says what would happen. Fix the rows in problems (or accept leaving them out), then send the same body with "dry_run": false and a new Idempotency-Key.

| Field | Required | Rules |
|---|---|---|
| `external_id` | yes | The comment's id in the system it comes from, 1 to 255 printable ASCII characters with no spaces, unique on the Site. |
| `post_external_id` or `post_slug` | exactly one | The article it is on: its `external_id` (as imported) or its slug on this Site. The article can be in any status. |
| `parent_external_id` | no | For a reply, the `external_id` of the comment it answers, imported earlier. |
| `status` | no | `approved` (default), `pending` or `spam`. |
| `author_name` | yes | 1 to 80 characters. |
| `author_email` | no | A valid address. Never shown publicly; used for erasure. |
| `author_kind` | no | `reader` (default) or `staff`, a reply from your team. |
| `body` | yes | Plain text, up to 20,000 characters. Convert HTML to plain text first. |
| `created_at` | yes | When it was written, ISO 8601, from 1990 to now. Kept as the comment's date. |

- **Matched on `external_id`.** A row whose `external_id` is already on the Site updates that comment (article, parent, status, author, text and date); a new one is created. Running the same import again never duplicates.
- **Parents before replies.** A reply's parent must be imported earlier: in a previous call, or earlier in the same call. A reply to a reply joins its parent's thread, as everywhere else. A parent on a different article is a problem for that row.
- **A bad row never stops the others.** It is listed in `problems` with its position (`index`, from 0) and why, and every valid row is still written.
- **At most 2,000 rows a call.** Split a larger set, keeping parents in an earlier call than their replies.
- **Stored whether or not comments are on.** Imported comments appear on the blog once comments are switched on (approved ones only), with their original dates.
- **No `comment.created` webhook** fires for imported comments, so a migration does not send a notification per comment.
- **From WordPress there is nothing to convert.** The WordPress import (`import_content`, or Import in the dashboard) brings the comments with the articles: approved, pending and spam comments from an export file, with trash, pingbacks and trackbacks skipped and HTML turned into plain text, or approved comments from a live site over its REST API, which never exposes commenters' email addresses. See [Move a blog to Writavo](/docs/migrate).

## Erasing a commenter (GDPR)

When someone asks you to delete everything they wrote, erase by the email address they commented with. Every comment on this Site with that address (compared without regard to case) is deleted, along with the replies under those comments.

curl:

```bash
curl -s -X POST https://api.writavo.com/v1/comments/erase \
  -H "Authorization: Bearer $WRITAVO_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "author_email": "sam@example.com" }'
```

200 response:

```json
{ "ok": true, "data": { "deleted": 3 } }
```

deleted is 0 when there was nothing to erase, which is still a success. The audit log records that an erasure happened and how many comments it removed, never the address. Comments left without an email address cannot be matched to a person this way; delete those one by one.

## The comment.created webhook

Subscribe a webhook to comment.created to hear about every reader comment as it arrives, whether it was approved at once or is waiting for review. Setting up and verifying webhooks is on the [Webhooks](/docs/webhooks) page.

- `data.id` in the payload is the comment's id. Fetch it with `GET /comments/{id}` (`comments:read`) for its status, text and email address.
- Replies written by your own team do not fire it, and neither do imported comments.
- A typical use: notify a moderator when `status` is `pending`.

## Limits

| Limit | Value |
|---|---|
| Comment and reply length | 5,000 characters (20,000 for an imported comment) |
| Comments in one import call | 2,000 |
| Name length | 80 characters |
| Comments from one reader | 5 per 10 minutes (needs `reader_ip` on the API; automatic on a hosted blog and behind a reverse proxy that sends the reader's address, see [Behind a reverse proxy](#reverse-proxy)) |
| Comments on one Site | 300 per hour |
| Comments waiting for review | 2,000 per Site; new comments are refused until the queue is worked down |
| Comments marked as spam | deleted 30 days after being marked |
| Comments shown per article | the first 1,000 approved |

A Site whose organisation is not active takes no comments. The usual API rate limits also apply to every request; see [Rate limits](/docs/rate-limits).

## Privacy

- **Email addresses are never public.** No public endpoint returns them, the hosted blog never shows them, and only people with the Moderate comments permission (or keys with `comments:read`) can see them.
- **IP addresses are never stored.** Only a hash of the address and the Site under the day's random salt is kept, and the salt is deleted the next day, so the hash cannot be traced back. It is used for the per-reader limit and to spot a repeat, nothing else.
- **No cookies** are set for commenting.
- **Erasure** is one request by email address (above), and deleting a comment removes it for good.

## From an AI assistant (MCP)

Every comment operation is an action on the Writavo MCP server. Call search_writavo_actions with a few words, for example "comment settings", "pending comments" or "reply to a comment"; it returns the operation id and its inputs. Run reads with read_writavo_action and changes with run_writavo_action.

The connection needs the Reader comments row, chosen by the person on the sign-in screen or later in Settings > AI agents: Read for the settings and the queue, Read and moderate for changes. It is off unless the person chooses it, like Outreach contacts, because the queue carries commenters' email addresses, and it needs the person's own Moderate comments permission. A connection made before the row existed does not have it; the person adds it to the same connection, with no new sign-in.

The same row, at Read and moderate, also lets import_content bring comments in when it moves a blog. Without it, the import brings the articles, skips the comments and says how to turn the row on; running the import again afterwards adds the comments without duplicating any article.

1. **Ask before turning comments on.** It changes the live blog. Read the settings first, then send only what the person asked for.
2. **Moderate on instruction only.** List the pending comments and show them to the person; approve, mark as spam or delete only what they say. Deleting cannot be undone.
3. **Never reveal an email address** beyond the person you are working for, and never put one into a public reply.
