Skip to content
RelationsAI
API fundamentals

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.
  • 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).

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. A retry with the same key returns the stored answer for 24 hours.
  • What is not stored: refusals (for example validation_error or agent_not_ready) and 500 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:

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" } }'
JSONJSON
{ "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:

Text
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:

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" } }'
JSONJSON
{ "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. 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. Wait Retry-After seconds and send the same request again.
  • Same key after the first request was interrupted: 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:

curl "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09%3A29%3A00.000Z" \ -H "Authorization: Bearer <token>"

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.
  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: check the state; if the action is not there, send the same request with the same key.
  3. 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

Place a call so that a retry can never place it twice.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

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 if the first attempt already applied, so read the object again and check whether your change is there. See 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.