---
title: "Every code, and what to do about it"
description: "Every code, what it means, and what to do about it."
canonical: "https://writavo.com/docs/errors"
last-updated: "2026-08-07"
---

# Every code, and what to do about it

Branch on `error.code`. It is stable within `/v1`; the message is not, and may be reworded at any time.

## The shape of a failure

Every failed response has this shape.

422:

```json
{
  "ok": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Add a title before publishing.",
    "fields": {
      "title": "Add a title before publishing."
    },
    "request_id": "0f2a8c31-0000-4000-8000-00000000abcd"
  }
}
```

- `code` is what you switch on. New codes may be added within `/v1`, so treat an unknown one as a generic failure of its HTTP status class.
- `fields` appears on `VALIDATION_FAILED` and maps each bad field to a message you can show beside the input.
- `request_id` identifies the request in our logs. Quote it to support.
- A message never contains a stack trace, SQL, an internal identifier, or another customer's data.

> Every code below has its own anchor to link to from an error handler: `https://writavo.com/docs/errors#NOT_ENTITLED`.

## The three different 402s

They look identical in a log but have three different fixes. Each means *not yet, and here is how to change that*, not *you may not* (a 403).

They are checked in that order, so a caller not on the right plan learns nothing about the balance.

**Check before you spend** (returns 200)

```bash

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

```

Read this before a pipeline run if you would rather fail fast than handle a 402.

## The full catalog

| Code | HTTP | Meaning | What to do |
|---|---|---|---|
| INVALID_REQUEST | 400 | Malformed JSON, a bad parameter, or a missing required header. | Fix the request before retrying. Common causes: a body that is not JSON, an unknown value in a query parameter, a cursor you constructed rather than passed back, or a missing `Idempotency-Key` on a creating POST. |
| INVALID_API_KEY | 401 | No key, an unparseable key, or one that does not exist. | Check the key is present, complete, and sent as `Authorization: Bearer <key>`. This response never distinguishes a missing key from a wrong one, so you cannot tell from it which mistake you made. |
| API_KEY_REVOKED | 401 | The key was revoked or rotated past its grace window. | Someone revoked or rotated this key. Create a new one in the dashboard and update your integration. Nothing you can send will make this key work again. |
| API_KEY_EXPIRED | 401 | The key passed its `expires_at`. | The key passed its `expires_at`. Create a replacement. If you did not expect an expiry, check whether the key was created with one. |
| INSUFFICIENT_SCOPE | 403 | The key is valid but lacks the scope this operation needs. For an AI agent, the message names the permission row the person must set to Read or Read and write when they reconnect. | Either the key lacks the scope, or its creator's permissions no longer cover it. Check `GET /ping` for the scopes the key carries now, then check the creator still has the matching dashboard permission. It never means the object belongs to someone else. For an AI agent, tell the person which permission row (for example "Articles" or "Team and organisation") needs Read or Read and write. They can add it to the SAME connection in Settings > AI agents (Permissions on that connection, or the Add button when a new permission is offered) or in Settings > API keys (Scopes on that key), with no new sign-in: the assistant stays signed in and may need to refresh its tools. Do not retry until they have. |
| FORBIDDEN | 403 | Refused for a reason no scope would change: a key that may not extend itself, an OAuth sign-in not started by Writavo's MCP server, 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 which and what to do instead. | Read the message: it names the reason and what to do instead, and no scope or retry changes it. On the team and permission operations it means an AI agent tried to change the access of the person it acts for, or something to do with ownership, or a change the caller's own role does not allow: a person makes that change at https://app.writavo.com/team. On a setting only a person may change (the AI agent controls, the outreach policy) a person does it in the dashboard at the page the message names. On `POST /auth/key/extend` the key is not one that may extend itself (only a live key from a device or AI-assistant sign-in can), its creator can no longer manage API keys, or AI agent access is off; signing in again gets a fresh key. On `POST /auth/device` it means `flow` was set to `oauth`, which only Writavo's hosted MCP server may do: leave `flow` out. |
| AGENT_ACCESS_DISABLED | 403 | The key belongs to an AI agent and the organisation has turned AI agent access off. | An owner or admin turned AI agent access off for this organisation. Nothing you send will work until it is turned back on in Settings > AI agents; the key itself is fine and needs no new sign-in once it is. Keys made in the dashboard are not affected. |
| APPROVAL_REQUIRED | 428 | An AI agent asked for an action a person must approve. `error.approval` has the link and the id to retry with. | Nothing has happened yet. Show the person `error.approval.url`, where they can approve or deny this exact request. Once they approve, send the identical request again with the header `Writavo-Approval: <error.approval.id>`. Do not change the path or the body, and do not retry in a loop: an approval lasts 24 hours. |
| APPROVAL_PENDING | 428 | The approval you sent has not been decided yet. Retry once it has. | The person has not decided yet. Wait for them to approve at `error.approval.url`, then retry with the same `Writavo-Approval` id. Polling will not speed it up. |
| APPROVAL_DENIED | 403 | A person denied the action. | A person looked at this request and said no. Do not retry it, and do not rephrase it to get around the refusal; ask them what they would like instead. |
| APPROVAL_INVALID | 409 | The approval you sent cannot be used for this request: expired, already used, for a different request, unknown, or what the request would do has changed since it was approved. | The approval id you sent is expired, already used, for a different request, unknown, or what the request would do has changed since it was approved ("What this request would do has changed..."). Approvals are single use and bound to the exact request and its effect. Retry without `Writavo-Approval` to ask for a new one, and tell the person what changed. |
| NOT_ENTITLED | 402 | Your plan does not include this AI pipeline capability. Upgrade. CMS capabilities are on every plan and never return this. | Your plan does not include this capability. Upgrading is the only fix; topping up credits will not help. This is answered before the credit check, so it tells you nothing about your balance. |
| INSUFFICIENT_CREDITS | 402 | The organisation cannot afford the next unit of work. Top up. | The organisation cannot afford the next unit of work. Top up. The balance is shared by every Site under the account, so another Site may have spent it. |
| SPEND_CAP_REACHED | 402 | This Site hit its own monthly ceiling. Raise it or wait. | This Site hit the monthly ceiling you configured for it, not a platform limit. Raise the cap in the dashboard or wait for the period to reset. `GET /usage` shows the cap, what has been spent against it, and whether it is reached. |
| PAYMENT_METHOD_REQUIRED | 402 | This would go past your included CMS allowance and there is no payment method on file. Not a plan limit and not an upgrade: CMS resources are pay-as-you-go on every plan. Add a card and retry. Nothing already published stops serving. | You have used everything included with your account for this resource, and there is no payment method on file to bill the rest to. Add a card and retry; the same request will then succeed. This is not a plan limit and upgrading will not fix it: storage, documents, seats, Sites and domains are pay-as-you-go on every plan including Free, so the fix is a card rather than a tier. Nothing you have already published stops serving while this is outstanding, and `GET /usage` shows which resource ran out and what the next unit costs. |
| NOT_FOUND | 404 | No such object, or it belongs to another Site. Deliberately indistinguishable. | Either there is no such object, or it belongs to a different Site. If you are certain the id is right, check you are using the key for the correct Site. |
| COMMENTS_DISABLED | 404 | The Site has reader comments turned off. Turn them on with `PATCH /comments/settings` (`{"enabled": true}`) or in the dashboard, Settings > Site > Reader comments. | The Site has reader comments turned off, so nothing about the key, the slug or the request is wrong. Turn them on with `PATCH /comments/settings` and `{"enabled": true}` (needs comments:write, the opt-in Reader comments row for an AI agent), or ask a person to turn them on in Settings > Site > Reader comments, then retry. A site showing comments should hide its comment form while `GET /comments/posts/{slug}` says `enabled: false`. |
| PARENT_NOT_FOUND | 404 | The comment in `parent_id` does not exist, is not approved, or is on another article. Reply to an approved comment on the same article. | The comment in `parent_id` is not an approved comment on this article: it never existed, was deleted, is still waiting for review, or is on another article. Reply to an approved comment from `GET /comments/posts/{slug}`, or send the comment without `parent_id`. |
| SLUG_CONFLICT | 409 | Another object on this Site already uses that slug. | Another object on this Site already uses that slug. Pick a different one, or let the API derive one by omitting `slug`. |
| IDEMPOTENCY_KEY_CONFLICT | 409 | The key was reused with a different request body. | You reused a key with a different body. That is a bug in the client rather than a retry: generate one key per logical operation and reuse it only to repeat that same operation. |
| IDEMPOTENCY_KEY_IN_FLIGHT | 409 | The first request with this key is still running. Retry shortly. | The first request with this key is still running. Wait a moment and retry with the same key; you will get the original response replayed. |
| CONFLICT | 409 | The request clashes with the object's current state, or the object changed under you mid request. The message names the step to take. | The request clashes with the object's current state. Read `error.message` first: when it names the step to take (for example "This article is live. Unpublish it first." or "Remove the current custom domain first."), do that step, then retry. When it says a scan is already running, wait a few minutes and read the result instead of starting another. Otherwise the object changed mid request: re-read it and retry. |
| PREREQUISITE_MISSING | 409 | Something this operation needs is not set up yet. The message names it and the operation that sets it up. Nothing was done and nothing was charged. | Set up the thing the message names, with the operation it names (for example `PATCH /site/settings` for a primary domain, `POST /seo/competitors` for a competitor, `POST /delivery/proxy` for the reverse proxy), then retry. Nothing was done and nothing was charged. If the missing piece is one only a person can set up (a CMS or Bing connection), give them the link from `POST /delivery/cms` or `POST /seo/backlinks/bing`. |
| FEATURE_UNAVAILABLE | 409 | Writavo has this capability switched off right now: paid backlink data platform wide, or spending while the plan has no daily spend cap configured. Nothing the caller sends changes it. | Writavo has this capability switched off right now, and retrying will not help. When the message says "Spending is paused", the organisation's plan has no daily spend cap configured, so nothing that spends credits or money can run: report it to the person, who contacts https://writavo.com/support. Otherwise use the free alternative the message names: for backlinks, the Search Console CSV import (`POST /seo/backlinks/import`) or the Bing Webmaster Tools feed (`POST /seo/backlinks/bing`). |
| PRECONDITION_FAILED | 412 | Your `If-Match` did not match. Someone else edited it. Re-read and retry. | Your `If-Match` did not match, so someone edited the object since you read it. Nothing was written. Re-read, merge your change on top, and retry with the new `ETag`. |
| VALIDATION_FAILED | 422 | The request parsed but the values are not acceptable. See `fields`. | Read `error.fields`. It maps each offending field to a message you can show next to the input. The three most common causes are publishing without a title, slug or content; scheduling in the past; and sending `status`, which is read only. |
| COMMENTS_CLOSED | 422 | Comments have closed on this article: it was published longer ago than the Site's `close_after_days`. Raise or clear that setting to reopen them. | Comments have closed on this article because it was published longer ago than the Site's `close_after_days`. Tell the reader comments are closed (hide the form when `GET /comments/posts/{slug}` says `open: false`). To reopen, raise or clear `close_after_days` with `PATCH /comments/settings`. |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests. Back off and honour `Retry-After`. | Back off and honour `Retry-After`. Watch `RateLimit-Remaining` on successful responses so you can slow down before you are refused rather than after. On `POST /comments`, `error.limit` names the comment limit hit: `reader` (that reader, 5 per 10 minutes: tell them to wait), `site` (300 per Site per hour) or `queue` (2,000 waiting for review: moderate the queue). |
| MAINTENANCE | 503 | Writes are paused for maintenance. Retry later. | Writes are paused. Reads usually keep working and your published blog is served from cache, so your site stays up. Retry after the window in `Retry-After`. |
| INTERNAL_ERROR | 500 | Our fault. Safe to retry an idempotent request. | Ours, not yours. Safe to retry, and safer with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. Quote the `request_id` if you contact support. |

## Why a wrong Site returns 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.

**An id that is not yours** (returns 404)

```bash

curl https://api.writavo.com/v1/articles/00000000-0000-4000-8000-000000000000 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"

```

An object belonging to another Site answers exactly like an object that does not exist. That is what stops anyone enumerating other customers' content by id.
