# Idempotency

`Idempotency-Key` is an **optional** header for `POST` and `DELETE`. When you send one, a retry of the same request returns the original answer instead of running the action again. When you do not, every request runs. This page explains both, and how to retry safely either way.

## When to send a key

No request requires an `Idempotency-Key`. Without one, the API runs every request it receives: if a request times out and you send it again, the action can happen twice, including placing a **second call** or adding a contact twice.

Send a key when a retry must not repeat the action, typically when placing calls, starting or resuming campaigns, and in any code that retries automatically.

- The value is 1 to 255 printable ASCII characters, no spaces. A UUID v4 is ideal. An empty, too long or non-ASCII value answers [`invalid_idempotency_key`](/errors/invalid_idempotency_key).
- Use one key per **intended operation** (one call to place, one contact to delete), not one per HTTP attempt, and store it with your own record before the first attempt so a restarted worker retries with the same key.
- `GET` never needs a key (reads change nothing), and `PATCH` uses `If-Match` instead (see [PATCH](#patch)).

## Scope and retention

- A key is scoped to your **workspace and mode**: the same key in test mode and live mode, or in two workspaces, never collides.
- A key identifies one request: the same operation, the same path and the same body.
- Keys are kept for **24 hours** after the first request with the key completes. After that, the same key is treated as new.
- **What is stored:** successful (`2xx`) results, and a few answers whose outcome is uncertain because work may have started, such as [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown). A retry with the same key returns the stored answer for 24 hours.
- **What is not stored:** refusals (for example [`validation_error`](/errors/validation_error) or [`agent_not_ready`](/errors/agent_not_ready)) and `500` [`internal_error`](/errors/internal_error). After a refusal, fix the cause and send the request again with the same key. After an `internal_error`, a retry with the same key runs the request again, so read the resource first and retry only if the change is not there.

## Replays

Place a call with a key:

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

response = requests.post(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
        "Idempotency-Key": "<unique-key>",
    },
    json={
        "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
        "to": "+12025550100",
        "metadata": {
            "crm_lead_id": "L-20931",
        },
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```json
{
  "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7",
  "object": "call",
  "livemode": false,
  "status": "queued",
  "outcome": null,
  "end_reason": null,
  "direction": "outbound",
  "source": "api",
  "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
  "campaign_id": null,
  "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu",
  "from": "+12025550150",
  "to": "+12025550100",
  "metadata": {
    "crm_lead_id": "L-20931"
  },
  "created_at": "2026-10-21T09:30:00.000Z",
  "ended_at": null,
  "duration_seconds": null,
  "billable_minutes": null,
  "cost": null,
  "artifacts": {
    "transcript": {
      "state": "pending"
    },
    "recording": {
      "state": "pending"
    },
    "analysis": {
      "state": "pending"
    }
  },
  "settled": false,
  "settled_at": null
}
```

If the response is lost (a timeout, a dropped connection), send **exactly the same request with the same key**. The API answers with the original call (the same `call_...` id) and marks the answer as a replay; no second call is placed:

```http
HTTP/1.1 202 Accepted
Idempotent-Replayed: true
X-Request-Id: req_7d3kqf2mzv6xw4tb5nsc8hpa

{
  "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7",
  "object": "call",
  "livemode": false,
  "status": "queued",
  "outcome": null,
  "end_reason": null,
  "direction": "outbound",
  "source": "api",
  "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
  "campaign_id": null,
  "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu",
  "from": "+12025550150",
  "to": "+12025550100",
  "metadata": {
    "crm_lead_id": "L-20931"
  },
  "created_at": "2026-10-21T09:30:00.000Z",
  "ended_at": null,
  "duration_seconds": null,
  "billable_minutes": null,
  "cost": null,
  "artifacts": {
    "transcript": {
      "state": "pending"
    },
    "recording": {
      "state": "pending"
    },
    "analysis": {
      "state": "pending"
    }
  },
  "settled": false,
  "settled_at": null
}
```

The replay has the same status and data (the JSON may not be byte-for-byte identical). A replayed campaign or contact answer carries the `ETag` stored with it, which may be out of date: read the object again before you update it.

## Conflicts

The same key with a different body (here, another number) is refused, and nothing runs:

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

response = requests.post(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
        "Idempotency-Key": "<unique-key>",
    },
    json={
        "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
        "to": "+12025550101",
        "metadata": {
            "crm_lead_id": "L-20931",
        },
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```json
{
  "error": {
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request.",
    "details": {},
    "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh",
    "retry": "no",
    "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused"
  }
}
```

- **Same key, different request:** [`idempotency_key_reused`](/errors/idempotency_key_reused). A new operation needs a new key; a retry must be exactly the original request.
- **Same key while the first request is still running:** [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress). Wait `Retry-After` seconds and send the same request again.
- **Same key after the first request was interrupted:** [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown). Check whether the action happened; only if it did not, send it with a **new** key.

**Never generate a new key to retry.** That is exactly how an action runs twice.

## Without a key

Without a key, the API cannot tell a retry from a new request. After a timeout or a dropped connection, do not send the request again blindly: first check whether it already ran. For a call, list calls to the same number created since just before your attempt, and look for the `metadata` you sent:

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

response = requests.get(
    "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09%3A29%3A00.000Z",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

If the call is in the list, it was placed: do not send the request again. If it is not, send it again. The same applies to other actions: read the contact, campaign or entry before repeating a create or a delete.

## After a timeout

With a key, recovering from a lost response is simpler:

1. Send the **identical** request with the **same** key, with backoff. If the first attempt finished, you get its stored result (`Idempotent-Replayed: true`). If it is still running, you get `idempotency_key_in_progress`: wait `Retry-After` and try again.
2. If you get `idempotency_outcome_unknown` or `internal_error`, check the state as described in [Without a key](#without-a-key).
3. Only if the action did not happen, send it again: with a new key after `idempotency_outcome_unknown`, with the same key after `internal_error`.
4. Retry within 24 hours. After that the key is forgotten and a retry runs the action again, so check state first.

## Retrying safely

A retry loop for a `POST` with a key decides each step from the answer:

1. No response at all, or `retry: after` / `retry: yes` (other than `internal_error`): wait (backoff or `Retry-After`) and send the same request with the same key.
2. [`internal_error`](/errors/internal_error): check the state; if the action is not there, send the same request with the same key.
3. [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown): check the state; if the action is not there, send the request with a **new** key.
4. Anything with `retry: no`: stop and fix the cause.

The complete runnable example demonstrates this optional feature, so it sends an `Idempotency-Key` on purpose. It places a test call with one key, retries with that key, checks for the call after a `500` or an unknown outcome, and repeats the request to show that the same call comes back.

Complete runnable example: [cURL script](/examples/fundamentals/idempotent-retry.sh) · [JavaScript](/examples/fundamentals/idempotent-retry.mjs) · [Python](/examples/fundamentals/idempotent-retry.py)

## PATCH

`PATCH` requests do not take an `Idempotency-Key`. Updates to campaigns and contacts are protected by `If-Match` instead: a lost response followed by a retry answers [`version_conflict`](/errors/version_conflict) if the first attempt already applied, so read the object again and check whether your change is there. See [Concurrency](/fundamentals/concurrency).

## Webhooks

Idempotency works the other way round for events: RelationsAI may deliver the same event more than once, and your receiver discards duplicates by the event `id`. See [Webhooks](/fundamentals/webhooks).
