Idempotency
The header that stops a retry becoming a double debit.
Every call that creates a transaction takes an Idempotency-Key header: POST /api/v1/airtime, /api/v1/data, /api/v1/vend, /api/v1/bills/electricity and /api/v1/bills/cable. It protects you from the one failure that costs real money: a request that succeeds on our side and then dies on the wire before the response reaches you.
Your server sees a timeout and retries, which is correct. With the same key, that retry returns the first answer instead of placing a second order.
How it works
The first request with a key runs, and its response is stored against the key. A later request with the same key and the same request does not run again: it gets the stored response back, with the same status code and body and an Idempotent-Replayed: true header. The stored body is the answer as it was first given, so a replayed vend still says queued. Read the current state from the transaction or its webhook.
- Keys are scoped to your business and to the mode: a key sent with a test key and the same key sent with a live key are two different orders.
- A key is kept for 24 hours from its first request. After that it is forgotten and a request carrying it is treated as new.
- "The same request" means the same method, path and body, byte for byte. Build the body once and send the same bytes on every attempt; a copy re-serialised with its fields in another order is a different request.
- A key is at most 128 characters.
- A response of
500or above is not stored. The key is released, so a retry with it runs the request again. - A
400or402from the vend itself is stored and replayed like a success. Nothing was created, so after you fix the cause (top up the wallet, correct the field), send the corrected request with a new key. A401or429is answered before the key is read, so nothing is stored for it.
Choosing a key
Generate the key once, when the order is created, and store it with the order. Every attempt at that order sends that key. A new key means a new order.
// When the order is created, once:
order.idempotencyKey = order.id; // or crypto.randomUUID(), saved on the order
order.vendBody = JSON.stringify({
network: "mtn",
destination: order.phone,
amount_kobo: order.amountKobo,
client_reference: order.id,
});
// Every attempt at this order, however many times your queue runs it:
await fetch("https://api.useroutegate.com/api/v1/airtime", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ROUTEGATE_API_KEY}`,
"Idempotency-Key": order.idempotencyKey,
"Content-Type": "application/json",
},
body: order.vendBody,
});Two rules:
- Reuse the same key, with the same body, for every retry of the same order. A key generated inside the retry makes every attempt a new transaction.
- Never reuse a key for a different request. The same key with a different amount or destination is a bug, not a retry, and it is refused with
422.
Send client_reference too. It is unique per business and never expires: a second vend with a client_reference already used is refused with 409 CONFLICT, and the message names the transaction that has it.
What you get back
| Situation | Response |
|---|---|
| First request | 202 and a new transaction, status: "queued" |
| Same key, same request, first one finished | The first response again, with Idempotent-Replayed: true |
| Same key, same request, first one still running | 409 with IDEMPOTENCY_IN_PROGRESS; retry shortly, same key |
| Same key, different body | 422 with IDEMPOTENCY_FINGERPRINT_MISMATCH |
No Idempotency-Key header | 400 with IDEMPOTENCY_KEY_MISSING |
| Key longer than 128 characters | 400 with VALIDATION_FAILED on the Idempotency-Key field |
First request answered 500 or above | Nothing stored; the retry runs the request |
| Key first used more than 24 hours ago | Treated as a new key |
When to retry
Retry with the same key on a network error, a timeout, a 500 or above, 409 IDEMPOTENCY_IN_PROGRESS, and 429 after its Retry-After. Do not retry 400, 402 or 422 with the same key: the same answer comes back. See errors for every code.