# Retry and reliability practices

**Availability: Available**

Networks fail and services get busy. This guide shows how to read the `retry` hint in errors, how to back off, and how to retry a write without doing it twice: by checking state before you resend, and optionally by sending an idempotency key.

The goal is simple to state: **every call you intend to place is placed at most once, and you always find out whether it was.** Placing calls and starting campaigns cost money in live mode and reach real people, so "probably did not happen" is never good enough.

## Before you begin

Read [Error handling](/fundamentals/error-handling). [Idempotency](/fundamentals/idempotency) explains the optional `Idempotency-Key` header used in one section below.

## Use the retry hint

Every error carries `error.retry`, which tells you what to do without a table of codes:

| `retry` | Meaning | What to do |
|---|---|---|
| `no` | The request cannot succeed as sent | Do not retry. Fix the request, the configuration, or accept the refusal (for example `dnc_listed`). |
| `yes` | A transient failure | Retry with exponential backoff and jitter, after checking state for a write that may have happened |
| `after` | Wait first | Retry after the number of seconds in the `Retry-After` header |

Each code's page in the [error-code reference](/fundamentals/error-codes) also says whether work **may have started** before the error (its "execution" fact). For example [`dnc_listed`](/errors/dnc_listed) is "not started": nothing happened, and sending the request again after fixing the cause is safe. [`internal_error`](/errors/internal_error) (500) is "may have started": for a write, the API does not know whether the change was applied. Never assume a failed request had no side effects unless its code says so.

## Every request runs

The API does not require an idempotency key. **Without one, every request you send is executed.** If a `POST /calls` times out after the API accepted it and you simply send it again, you place a second call. So the rule for any write whose outcome you did not see:

1. **Put your own id in the request** where you can find it again: `metadata` on a call (for example your record id and the attempt), the phone number on a contact, the campaign `name`.
2. **After a timeout, a dropped connection or a `5xx`, check the state first**: read or list the resource and look for your id.
3. **Send the request again only if it did not happen.**

Errors that say nothing happened (`retry: "no"` refusals before any work, `429 rate_limited`, `429 calling_capacity_reached`, `503 service_unavailable`) can be resent without a check.

## A call that can be retried safely

The whole rule for [`POST /calls`](/api/calls#place-a-call), as a sequence of requests. The complete runnable example below implements it with backoff, jitter and the `retry` hint.

**1. Place the call with your own id in `metadata`:**

```bash
curl -X POST https://voice-api.relationsai.com/v1/calls \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
  "to": "+12025550100",
  "metadata": {
    "logical_id": "renewal-L-1042-2026-11"
  }
}'
```
```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_1ZltiWzxrXmMprGD6q63",
    "to": "+12025550100",
    "metadata": {
      "logical_id": "renewal-L-1042-2026-11"
    }
  }),
});
const call = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
        "to": "+12025550100",
        "metadata": {
            "logical_id": "renewal-L-1042-2026-11",
        },
    },
    timeout=30,
)
call = response.json()
```

**2. If it times out, fails to connect or answers `5xx`,** look for the call before sending again: list calls to that number since just before your first attempt, and match your `metadata`:

```bash
curl -X GET "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09:20:00.000Z" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09:20:00.000Z", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const calls = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
    },
    params={
        "to": "+12025550100",
        "created_after": "2026-10-21T09:20:00.000Z",
    },
    timeout=30,
)
calls = response.json()
```

If a call with your `logical_id` is in `data`, it was placed: store its id and stop. Calls appear in the list as soon as they are accepted, but allow a few seconds after a timeout before you conclude it is not there. Only if it is not, send the request from step 1 again.

**3. If it answers `429` or `503`,** nothing was done: wait `Retry-After` and send it again.

Complete runnable example: [cURL script](/examples/guides/retry-and-reliability/create-call-reliably.sh) · [JavaScript](/examples/guides/retry-and-reliability/create-call-reliably.mjs) · [Python](/examples/guides/retry-and-reliability/create-call-reliably.py)

The same pattern applies to campaign starts and resumes (read the campaign: `running` means it started), contact upserts (repeating one is harmless) and every other write.

## Optional: an idempotency key

For extra safety you can send an `Idempotency-Key` header (any unique value, a UUID is ideal) on a POST or DELETE, and the same key on every retry of that request. Then a retry of a request that already succeeded returns the original answer with `Idempotent-Replayed: true` instead of running again, for 24 hours. Create the key once per action and save it **before** the first attempt, so a retry after a crash reuses it. The rules, including the 409 answers for a key reused with a different request, a first attempt still running, or an interrupted one, are in [Idempotency](/fundamentals/idempotency).

A key does not replace the state check after a `500`: a `500` is not stored under the key, so a retry with the same key runs the request again.

PATCH requests are protected by `If-Match` instead (see [Concurrency](/fundamentals/concurrency)): repeating the same PATCH with a fresh ETag is harmless.

## Backoff

- **Exponential with jitter.** Wait a random time between 0 and `min(cap, base × 2^attempt)`. Jitter spreads retries from many workers so they do not hit the API in synchronised waves. The complete example uses a base of 1 second and a cap of 30.
- **Honour `Retry-After`** on `429` and `503` (and on any error with `retry: "after"`); it is the earliest time the request can succeed.
- **Cap the attempts** (for example 5 or 6) and the total time. Then park the action, check its state later, or alert a person.
- **Retry only what can succeed.** Never retry `retry: "no"`. Do not retry `400`, `401`, `403`, `404` or `422` in a loop.

## Rate limits and the daily call cap

Limits are per key and per workspace, separately in test and live mode, in three classes. Each operation's class is in the API reference (`x-rate-limit-class`):

| Class | Operations | Per key per minute | Per workspace per minute |
|---|---|---|---|
| `read` | Every GET | 600 | 1200 |
| `write` | Other writes (contacts, DNC, campaign create, pause, stop, edits, webhooks) | 120 | 240 |
| `dial` | `POST /calls`, campaign start and resume | (workspace limit) | 30 |

Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window resets). Over the limit you get [`rate_limited`](/errors/rate_limited) (429) with `Retry-After`. Read the headers and slow down before you hit the limit: a batch job should pace itself (see [Common integration patterns](/guides/common-integration-patterns)).

Two more limits concern calls, and neither is a rate limit:

- [`calling_capacity_reached`](/errors/calling_capacity_reached) (429): call starts are paced across the telephony network. The request was valid; nothing was dialled. Wait `Retry-After` and send it again, or use a campaign for volume.
- [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429): live mode caps calls placed through the API per workspace per UTC day, as protection against runaway scripts and retry storms (`limits.daily_call_limit` on [`GET /workspace`](/api/workspace#retrieve-the-workspace)). It resets at 00:00 UTC; `Retry-After` and `details.resets_at` say when. A campaign start counts every recipient and is refused whole when they do not all fit (`details.requested`, `details.remaining`); a resume never counts. If your expected volume is higher, write to support@relationsai.com. Do not "retry until it works".

When the API is unavailable it fails closed with [`service_unavailable`](/errors/service_unavailable) (503): nothing was done; send the request again after `Retry-After`.

## When the outcome is unknown

Some failures leave you not knowing whether the action happened. Treat them as **"may have happened"** until you have checked.

- **Timeout or connection error on a POST**: the request may have been processed. Check the state (step 2 above) before sending it again. If you sent an idempotency key, sending the same request with the same key is also safe: a replay tells you it happened.
- **[`internal_error`](/errors/internal_error) (500)**: the write may have been applied. Read the resource first; send it again only if it did not happen.
- **`202` with the call `queued`**: accepted; the start is still being confirmed. Do nothing new. Follow the call: it proceeds, or ends with `end_reason: not_confirmed` (the start was never confirmed; not charged).
- **`202` from a campaign start**: started, confirmation pending. Read the campaign in a minute; do not start it again.
- **[`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409)**: a start or resume was sent and not confirmed; dialling may have begun. Wait `Retry-After`, then read the campaign: `running` means it started. Only if it did not, send the action again (the campaign refuses to dial the same run twice). If you sent an idempotency key, the same key returns this answer again. If `details.needs_support` is true, contact support first.
- **[`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) (409)**: the pause may not have taken effect; the campaign may still be dialling. Read the campaign; if it is still running, pause again or stop it.
- **[`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) (409)**: only with an idempotency key: the first attempt with that key was interrupted mid-way. Check whether it happened before sending anything; only if it did not, send it again with a **new** key or none.

If the state is still ambiguous after a few minutes, write to support@relationsai.com with the `request_id`.

## What can go wrong

- **Resending a call after a timeout without checking**: a second call. Instead: list calls by `to` and `created_after`, match your `metadata`, then decide.
- **No id of your own in the request**: you cannot tell your call from others after a timeout. Instead: put your record id in `metadata`.
- **A new Idempotency-Key per retry** (if you use keys): no protection at all. Instead: one key per action, saved before the first attempt.
- **The same key for a different request**: [`idempotency_key_reused`](/errors/idempotency_key_reused). Instead: a new key for each new action.
- **Retrying `retry: "no"` errors**: wasted requests, possible rate limiting. Instead: fix the cause or accept the refusal.
- **Retrying in a tight loop**: [`rate_limited`](/errors/rate_limited), then `daily_call_limit_reached`. Instead: backoff with jitter and a cap.

## Next steps

[Going live](/guides/going-live).
