Idempotency and safe retries
EveryPOST that creates a resource — a message, a call, a contact, a campaign, a top-up — accepts an Idempotency-Key header. This page explains the contract behind that header as a single model: what Orbit guarantees on a replay, where a key is mandatory rather than optional, how wallet mutations defend themselves at a second level, and what your SDK already does on your behalf. For the error codes referenced here (IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_KEY_REQUIRED, INVALID_IDEMPOTENCY_KEY, DEDUCT_IN_FLIGHT, BALANCE_SERVICE_UNAVAILABLE), see Error codes.
1. The contract: same key, same result
Send anyPOST that creates with an Idempotency-Key header set to a value you choose:
- Same key + same body → the cached response. You get the original result back, exactly as it was first returned — no duplicate send, no second charge. Use this to recover the response after a timeout, a connection drop, or a
5xxwhere you can’t tell whether the request landed. - Same key + different body →
409 IDEMPOTENCY_KEY_REUSED. Reusing a key against different arguments is a bug in your client, and Orbit refuses it loudly rather than guess which request you meant.
order-conf-98421, invoice-run-2026-08-26-acme — is easier to debug than a random UUID, because the key shows up in your logs alongside the event that produced it. After 24 hours the cache entry expires and the same key is treated as fresh, so don’t build workflows that depend on longer retention.
Idempotency-Key guards the creation handshake. It is not a substitute for webhook delivery semantics, which is the at-least-once contract for events flowing the other way; both rely on deduplication, but each direction has its own window and its own key.
2. Money-moving endpoints: the key is required
On endpoints that move money — top-up checkout and the endpoints that follow the same pattern — theIdempotency-Key header is required, not optional:
- Missing the header entirely →
IDEMPOTENCY_KEY_REQUIRED. - Sending a key that fails shape validation (length or charset) →
INVALID_IDEMPOTENCY_KEY.
3. Wallet-level concurrency: the second line of defense
The API-level cache answers “did I already run this request?” Balance mutations additionally guard “is this exact balance change already in flight right now?” — two callers racing with the same idempotency key at the same moment. Orbit deduplicates concurrent balance operations in a short window and reports the collisions:DEDUCT_IN_FLIGHT— another request holding the same idempotency key is still running and hasn’t posted its final result within the poll window. Your key is in use right now; wait for the in-flight request to finish and replay, rather than firing a parallel attempt.BALANCE_SERVICE_UNAVAILABLE(503) — the dedup layer itself is temporarily unreachable, so balance changes are refused instead of running unguarded. Retry with exponential backoff; the SDKs already classify503as retryable.
503. Both exist so that even under concurrency, a wallet charge happens once per named key.
4. SDK behavior: keys you don’t have to think about
Every Orbit SDK auto-generates anIdempotency-Key (UUIDv4) on every non-GET request. A blind retry — the SDK’s own 3-attempt backoff on 429/5xx, or your catch-block re-call — therefore never produces a duplicate even if you never set a key yourself. See SDKs for the status and per-language override syntax; the override is always a single option or argument named idempotencyKey / idempotency_key.
The one place you should supply your own key is when retries leave the process that mints the auto key — for example, when a send job sits in your own queue (BullMQ, SQS, Resque) and a worker crash re-runs the job in a fresh process. Generate one stable key per job, store it with the job payload, and pass it on every attempt:
409 is the guard telling you your queue mutated the payload between attempts — fix the job so every attempt carries identical arguments.
Putting it together
- On every creating
POST, send anIdempotency-Key— natural identifiers from your domain beat random ones for debuggability. - On money-moving endpoints the key is mandatory; treat
IDEMPOTENCY_KEY_REQUIREDandINVALID_IDEMPOTENCY_KEYas client bugs to fix, not statuses to retry. - Retries after timeouts and
5xxare safe by construction: same key + same body always returns the original result within 24 hours. - When retries cross process boundaries (your own queue), derive one stable key per job and pass it explicitly — the SDK override exists for exactly this case.