## The envelope

Every `/public/v1/*` error responds with the same body, whatever the HTTP status:

```json
{
  "error": "PERMISSION_DENIED",
  "message": "You do not have the required permission for this action",
  "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "details": {}
}
```

- **`error`** — the code from the table below; the only field your integration should branch on. It never
  changes shape within `/public/v1` (see [Versioning](/en/api/versioning/)).
- **`message`** — human-readable, in English, meant for debugging — don't show it as-is to your end user or
  branch logic on it, its wording can change without notice.
- **`request_id`** — include it if you write to support about a specific error; it helps us find the exact
  request in our logs.
- **`details`** — extra context (e.g. which fields failed validation); never the stable part of the
  contract, treat it as informational.

## Error catalog

| HTTP | `error` | When it shows up |
| --- | --- | --- |
| 401 | `INVALID_CREDENTIALS` | The key doesn't exist, was copied wrong, or is revoked. |
| 401 | `TOKEN_EXPIRED` | The key is past its expiration date. |
| 403 | `PERMISSION_DENIED` | The key doesn't have the exact scope the endpoint requires — see [Authentication](/en/api/authentication/). |
| 403 | `PLAN_NOT_ELIGIBLE` | The key's owning tenant isn't on a Pro or Enterprise plan — see [Plans and limits](/en/api/plans-and-limits/). |
| 404 | `NOT_FOUND` | The resource doesn't exist, or belongs to another tenant — same response either way, so we never confirm someone else's data exists. |
| 409 | `WHATSAPP_SESSION_WINDOW_CLOSED` | `POST /conversations/{id}/messages` to a WhatsApp contact whose 24h window is closed — use `POST /conversations/start` with an approved template instead. The real send is never attempted. |
| 422 | `VALIDATION_ERROR` | The request body or parameters don't match the endpoint's schema — check `details`. |
| 422 | `IDEMPOTENCY_KEY_REUSE_MISMATCH` | Only if you send the optional `Idempotency-Key` header: the same value was already used with a DIFFERENT body on an earlier request. Mint a fresh key for a genuinely different request. |
| 429 | `RATE_LIMIT_EXCEEDED` | You went over your request quota — see [Plans and limits](/en/api/plans-and-limits/) for the `X-RateLimit-*` headers. |

A FastAPI validation `422` (malformed body, wrong type) uses the same envelope, with `details` listing each
field that failed.