Idempotency
One header on one endpoint, and the part of this API integrators most often get wrong. Read it in order: the rules are simple, but two of them are surprising and both cost real money when they surprise you in production.
The header
Idempotency-Key is required on POST /orders. There is no way to place an order without one, and no other endpoint accepts one because no other endpoint mutates anything.
- 16–255 characters after trimming. A UUID is 36 and fits.
"1"does not, deliberately. - Missing, blank or out of range is
400 request.invalidwithparam: "Idempotency-Key". - Unique per partner, across both modes. Two different partners may both use
order-1; you may not use it in sandbox and then in live. - Keys are never freed and never expire. There is no TTL anywhere in this mechanism.
What actually happens
The order row is written — claiming your key on a uniqueness constraint — before the money moves and before anything reaches a supplier. A duplicate delivery loses that race at the database rather than at the application, so two identical requests arriving at the same instant on two servers cannot both proceed. There is no window in which a retry can produce a second order.
Which means the fingerprint matters, so here is exactly what it is taken over: plan_id, quantity, reference and metadata, with keys sorted at every level and normalised values.
The four things a reused key can do
Send the same key twice and one of exactly four things happens. Three of them are ordinary; the fourth is the one to design around.
1. Same key, same body — a replay
HTTP 200 (not 202) with Idempotency-Replayed: true, and the body is the order as it stands now — current status, current esims[], current poll_after_ms. Nothing is re-executed.
2. Same key, different body — a conflict
409 idempotency.key_reused, param: "Idempotency-Key". Either use a new key, or resend the original body. Silently replaying the first order would hand you a profile for a plan you did not ask for the second time, which is worse than an error.
3. Same key, the other mode — also a conflict
409 idempotency.key_reused, with a message saying which mode used it. This is checked before the body comparison, so a sandbox key reused in live is a mode conflict rather than a confusing body mismatch. Namespace your keys per mode.
4. Same key, and the order was refused before it existed
The original refusal is raised again — its original status, its original code — with a sentence added saying the key is now bound to that refusal, and with balance_cents and required_cents refreshed to their current values. It will keep doing that forever.
The rule to design around
| Code | Binds the key? |
|---|---|
balance.insufficient | Yes, permanently |
partner.not_live | Yes, permanently |
partner.suspended | Yes, permanently |
partner.closed | Yes, permanently |
Everything else leaves the key usable, and the distinction is not arbitrary — it is whether the order row was written. These are all raised before it exists, so the key was never claimed:
404 plan.not_found,400 plan.inactive,422 plan.unavailable— a catalogue problem. Fix the plan reference and retry the same key.400 request.invalid— a malformed request. Fix it and retry the same key.
And anything that goes wrong after the 202 — a supplier failure, a partial delivery, an order abandoned as unopenable — replays as case 1. It is an ordinary read of an order you already know about, not an error bound to a key.
Choosing keys
- A key per order intent, not per HTTP attempt. Every retry of the same intent reuses the same key; that is the entire point.
- Derive it from something you own. Your booking id, or a UUID stored beside the order in your database before you send anything. A key generated at the moment of sending cannot be reused by a process that crashed mid-request.
- Namespace per mode.
live:BK-99182andtest:BK-99182, or a fresh UUID each time. One namespace across both is a 409 waiting for your first live deploy. - Mint a new key when the intent changes. Different quantity, different plan, or the same basket after a top-up — all new intents, all new keys.
When you do not know whether it arrived
- Resend it, same key. Not a fresh one. You will get either the 202 you missed or a 200 replay, and never a second order.
- Never retry a 202 you did receive. The order is durable on its own: a detached run, a drain on shutdown, and a reconciler that re-drives anything stranded. It will reach a terminal state without your help.
- A 500 is safe to retry with the same key. That is what the mechanism is for.
The lifecycle page covers what a terminal state means and when money comes back.