# Error handling

Every error response uses one envelope and a stable code. Handle errors by their `code`, use the `retry` hint to decide whether to try again, check whether the work may already have started, and log the `request_id`.

## The error envelope

Every non-2xx response has this shape:

```json
{
  "error": {
    "code": "agent_not_ready",
    "message": "This agent cannot place calls right now.",
    "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": ["agent_not_published"] },
    "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh",
    "retry": "no",
    "doc_url": "https://docs.relationsai.com/errors/agent_not_ready"
  }
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `code` | string | Stable, machine-readable error code. **Branch on this.** Codes are never renamed in v1; new codes can be added. |
| `message` | string | A sentence for people, safe to show an operator. It may be reworded at any time: never parse it. |
| `details` | object | Extra facts for this code, such as `required_scopes` or `errors[]`, documented on each code's page. Always an object; `{}` when the code has none. |
| `request_id` | string | The request id. Log it and quote it to support. Also log the `X-Request-Id` header of the response you received: for a replayed answer (`Idempotent-Replayed: true`), the body can carry the id of the original request. |
| `retry` | string | What to do next: `no`, `yes` or `after` (below). |
| `doc_url` | string | The code's page in the [error-code reference](/fundamentals/error-codes), with causes and fixes. |

Each code always comes with the same HTTP status, so you can rely on the status class (`4xx`, `5xx`) as a fallback, but the `code` is what distinguishes, for example, the several kinds of `409`. Error messages never include internal details, and no error is ever returned with a `2xx` status.

## The retry hint

| `retry` | Meaning | What to do |
| --- | --- | --- |
| `no` | Retrying the same request will fail the same way. | Change the request or the configuration first. |
| `yes` | A temporary problem. | Retry with exponential backoff and jitter. For a `POST` or `DELETE`, reuse the **same** `Idempotency-Key` if you sent one. |
| `after` | Wait first. | Wait the number of seconds in the `Retry-After` header, then retry (with the same `Idempotency-Key`, if you sent one). |

"The same key" on this page means the `Idempotency-Key` you sent with the original request. It is optional; without one, a retried `POST` or `DELETE` runs again, so read the state before retrying a write whose outcome you do not know.

Never retry `no` errors in a loop. Cap retries for `yes` and `after` (for example at five attempts) and give up loudly. A very long `Retry-After`, such as the hours until the daily call limit resets, is a signal to schedule the work for later, not to sleep.

## Did anything happen?

Most errors mean the request was refused **before** it did anything: nothing was created, dialled or charged. A few can happen **after** work may have begun, for example when the start of a call or a campaign was sent but its confirmation was lost. Never assume an error means no side effects. Each code's page states which kind it is, as **Execution**:

| Execution | Meaning |
| --- | --- |
| Not started | Nothing was done. Fix the cause (or wait) and send the request again. |
| May have started | The work may have happened, partly or fully. Read the resource before doing anything else; with a key, an identical retry with the **same** key is also safe. |
| Not applicable | Reads, where nothing is changed either way. |

These codes may have started work:

- [`internal_error`](/errors/internal_error) (500, `retry: yes`): A `500` is not stored under an `Idempotency-Key`: a retry with the same key runs the request again from the start; it does not replay an earlier result. A read can simply be retried. For a write, the API does not know whether the change was applied before the error, so read the resource first (for a call, list calls filtered by `to` and `created_after`; for a campaign or a contact, retrieve it) and retry with the same key only if the change is not there.
- [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress) (409, `retry: after`): The first request with this key is still running. Wait `Retry-After`, then send the identical request with the same key to get its result.
- [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) (409, `retry: no`): The first request with this key was interrupted and its outcome is unknown. Read the resource (for a call, list calls by `to` and `created_after`). Only if it did not happen, send it again with a **new** key.
- [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409, `retry: after`): A start or resume was sent but not confirmed, and dialling may have begun. This answer is stored under the key: a retry with the same key returns it again and sends nothing. Wait, then retrieve the campaign: `running` means it started. Only if it is not running and you still want to try, send the action with a **new** key; the campaign refuses a second dial of the same run.
- [`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) (409, `retry: yes`): The pause was not confirmed and dialling may continue. Retry the pause with the same key, then retrieve the campaign; if it is still running, stop it.

A timeout or a dropped connection, where you get no response at all, is the same situation: the request may or may not have run. See [After a timeout](/fundamentals/idempotency#after-a-timeout).

One more case: `POST /v1/calls` answers `202` even when the start of the call cannot be confirmed straight away. The call is then resolved within about two minutes: it either proceeds, or ends with `outcome: failed` and `end_reason: not_confirmed`, and nothing is charged.

## Handling errors by class

- **Fix the request**: A bug or bad input. Fix it and send again. A refused request is not stored, so an `Idempotency-Key` you sent may be reused. Examples: [`validation_error`](/errors/validation_error), [`invalid_json`](/errors/invalid_json), [`invalid_phone_number`](/errors/invalid_phone_number), [`unsupported_media_type`](/errors/unsupported_media_type), [`payload_too_large`](/errors/payload_too_large), [`invalid_idempotency_key`](/errors/invalid_idempotency_key), [`invalid_cursor`](/errors/invalid_cursor).
- **Fix credentials or access**: Stop and alert a person: an admin usually has to change a key or a setting. Examples: [`invalid_api_key`](/errors/invalid_api_key), [`api_key_expired`](/errors/api_key_expired), [`insufficient_scope`](/errors/insufficient_scope), [`insufficient_permissions`](/errors/insufficient_permissions), [`api_access_not_enabled`](/errors/api_access_not_enabled), [`ip_not_allowed`](/errors/ip_not_allowed).
- **Fix the configuration**: Something in the console needs attention. Alert an operator; retry after it is fixed. Examples: [`agent_not_ready`](/errors/agent_not_ready), [`calling_not_configured`](/errors/calling_not_configured), [`phone_number_not_usable`](/errors/phone_number_not_usable), [`insufficient_balance`](/errors/insufficient_balance), [`spend_limit_reached`](/errors/spend_limit_reached), [`account_inactive`](/errors/account_inactive), [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled).
- **Respect a rule**: Expected business outcomes, not failures. Record them; do not retry until the situation changes (consent recorded, calling hours open, the next day). Examples: [`dnc_listed`](/errors/dnc_listed), [`consent_required`](/errors/consent_required), [`outside_calling_hours`](/errors/outside_calling_hours), [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached).
- **Check the id**: Check the id, its prefix and the key's mode. A test-mode id is not found with a live key. Examples: [`resource_not_found`](/errors/resource_not_found), [`route_not_found`](/errors/route_not_found).
- **Check state first**: Read the current state and decide again. For `idempotency_key_reused`, use a new key for a new operation. Examples: [`version_conflict`](/errors/version_conflict), [`campaign_state_conflict`](/errors/campaign_state_conflict), [`contact_already_exists`](/errors/contact_already_exists), [`idempotency_key_reused`](/errors/idempotency_key_reused).
- **Wait for Retry-After**: Nothing was done. Wait `Retry-After` seconds and retry with the same key. Examples: [`rate_limited`](/errors/rate_limited), [`calling_capacity_reached`](/errors/calling_capacity_reached), [`service_unavailable`](/errors/service_unavailable), [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress).
- **Schedule for later**: Nothing was done, and the wait is hours, not seconds. Queue the work for after `details.resets_at` (or start a smaller campaign that fits in `details.remaining`). Example: [`daily_call_limit_reached`](/errors/daily_call_limit_reached).
- **Retry with backoff (reads) or check state first (writes)**: For a read, retry with exponential backoff. For a write, the `500` is not stored, so a same-key retry runs it again: read the resource first and retry with the same key only if the change is not there. Example: [`internal_error`](/errors/internal_error).

The [error-code reference](/fundamentals/error-codes) lists every code with its status, retry hint and fix.

## Handling errors in code

Read the envelope once, then decide from `retry` and, where it matters, from `code`. For example, this call uses one of your own agents that is not published yet:

```bash
curl -X POST https://voice-api.relationsai.com/v1/calls \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1",
  "to": "+12025550100"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls", {
  method: "POST",
  headers: {
    Authorization: "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agent_id: "agent_3hT9kQ2mVx7RbN4pLc8Wz1",
    to: "+12025550100",
  }),
});
console.log(response.status, await response.json());
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1",
        "to": "+12025550100",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```json
{
  "error": {
    "code": "agent_not_ready",
    "message": "This agent cannot place calls right now.",
    "details": {
      "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1",
      "reasons": [
        "agent_not_published"
      ]
    },
    "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh",
    "retry": "no",
    "doc_url": "https://docs.relationsai.com/errors/agent_not_ready"
  }
}
```

`retry` is `no`, so sending the same request again would fail the same way. `details.reasons` says what to fix: publish the agent in the console, then send the request again. If you sent an `Idempotency-Key`, the refusal was not stored under it, so you may reuse it.

Decide the same way for every error:

1. `code` is [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) or [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown): read the resource before anything else.
2. `code` is [`internal_error`](/errors/internal_error) on a write: read the resource first and retry with the same key only if the change is not there. On a read, retry with backoff.
3. `retry` is `after`: wait the number of seconds in `Retry-After`, then send the same request with the same key.
4. `retry` is `yes`: retry with exponential backoff and the same key.
5. `retry` is `no`: fix the request or the configuration (`details` says what), then send it again.

Complete, runnable retry helpers are on [Rate limits](/fundamentals/rate-limits#handling-429) (backoff with `Retry-After`) and [Idempotency](/fundamentals/idempotency#retrying-safely) (retries that can never place a call twice).

When your server gets no HTTP response at all (a timeout, a reset connection or a proxy error page that is not JSON), the request may or may not have run. With an `Idempotency-Key`, retry with the same key, then check state if the retries run out. Without one, check state first (see [Idempotency](/fundamentals/idempotency#without-a-key)): a blind retry can repeat the action.

## Log what support needs

For every failed request, log at least: the time in UTC, the method and path, the HTTP status, `error.code` and `error.request_id`. Never log the `Authorization` header or the key. With the `request_id`, [support](/resources/support) can find exactly what happened.

## All error codes

See the [error-code reference](/fundamentals/error-codes): every code, its HTTP status, what it means, how to fix it and whether to retry. Each code also has its own page at `/errors/<code>`, which is the `doc_url` in the error.
