Idempotency
Every mutating request (POST) requires an Idempotency-Key header containing a UUID you generate:
Idempotency-Key: 3f9c1e2a-7b4d-4a1f-9c8e-2d6b5a0f3e11This is not optional and not advisory. Network timeouts happen; without an idempotency key, a retry after a timeout can charge a customer twice. With one, retrying is always safe.
How it behaves
| Situation | Result |
|---|---|
| First request with a key | Executes normally. |
| Same key, same body | Returns the original response, byte-for-byte, with the original status code and the header Idempotency-Replayed: true. No second transaction is created. |
| Same key, different body | 409 idempotency_key_reuse. Keys are not reusable for different requests. |
| Same key while the first is still running | 409 idempotency_in_progress. Retry shortly. |
| First request failed (5xx or crash) | The key is released, so retrying re-executes. You never get stuck replaying a failure. |
Two requests with the same key arriving simultaneously are safe: exactly one executes, and the other replays or gets idempotency_in_progress.
Choosing keys
- One key per logical operation, generated by you, ideally tied to something in your system: an order id, a payout batch line, a retry group.
uuidgen,crypto.randomUUID(), orGuid.NewGuid()all work. - Reuse the same key when retrying the same operation — that is the entire point.
- Never reuse a key across different operations. Two different charges need two different keys.
- Keys are scoped per merchant, so they cannot collide with another merchant's.
Retry pattern
const idempotencyKey = crypto.randomUUID(); // generated ONCE, outside the retry loop
async function createCollection() {
for (let attempt = 1; attempt <= 3; attempt++) {
const response = await fetch('https://api-dev.moni-wave.com/v1/collections', {
method: 'POST',
headers: {
'Authorization': `Bearer ${SECRET_KEY}`,
'Idempotency-Key': idempotencyKey, // same key on every attempt
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 5000, currency: 'XAF', msisdn: '237690000001', description: 'Order #1042' }),
});
if (response.status === 409) {
const { error } = await response.json();
if (error.code === 'idempotency_in_progress') {
await sleep(1000 * attempt);
continue; // the original is still running
}
throw new Error(error.code); // idempotency_key_reuse: a bug in your code
}
return response.json(); // 201 fresh, or replayed original
}
}The key is generated outside the loop. Generating it inside would create a new transaction per attempt — exactly the bug idempotency exists to prevent.
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | idempotency_key_required | Header missing on a mutating request. |
| 400 | idempotency_key_invalid | Header present but not a UUID. |
| 409 | idempotency_key_reuse | Key already used with a different request body. |
| 409 | idempotency_in_progress | The original request with this key is still executing. |
Relationship to webhooks
Idempotency protects requests you send us. Webhooks are the mirror image: they are delivered at-least-once, so your endpoint must tolerate duplicates. Each webhook carries a stable event id for exactly that purpose — see webhooks.md.