> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Do idempotent requests across the Orbit API

> Use the Idempotency-Key header correctly on every create call — pick the right key, read the replay and conflict responses, recover from a Redis 503, and dedupe bulk uploads. Includes worked curl examples.

# Do idempotent requests across the Orbit API

Every Orbit `POST` or `PUT` that creates or mutates honors an `Idempotency-Key` header — one header, one contract, across messages, voice, contacts, dialer, batch inference, porting, and wallet passes. This guide walks the contract end to end: which endpoints honor it, how to pick a key, what the TTL windows are, how each failure mode responds, and two worked retry examples you can mirror in your own queue worker.

<Note>
  The [SDKs](/sdks/index) auto-generate an `Idempotency-Key` on every non-GET request, so blind retries from inside a single process are already safe. You need this guide when a retry can leave your process — a queue worker, a backfill script, a webhook consumer — or when you want deterministic `replayed` behavior like wallet passes.
</Note>

## 1. What the header guarantees

When you send `Idempotency-Key: <your-key>` on a `POST` or `PUT`:

* **Cached-response short-circuit.** The completed response (status code + body) is stored against the key. A retry with the same key and the same body returns that original response — the handler never re-runs, so no second send and no second charge.
* **Body fingerprint.** The request body is hashed (key order within objects is normalized, so reformatting JSON between attempts is fine). A retry whose body differs from the original is rejected with `409 IDEMPOTENCY_KEY_REUSED` instead of silently dropping one of the two requests.
* **In-flight lock.** A second request with the same key while the first is still running gets `409 CONFLICT` with a `Retry-After: 2` header. The first request caches its result when it finishes, and the retry then replays that result.
* **Replay markers.** A replayed response carries `Idempotency-Replay: true` (and the back-compat `X-Idempotent-Replayed: true`). Wallet passes also return `replayed: true` in the response body — see section 8.

Safe methods (`GET`, `DELETE`, `HEAD`, `OPTIONS`) ignore the header. Keys are capped at 255 characters, and one is only honored when the request also carries a tenant credential (`X-API-Key` or `Authorization`) — keys are scoped per tenant and per endpoint path, so the same key value never collides across two tenants or two different endpoints. Without a credential the request gets a `400 VALIDATION_ERROR`.

## 2. Which routes honor it

All authenticated `POST`/`PUT` endpoints on `https://api.orbit.devotel.io/api/v1` — the header is a platform-level contract, not a per-route opt-in. The ones integrators hit most:

| Surface         | Endpoint                                                                                          | Why the key matters                                                                                           |
| --------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Messaging       | `POST /messages/sms`, `/messages/whatsapp`, `/messages/rcs`, `/messages/viber`, `/messages/email` | A retried send must not double-send or double-charge.                                                         |
| Voice           | `POST /voice/calls`                                                                               | A retried outbound call must not double-dial.                                                                 |
| Contacts        | `POST /contacts`, `POST /contacts/imports`                                                        | A retried create/import must not append duplicates.                                                           |
| Dialer          | `POST /dialer/*`                                                                                  | Retried campaign launches must not double-launch.                                                             |
| Batch inference | `POST /agents/batch-inference`                                                                    | A retried batch submission must not run twice.                                                                |
| Wallet passes   | `POST /wallet-passes/issue`                                                                       | Also accepts `idempotency_key` as a body field — a retried issue must not mint a second pass (see section 8). |
| Porting         | `POST /numbers/porting`, `POST /numbers/porting/check`, `POST /numbers/port-in/bulk-csv`          | A retried port submission must not open a second port order.                                                  |

On money-moving endpoints such as wallet top-up checkout the header is **required**: missing it gets `IDEMPOTENCY_KEY_REQUIRED`; a key that fails shape validation gets `INVALID_IDEMPOTENCY_KEY`. Wherever a silent retry could double-charge you, the API refuses to run unguarded.

## 3. Picking the key

Two key choices work. Choose per operation type:

* **A business identifier** — for an operation with a natural id in your own domain: `order-conf-98421`, `enroll-10482-2026-08`, `campaign-2026-09-step-3`. This makes the key readable in your logs and means every retry of that operation lands on the same key.
* **A random UUID** — for one-shot operations with no natural id (a compose-screen send, an ad-hoc test). Generate it once, when you build the request, and reuse it for every retry of that request.

The critical rule: scope the key to the **logical operation, not the attempt**. A key like `job-7a3b9d-attempt-1` breaks the moment your retry loop hits attempt 2 — each attempt becomes a brand-new operation the API has never seen. Derive the key from the business object (enrollment id, `campaign_id + step`, port row id, job id) and keep it constant across all attempts of that operation. After the cache entry expires (24 hours for successful responses), the same key value is treated as fresh.

## 4. TTL and cache semantics

Response class decides how long the cached entry lives:

| Response class                                        | Cached for     | Behavior on a matching-body retry                                                                                                                               |
| ----------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Success (`2xx`)                                       | **24 hours**   | Full replay — the original success comes back.                                                                                                                  |
| Client error (`4xx`, except auth/transport codes)     | **5 minutes**  | The error replays, so a misconfigured retry loop can't burn your quota or re-run validation for five minutes. After expiry, a corrected input retries as fresh. |
| Server error (`5xx`)                                  | **30 seconds** | Covers the transport retry burst (SDKs retry a few times on `5xx`) so a provider blip doesn't double-charge through the retry storm.                            |
| Auth failure (`401`/`403`) or transport (`408`/`429`) | **Not cached** | Auth is always re-evaluated live; a throttled retry still hits the live limiter.                                                                                |

Body mismatch is handled on the cached entry:

* **Cached success + different body → `409 IDEMPOTENCY_KEY_REUSED`.** The successful result claimed the key; refuse to guess which request you meant.
* **Cached failure + corrected body → runs fresh.** A first attempt that failed did not claim the key — a retry that fixes the recipient, tops up the balance, or rewrites the body is allowed to converge instead of being trapped in a `409` loop.

Treat `409 IDEMPOTENCY_KEY_REUSED` as a client bug: fix your payload or mint a new key. Never treat it as a retryable status.

## 5. Failure modes and safe recovery

**Redis unavailable → `503 SERVICE_UNAVAILABLE`.** Idempotency deliberately fails closed: if the cache layer can't be reached, the request is refused rather than run unguarded. The response carries `Retry-After: 2`. Retry with backoff using the **same key** — the retry works exactly like the first call, and once the cache recovers your key is honored normally. Nothing was processed on a `503`, so the same-key retry is safe by construction. These blips are rare and typically clear within seconds; SDK retry loops handle them automatically.

Three retry-unsafe cases:

1. **Retrying a `POST` with no key.** The first attempt may have landed while the connection dropped. Without a key, the retry is a second operation.
2. **Retrying with a mutated body.** You get `409 IDEMPOTENCY_KEY_REUSED` on a cached success. Change only what you must to satisfy the guard — for a blocked first failure you may correct the body — and keep every retry byte-identical otherwise.
3. **Retrying across rotating credentials.** The key is scoped to the credential that sent it. If you rotate an API key mid-retry, the new credential's scope no longer recognizes the old key's cache entry. Finish the operation (or let the 24h window expire) before rotating, or generate a fresh key after rotation and accept that this is treated as a new operation.

## 6. Idempotency vs webhook delivery

The `Idempotency-Key` header protects the outbound direction — your retries against Orbit. Inbound to you, Orbit webhooks are **at-least-once**: a stalled or `5xx` receiver gets the event redelivered, sometimes more than once. Your receiver must dedupe on the event's stable `id` the same way Orbit dedupes on your key. The two directions have separate windows and separate keys; use both. See [Build your first webhook receiver](/webhooks/first-receiver) and the [webhook consumer playbook](/guides/webhook-consumer) for the dedupe-table pattern.

## 7. Worked example — a queue worker retrying a send

A worker sends a message and the first attempt dies at the network with a `500`. The job carries one key for the logical send, so the retry is a replay:

**Attempt 1 — the job pulls `job_abc123` off the queue:**

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: send-ship-98421" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+16572262362",
    "to": "+14155552671",
    "body": "Shipment #98421 is out for delivery"
  }'
```

The worker sees a connection `500` and re-queues the job with its stored key.

**Attempt 2 — same key, identical body:**

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: send-ship-98421" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+16572262362",
    "to": "+14155552671",
    "body": "Shipment #98421 is out for delivery"
  }'
```

Attempt 2 returns the cached response from attempt 1 — status `202`, the same `msg_…` id, no second send, no second charge. The response headers mark it:

```
HTTP/2 202
Idempotency-Replay: true
X-Idempotent-Replayed: true
```

```json theme={null}
{
  "data": {
    "id": "msg_01HXYZ...",
    "channel": "sms",
    "direction": "outbound",
    "from": "+16572262362",
    "to": "+14155552671",
    "status": "sent",
    "external_id": "prov_7f3c91...",
    "created_at": "2026-09-18T10:23:00Z"
  },
  "meta": {
    "request_id": "req_01HXYZ...",
    "timestamp": "2026-09-18T10:23:00Z"
  }
}
```

`Idempotency-Replay: true` is a success signal — the dedupe working. Treat it as informational, log it if you track dedupe rates, and never as an error.

## 8. Worked example — wallet pass issue and bulk CSV dedupe

**Wallet pass issue.** `POST /wallet-passes/issue` accepts the key both as the `Idempotency-Key` header and as an `idempotency_key` body field — the body field is the documented form for passes:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/issue \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "loyalty_card",
    "contact_id": "ctn_9f8e7d6c5b4a",
    "title": "Aurora Coffee Rewards",
    "barcode_message": "MEMBER-10482",
    "barcode_format": "QR_CODE",
    "idempotency_key": "enroll-10482-2026-08"
  }'
```

A first successful issue returns `201` with the new `wps_…` pass id. A retry with the same `idempotency_key` returns `200` with the original pass and `"replayed": true` in the body — no duplicate pass on the customer's phone. A retry without the key mints a second pass, so keep the key on any flow that can be re-run (enrollments, backfills, webhook consumers). Full lifecycle in the [wallet pass guide](/guides/wallet-passes).

**Bulk CSV submissions.** For port-in bulk (`POST /numbers/port-in/bulk-csv`) and contact imports, hash the file and use the hash as the key:

```bash theme={null}
FILE_HASH=$(sha256sum ports-2026-09-18.csv | cut -d' ' -f1)

curl -X POST https://api.orbit.devotel.io/api/v1/numbers/port-in/bulk-csv \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: port-bulk-${FILE_HASH}" \
  -F "file=@ports-2026-09-18.csv"
```

An operator double-clicking the upload, or a backfill re-run against the same file, replays the original submission instead of opening duplicate port orders. A genuinely changed file produces a different hash — a different key — and runs as the new batch it is.

## 9. Anti-patterns

Avoid these; each one breaks the guarantee you're asking for:

* **Timestamp or random-per-retry keys.** `Date.now()` or a fresh UUID inside the retry loop makes every attempt a new operation — the API has never seen the key and processes the send again. Generate the key once per logical operation.
* **Missing the key on retry after a 5xx.** If the first attempt had a key and the retry doesn't, the retry escapes the guard. Store the key with the job and send it on every attempt, unconditionally.
* **Treating `Idempotency-Replay: true` as an error.** A replay is the API telling you dedupe worked — the result is the success from the first attempt. Failing the job on that header makes your queue misreport settled operations.
* **Key per attempt suffix.** `key-attempt-1`, `key-attempt-2` — same defect as the timestamp pattern under a different shape. One key per operation, constant across attempts.
* **Reusing one key across distinct operations.** `idempotency-key: send` on every send makes the second send replay the first one's result. Keys must differ per logical operation.

## Related

* [Idempotency and safe retries](/concepts/idempotency-and-safe-retries) — the concept model behind the header, including money-moving endpoints and wallet-level guards.
* [Build your first webhook receiver](/webhooks/first-receiver) — at-least-once delivery in the other direction and consumer-side dedupe.
* [Rate limits](/guides/rate-limits) — the `429` contract and `retry_after` back-off.
* [Error handling examples](/guides/error-handling-examples) — reading `error.code`, `REQUEST_ID`, and retry classification.
