Every creating POST on the Orbit API — sending a message, placing a call, registering a contact, topping up a wallet — accepts an Idempotency-Key header. Send the same key with the same body and you get the original response back, not a duplicate. This post walks through the full contract: why naive retries double-charge, where the key is required rather than optional, what the SDKs generate for you, the error codes you can hit, and one worked retry sequence end to end.
The quick answer
Attach an Idempotency-Key header to any POST that creates a resource:
curl https://api.orbit.devotel.io/api/v1/messages/sms \
-H "X-API-Key: dv_live_sk_..." \
-H "Idempotency-Key: order-conf-98421" \
-H "Content-Type: application/json" \
-d '{"to":"+14155552671","body":"Your order confirmation"}'Orbit stores the completed response against that key for 24 hours. Within that window, a retry carrying the same key and the same body returns the cached response — the original message id, exactly as it first came back — with no second send and no second charge. Timeouts, dropped connections, and 5xx responses where you can't tell whether the request landed all become safe to retry. Use natural identifiers from your own domain (order-conf-98421, invoice-run-2026-08-26-acme) rather than random UUIDs; the key shows up in your logs next to the event that produced it, which makes debugging straightforward. After 24 hours the cache entry expires and the same key is treated as fresh.
The full contract is in the idempotency and safe retries concept page in the docs.
Why naive retries double-charge
Without a key, a retry is indistinguishable from a second intentional request. Your client's HTTP library times out after the carrier accepted the message but before the response arrived, so you fire again — and the API genuinely cannot tell the second attempt from a deliberate second send. Two messages go out, two wallet deductions land, and your customer gets the same SMS twice.
Orbit's money-moving endpoints answer this with a second layer underneath the API-level cache. The replay cache answers "did I already run this request?"; the wallet layer answers "is this exact balance change already in flight right now?" — the case of two callers racing with the same key at the same moment. Concurrent balance operations are deduplicated in a short window, so even under parallel retries a wallet charge happens once per named key. If that dedup layer itself is unreachable, the mutation is refused rather than run unguarded (503 — see the error codes section below).
This is why "retry with backoff" alone is not a reliability strategy for APIs that move money. Backoff decides when to retry; idempotency decides whether retrying is safe. Orbit makes it safe by construction.
The 409 body-mismatch guard, and when a key is required
The reuse protection works both ways:
- Same key + same body → the cached response. This is the happy path for every legitimate retry.
- Same key + different body → `409 IDEMPOTENCY_KEY_REUSED`. Reusing a key against different arguments is a client bug, and the API refuses it loudly instead of guessing which request you meant.
On endpoints that move money — top-up checkout and the mutations that follow the same pattern — the header is required, not optional:
- A missing header →
IDEMPOTENCY_KEY_REQUIRED. - 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, the API refuses to run the mutation until you make the retry intent explicit by naming it with a key. Every other creating POST accepts a key on an optional basis, and it is worth sending one everywhere — optional means the guard is available when you want it, not that duplicates are fine.
What the SDKs generate, and when to override
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.
The one place you should supply your own key is when retries leave the process that mints the auto key. A send job sitting in your own queue (BullMQ, SQS, Resque) that re-runs in a fresh process after a worker crash is the canonical case. Generate one stable key per job, store it with the job payload, and pass it on every attempt via the SDK's idempotencyKey / idempotency_key option:
// Stable key stored with the job payload — identical across attempts
const idempotencyKey = job.data.idempotencyKey; // e.g. "job-7a3b9d-attempt-1"
await orbit.messages.sendSms(
{ to: "+14155552671", body: "Shipment #98421 is out for delivery" },
{ idempotencyKey },
);Every attempt then either performs the send exactly once or replays the original result. The per-language override syntax and SDK status table are on the SDKs page.
Error codes reference
The five codes this contract can raise, in the order you are likely to meet them (all documented in the error codes reference):
| Code | HTTP | What it means | What to do |
|---|---|---|---|
IDEMPOTENCY_KEY_REUSED | 409 | The same key arrived with a different body | Client bug — keep payloads identical across retries |
IDEMPOTENCY_KEY_REQUIRED | 400 | A money-moving endpoint was called without a key | Treat as a client bug, not a retryable status |
INVALID_IDEMPOTENCY_KEY | 400 | The key failed shape validation (length/charset) | Fix key generation |
DEDUCT_IN_FLIGHT | 409 | Same key still running; result not posted yet | Wait for the in-flight request, then replay |
BALANCE_SERVICE_UNAVAILABLE | 503 | The dedup layer is unreachable, so balance changes are refused | Retry with backoff — SDKs classify 503 as retryable |
The first three are client bugs to fix in code. The last two are transient by design: dedup collisions normally resolve within the poll window, and the SDK retry loop handles the 503.
A worked sequence: client retry → safe replay
One end-to-end pass through the contract:
- First attempt. Your queue worker sends
POST /api/v1/messages/smswithIdempotency-Key: job-7a3b9d-attempt-1and the shipment body. The carrier accepts, but the connection drops before the response arrives. - Retry with the same key and body. The job framework re-runs the job with the stored key. Orbit finds the completed response in the 24-hour replay cache and returns the original message id — no second send, no second charge.
- If two attempts race. Two workers holding the same key hit the balance mutation simultaneously; the wallet-level dedup makes the second one answer with
DEDUCT_IN_FLIGHTinstead of charging twice. It waits for the in-flight request and replays. - If the payload mutated between attempts. Say the job re-rendered the body and the second attempt carries different text — Orbit answers
409 IDEMPOTENCY_KEY_REUSED, and the log line tells you exactly which divergent payload to fix.
That is the whole loop: name the action with a key, retry freely, and let the two-level guard (API replay cache, then wallet dedup) absorb the messy parts of distributed retry.
Frequently asked questions
Do I have to send an Idempotency-Key on non-money endpoints?
No — it is optional there. But every Orbit SDK sends one automatically on every non-GET request, so if you are on an SDK the guard is already active. Raw HTTP integrations should send one on every creating POST.
How long does the replay window last?
24 hours. After the window the cache entry expires and the same key is treated as fresh, so don't build workflows that depend on longer retention.
What key format should I use?
Any string that passes shape validation (see the docs for length and charset rules). Natural identifiers from your domain — order ids, invoice-run ids, job ids — beat random UUIDs because the key appears in your logs alongside the event that produced it.
Is this the same as webhook delivery semantics?
No. Idempotency-Key guards the creation handshake (POSTs flowing into Orbit). Webhook delivery semantics is the at-least-once contract for events flowing the other way. Each direction has its own window and its own key.