# 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. Treat an unknown code as a generic failure of its HTTP status class rather than crashing: new codes may be added within `/v1`.
- `fields` is present on `VALIDATION_FAILED` and maps each offending field to a message fit to put next to an input.
- `request_id` identifies the exact request in our logs. Quote it when you contact support.
- A message never contains a stack trace, SQL, an internal identifier, or anything belonging to another customer.

> Every code below has its own anchor, so an error handler can link a person straight to the paragraph that explains what they hit: `https://writavo.com/docs/errors#NOT_ENTITLED`.

## The three different 402s

These are the ones worth reading before you hit them, because they look identical in a log and have three different fixes. They are 402 rather than 403 deliberately: a 403 says *you may not*, and each of these says *not yet, and here is how to change that*.

They are checked in that order, so a caller who is not on the right plan never learns anything 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. The only 403 in the API. | Either the key lacks the scope, or its creator's permissions no longer cover it. Check `GET /keys` for the scopes actually granted, then check the creator still has the matching dashboard permission. This is the only 403 in the API, and it never means the object belongs to someone else. |
| 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. |
| 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 object changed under you mid request. Re-read and retry. | The object changed under you mid request. Re-read it and retry. |
| 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. |
| 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. |
| 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`. 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.

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