# Error codes

Every error response has the same body. `field` names the offending field when there is one,
and `details` carries code-specific data:

```json
{
  "error": { "code": "missing_field", "message": "message is required.", "field": "message" },
  "request_id": "req_8hT2kLm4Qw9Zx1Cv3Bn5"
}
```

`request_id` matches the `X-Request-Id` header every response carries; quote it when you ask
for help. Messages are for people and may change; match on `code`.

**Retrying.** Never retry a 4xx unchanged, except 429 after its `Retry-After`. Retry 5xx with
exponential backoff, and 503 after its `Retry-After`. Send an `Idempotency-Key` with every
send you might retry, so a retry after a lost response doesn't notify twice.

| Code | Status | Meaning | What to do |
|---|---|---|---|
| `invalid_request` | 400 | The body isn't a JSON object, isn't valid JSON, or nests too deeply | Fix the body |
| `missing_field` | 400 | A required field is absent, such as `message` | Add the field named in `field` |
| `invalid_field` | 400 | A field is unknown, the wrong type, too long, or out of range | Fix the field named in `field` |
| `conflicting_field` | 400 | A field was given twice with different values (two sources, two aliases, or a repeat) | Give it once |
| `invalid_scene` | 400 | The scene or scene version isn't in the catalog | Check `GET /v1/scenes` |
| `invalid_scene_param` | 400 | A scene parameter is unknown or outside the scene's schema | Fix `scene_params.<name>` |
| `unauthorized` | 401 | No credential, or one the service doesn't know | Check the token and the base URL |
| `token_revoked` | 401 | The send token was revoked | Get a new token |
| `token_expired` | 401 | The send token passed its expiry | Get a new token |
| `key_rotated` | 401 | The account key was replaced by a rotation | Use the new key |
| `account_merged` | 401 | The account was merged into another (`details.canonical_account_id`) | Use the surviving account's key |
| `account_deleted` | 401 | The account was deleted | Stop; clients wipe their local state |
| `forbidden` | 403 | The credential is valid but may not use this route (for example an account key on a send) | Use the right credential |
| `scene_not_allowed` | 403 | The scene is outside the token's scene allowlist | Use an allowed scene, or change the token |
| `not_found` | 404 | No such resource, or one this credential can't see | Check the id |
| `idempotency_conflict` | 409 | The `Idempotency-Key` was used for a different request | Retrying? Resend the byte-identical original request. Use a new key only for a new notification |
| `rotation_conflict` | 409 | Another key rotation won (`details.generation`) | Wait for the new key |
| `limit_reached` | 409 | An account limit: 20 devices, 50 tokens, or 5 link approvals in 10 minutes (`details.limit`) | Remove something; for link approvals, wait for `Retry-After` |
| `merge_group_full` | 409 | A merged group holds at most 5 accounts | — |
| `link_not_pending` | 409 | The link request was already approved, cancelled or can't be approved | Start a new link request |
| `device_exists` | 409 | A device with this id is already registered or being linked | Remove it first |
| `link_superseded` | 409 | A merge brought in another registration of this device id | Link again with a new device id |
| `source_rejected` | 409 | An account switch's proof of the account the device leaves was refused; `details.reason` says why | Fix the source proof, or leave the old account first |
| `source_in_use` | 409 | An account switch with `source_in_use: refuse` found the old account still in use (`details.token_count`, `details.other_devices`) | Ask the user, then switch with `source_in_use: leave` |
| `cursor_expired` | 410 | The inbox cursor is older than retention or belongs to another feed | Resync from the start |
| `link_expired` | 410 | The link code expired (10 minutes) | Start a new link request |
| `payload_too_large` | 413 | The request is over 16 KB | Send less |
| `unsupported_media_type` | 415 | The body's content type isn't accepted | Send JSON (or, on the simple endpoint, a form or plain text) |
| `urls_not_allowed` | 422 | `url` or `image` from a token without URL permission (agent tokens default to none) | Drop the URL, or allow URLs on the token |
| `quota_exceeded` | 429 | The account's monthly send limit is reached | Wait for `Retry-After` (the next UTC month) |
| `rate_limited` | 429 | Too many requests in a short time (30 sends a minute per token, 60 per account) | Wait for `Retry-After` |
| `link_locked` | 429 | Too many wrong link codes | Wait for `Retry-After` |
| `service_unavailable` | 503 | Sends are paused or capped, or the request met concurrent changes | Retry after `Retry-After` |
| `internal_error` | 500 | Something went wrong on the server | Retry with backoff |

Any route may answer `500 internal_error`. API Gateway itself may answer `429` with the body
`{"message": "Too Many Requests"}` and no `Retry-After` when the whole service is over its
request rate, and a proxy may answer 502 or 504 without the error body. Decide on the HTTP
status first: treat a 429 without `error.code` like `rate_limited` and such a 5xx like
`service_unavailable`, with bounded exponential backoff (for example 1, 2 and 4 seconds, then
give up). `Retry-After` is always an integer number of seconds.
