> ## 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.

# Idempotency and safe retries

> How the Idempotency-Key contract works across the Orbit API — the 24-hour replay cache, the 409 body-mismatch guard, which endpoints require a key, the wallet-level re-entry protection behind balance mutations, and how every SDK generates (or overrides) keys for you.

# 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](/reference/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:

```bash theme={null}
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:

* **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](/concepts/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](/sdks/index) 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:

```bash theme={null}
# First attempt — key derived from your job id
curl https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: job-7a3b9d-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{"to":"+14155552671","body":"Shipment #98421 is out for delivery"}'
```

Retry the job with the **same** key and body — Orbit returns the original response instead of a second send:

```json theme={null}
{
  "code": "IDEMPOTENCY_KEY_REUSED",
  "message": "Idempotency-Key 'job-7a3b9d-attempt-1' was used with a different request body"
}
```

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.
