Skip to main content

Idempotency and safe retries

Every POST 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 any POST that creates with an Idempotency-Key header set to a value you choose:
Orbit stores the completed response against that key for 24 hours. Within that window:
  • 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 5xx where 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.
Pick keys that are unique per logical action. A natural identifier from your own domain — 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 — the Idempotency-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.
The rule of thumb: anywhere a retry could silently double-charge you or your customer, Orbit refuses to run the mutation until you make the retry intent explicit by naming it with a 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 classify 503 as retryable.
You normally never see either: dedup collisions resolve within the poll window, and the SDK retry loop covers the 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 an Idempotency-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:
Retry the job with the same key and body — Orbit returns the original response instead of a second send:
That 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

  1. On every creating POST, send an Idempotency-Key — natural identifiers from your domain beat random ones for debuggability.
  2. On money-moving endpoints the key is mandatory; treat IDEMPOTENCY_KEY_REQUIRED and INVALID_IDEMPOTENCY_KEY as client bugs to fix, not statuses to retry.
  3. Retries after timeouts and 5xx are safe by construction: same key + same body always returns the original result within 24 hours.
  4. 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.