Skip to main content

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.
The SDKs 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.

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: 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: 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 and the webhook consumer playbook 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:
The worker sees a connection 500 and re-queues the job with its stored key. Attempt 2 — same key, identical body:
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:
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:
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. 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:
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.