Idempotency
How to retry an operation that spends money without spending it twice.
A request that times out leaves you with a genuine problem: you do not know whether it happened. Retrying might double-charge you; not retrying might leave a customer without what they paid for.
An idempotency key settles it. Retry with the same key and you either get the original result back or the operation runs exactly once — never twice.
Sending a key
One header, any string up to 200 characters. A UUID is the usual choice:
curl -X POST 'https://api.khalil.chatup.ch/v1/esim/purchases' \
--header 'x-key-id: ak_live_1a2b…' \
--header 'x-api-secret: 9f83…' \
--header 'Idempotency-Key: 3f2a9c1e-7b44-4e0a-9d33-8e21c5b4f907' \
--header 'Content-Type: application/json' \
--data '{"planId":"69f1fd87f32cd1cae4cb6099"}'Generate the key before the first attempt and reuse it for every retry of that same operation. A fresh key on a retry is a new operation, which is exactly what you are trying to avoid.
Which operations require one
These nine refuse the request with 400 IDEMPOTENCY_KEY_REQUIRED if the header is missing —
before anything is charged or any provider is contacted.
| Product | Operations |
|---|---|
| OTP | request a number · resend · reactivate · open a refund request |
| eSIM | purchase · top-up · schedule a future order · request a refund |
| HLR | submit a lookup |
What happens when you reuse a key
| You send | You get |
|---|---|
| Same key, same request, first attempt finished | The original response, status and body identical, for 24 hours |
| Same key, same request, first attempt still running | 409 CONFLICT — the original is in flight; poll rather than retry |
| Same key, different request | 409 IDEMPOTENCY_CONFLICT — use a new key |
| A new key | A new operation |
Keys are scoped to your account, so yours can never collide with anyone else's.
Field order in your JSON body does not matter — {"a":1,"b":2} and {"b":2,"a":1} are the
same request.
Failures release the key
If an attempt fails in a way that leaves the outcome unknown — a 503, or a dependency we
could not reach — the key is released so you can retry it. Otherwise the key would be
poisoned: you would replay the same failure forever and never be able to complete an
operation you were never charged for.
A 4xx is different. That is a decision, not an unknown, so it is stored and a retry with
the same key replays it.
Cancellations need no key
Cancelling is already safe to repeat: cancel an order twice and the second call returns the existing cancelled state, without touching a provider or your balance again.
That applies to every cancellation, plus HLR export creation and the eSIM confirmation resend. Requiring a key to undo something would be hostile.
A retry loop worth copying
const idempotencyKey = crypto.randomUUID(); // once, outside the loop
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch('https://api.khalil.chatup.ch/v1/esim/purchases', {
method: 'POST',
headers: {
'x-key-id': process.env.KEY_ID,
'x-api-secret': process.env.SECRET,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ planId }),
});
if (res.ok) return res.json();
const { error } = await res.json();
// Nothing about a retry will change these.
if (['VALIDATION_ERROR', 'INSUFFICIENT_BALANCE', 'IDEMPOTENCY_CONFLICT',
'AUTHENTICATION_REQUIRED', 'INVALID_API_KEY'].includes(error.code)) {
throw new Error(`${error.code} (request ${error.requestId})`);
}
// CONFLICT here means the original attempt is still running — so does waiting.
await sleep(res.headers.get('retry-after') * 1000 || 2 ** attempt * 1000);
}