# Errors

## The error body

Every failure is FastAPI's own envelope — no second wrapper, no vendor code:

```json
{ "detail": "API key is missing the required scope 'voice:write'" }
```

`detail` is a sentence for the developer. On a `422` it is instead an array of
`{ loc, msg, type }` validation items, where `loc` names the offending field.
One retired route returns a structured `{ code, message, migrate_to }` object,
which is why the declared type is `string | object` rather than `string`.

## Every status this API returns

The cause column is the backend's own declaration — from the OpenAPI responses and `devplatform/schemas/api_errors.py` — not a generic HTTP table. A status that is not listed here is not one this API raises deliberately.

| Status | What causes it | What to do |
| --- | --- | --- |
| `400` | A hard limit was reached: too many projects, or too many active keys on this project. A requested scope is not a scope this platform defines. | Fix the request or free a slot — the message names the limit that was hit. |
| `401` | No account session was presented, or it is invalid or expired. `Missing API key` or `Invalid API key` — the `X-Api-Key` header is absent, unknown, or revoked. | Re-authenticate. For a console session refresh once, then send the user to sign in; for a project key, check the header and that the key is not revoked. |
| `402` | Wallet balance cannot cover this session's maximum duration. Wallet balance cannot cover this call's maximum duration. | Top up the wallet, or lower `max_duration_seconds` so the hold fits the spendable balance. |
| `403` | The account is suspended or its email is not verified. `API key is not scoped to a project`, `Project unavailable`, or `API key is missing the required scope '<scope>'`. | Verify the account's email, or mint a key with the scope the message names. Never retry unchanged. |
| `404` | The project or the addressed resource does not exist for this owner. A resource owned by another developer is deliberately indistinguishable from one that never existed. | Treat as absent, never as a permissions hint — another owner's resource and one that never existed are deliberately the same answer. |
| `409` | The project or resource is in a state that refuses this change. | Read the state and resolve it — verify the carrier, re-enable the endpoint, rename the duplicate — then retry. |
| `422` | The request body or query is not valid. `detail` is an array of `{loc, msg, type}` and `loc` names the offending field. | The body failed schema validation. `detail` is an array of `{loc, msg, type}`; the `loc` path names the offending field. |
| `429` | The project's `api_rpm` ceiling was exceeded. Honour `Retry-After`; `X-RateLimit-*` headers are on every response. | Back off for `Retry-After` seconds. `X-RateLimit-Limit`, `-Remaining` and `-Reset` are on every response, so pace before you are refused. |
| `502` | The payment gateway did not create an order. | An upstream (payment gateway, carrier) did not answer usefully. Safe to retry with backoff. |
| `503` | The order could not be recorded locally, so no checkout was returned. BYOK provider accounts are unverified/unavailable, or the service is draining. The carrier connection or BYOK pipeline is unavailable. Webhook secret storage (the credential keyring) is unavailable. | A dependency is unavailable or the service is draining. Retry with backoff; do not treat it as a permanent failure. |


## Rate limits

Machine routes are counted per project against that project's `api_rpm`, in a
fixed one-minute window. Every response — not only a refusal — carries:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | the project's requests per minute |
| `X-RateLimit-Remaining` | what is left in this window |
| `X-RateLimit-Reset` | seconds until the window rolls |
| `Retry-After` | on a `429` only; wait this long |

Pace against `X-RateLimit-Remaining` rather than waiting to be refused. The
limiter fails **open**: if its store is unreachable your requests are allowed
through, because a limiter protects other tenants and is not a security
control.

## WebSocket close codes

The media socket refuses with an application close code rather than a status:

| Code | Meaning |
| --- | --- |
| `4401` | bad or expired stream token, or a token/session mismatch |
| `4404` | unknown voice session |
| `4408` | the session configuration expired before you connected |
| `4409` | the session is not connectable, or the project changed after admission |
| `4429` | the project's concurrency limit is full |
| `4453` | the provider pipeline is unavailable |
| `1013` | the service is restarting; reconnect with a new session |

## Retrying safely

- `429`, `502`, `503` — retry with exponential backoff and jitter. Honour
  `Retry-After` when it is present.
- `402` — do not retry until the wallet is funded; the answer will not change.
- `400`, `403`, `404`, `409`, `422` — never retry unchanged. Each one names
  something you must alter in the request or in the resource's state.
- Outbound calls take an `Idempotency-Key`. Reuse the same key when you retry
  so a network timeout cannot dial twice; the repeat returns the first call
  with `duplicate: true`.

## Truthful states

The API reports what it actually knows, and the console shows those words
verbatim rather than smoothing them over:

- A saved credential is **configured** until the provider confirms it; only a
  **verified** account takes calls. A provider outage during a probe leaves it
  **configured**, never optimistically verified.
- A carrier connection the carrier rejected is **invalid**, carrying the
  carrier's own message.
- A voice session is billed for connected seconds only, rounded up to whole
  minutes, and settled exactly once.
- A webhook test returns `202` when the delivery is **recorded**, not when it
  is delivered. Read `dead-letters` for deliveries that never landed.

---

Source: https://developers.dvaarik.com/docs/errors · every page as one file: https://developers.dvaarik.com/docs.md
