Bitcall logoBitcall API
The Gateway

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.

ProductOperations
OTPrequest a number · resend · reactivate · open a refund request
eSIMpurchase · top-up · schedule a future order · request a refund
HLRsubmit a lookup

What happens when you reuse a key

You sendYou get
Same key, same request, first attempt finishedThe original response, status and body identical, for 24 hours
Same key, same request, first attempt still running409 CONFLICT — the original is in flight; poll rather than retry
Same key, different request409 IDEMPOTENCY_CONFLICT — use a new key
A new keyA 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);
}

On this page