# What do VARTA API errors look like and how do I handle them?

Every /v1 error is a typed JSON envelope with a stable code, a human message and a doc_url — this page shows the shape and links every code the API can return.


Every error from `/v1` — regardless of what went wrong inside the platform — comes back
as the same JSON shape, with an HTTP status that matches it, with one exception:
[`unauthenticated`](/docs/errors/unauthenticated) can also arrive as a bare
`{"detail": "authentication required"}` with no envelope at all, rejected before a `/v1`
handler ever runs — see that page for when.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "workflow_not_found",
    "message": "No agent with id 'ag_x' exists for this tenant.",
    "param": "agent_id",
    "request_id": "req_a91c4e7b2f30",
    "doc_url": "https://vartavoice.ai/docs/errors/workflow_not_found"
  }
}
```

- **`type`** is the coarse bucket a client branches on — `invalid_request_error`,
  `authentication_error`, `permission_error`, `billing_error`, `rate_limit_error`,
  `provider_error`, or `api_error`.
- **`code`** is the stable contract. **`message`** is for humans and may be reworded —
  write your error handling against `code`, never against the text of `message`.
- **`param`** names the offending request field, when the error is about one specific
  field. It's `null` otherwise.
- **`request_id`** is unique per request. Quote it when you contact support about a
  5xx.
- **`doc_url`** points at `https://vartavoice.ai/docs/errors/<code>` — the value in the
  JSON example above is what the platform emits (`DOC_BASE` in
  `backend/app/api/v1/errors.py`), and it is the page for that code on this site: the
  same one the table's links below open. It is safe to follow, and safe to use as a
  stable identifier for the code.

## Unrecognised errors still parse

Only the codes in the table below are permanent. If the platform hits an internal
failure it hasn't given a specific code, it degrades to one of a small set of
per-status fallback codes (for example, an unrecognised 404 becomes `not_found`, an
unrecognised 429 becomes `rate_limit_exceeded`) rather than breaking the envelope
shape. Two statuses have no fallback entry at all and so land on `internal_error`
while keeping their own status: a `405` (wrong HTTP method for a `/v1` path) and a
`415` (unsupported media type) both arrive as `code: "internal_error"` with status
`405` / `415` respectively (`backend/app/api/v1/errors.py:99-111`) — so an
`internal_error` on a non-5xx status is a malformed *request*, not a platform fault,
and retrying it unchanged will not help. So it's safe to build a client that switches on `type` and a known subset of
`code`s, and falls through to `message` for anything else — you will never get back
something that isn't this envelope.

## All error codes

| Code | HTTP status | Type |
| --- | --- | --- |
| [`invalid_request`](/docs/errors/invalid_request) | 400 | `invalid_request_error` |
| [`validation_error`](/docs/errors/validation_error) | 422 | `invalid_request_error` |
| [`unauthenticated`](/docs/errors/unauthenticated) | 401 | `authentication_error` |
| [`invalid_api_key`](/docs/errors/invalid_api_key) | 401 | `authentication_error` |
| [`token_expired`](/docs/errors/token_expired) | 401 | `authentication_error` |
| [`insufficient_credits`](/docs/errors/insufficient_credits) | 402 | `billing_error` |
| [`insufficient_role`](/docs/errors/insufficient_role) | 403 | `permission_error` |
| [`insufficient_scope`](/docs/errors/insufficient_scope) | 403 | `permission_error` |
| [`tenant_forbidden`](/docs/errors/tenant_forbidden) | 403 | `permission_error` |
| [`destination_not_permitted`](/docs/errors/destination_not_permitted) | 403 | `permission_error` |
| [`not_found`](/docs/errors/not_found) | 404 | `invalid_request_error` |
| [`workflow_not_found`](/docs/errors/workflow_not_found) | 404 | `invalid_request_error` |
| [`session_not_found`](/docs/errors/session_not_found) | 404 | `invalid_request_error` |
| [`campaign_not_found`](/docs/errors/campaign_not_found) | 404 | `invalid_request_error` |
| [`step_not_found`](/docs/errors/step_not_found) | 404 | `invalid_request_error` |
| [`trunk_not_found`](/docs/errors/trunk_not_found) | 404 | `invalid_request_error` |
| [`session_already_ended`](/docs/errors/session_already_ended) | 409 | `invalid_request_error` |
| [`idempotency_key_reuse`](/docs/errors/idempotency_key_reuse) | 409 | `invalid_request_error` |
| [`idempotency_key_in_progress`](/docs/errors/idempotency_key_in_progress) | 409 | `invalid_request_error` |
| [`caller_id_not_on_trunk`](/docs/errors/caller_id_not_on_trunk) | 400 | `invalid_request_error` |
| [`dial_failed`](/docs/errors/dial_failed) | 502 | `provider_error` |
| [`telephony_not_configured`](/docs/errors/telephony_not_configured) | 503 | `provider_error` |
| [`rate_limit_exceeded`](/docs/errors/rate_limit_exceeded) | 429 | `rate_limit_error` |
| [`concurrency_limit_exceeded`](/docs/errors/concurrency_limit_exceeded) | 429 | `rate_limit_error` |
| [`internal_error`](/docs/errors/internal_error) | 500 | `api_error` |
| [`provider_error`](/docs/errors/provider_error) | 502 | `provider_error` |
| [`provider_unavailable`](/docs/errors/provider_unavailable) | 503 | `provider_error` |
| [`provider_timeout`](/docs/errors/provider_timeout) | 504 | `provider_error` |

Each page answers three questions: what the code means, what causes it in this
platform, and what to do about it. Where the underlying code has no live path that
raises it yet, the page says so directly rather than guessing.
