Guide ยท 8 min read
first attempt, timed out client side
POST /v1/transactions
Idempotency-Key: payout:8821
201 Created
{ "id": "txn_41", "status": "posted" }the retry
POST /v1/transactions
Idempotency-Key: payout:8821
200 OK
{ "id": "txn_41", "status": "posted" }Same id, no second movement. The caller never learned whether the first one landed, and it did not need to.
Define this once
| Case | Correct behaviour |
|---|---|
| Same key, same body, first time | Process it. Store the result against the key. |
| Same key, same body, replay | Return the stored result. Do not process again, and do not report an error. |
| Same key, different body | Refuse loudly. This is a caller bug and swallowing it moves money twice. |
| Same key, still in flight | Refuse with a retryable status. The caller tries again and gets the stored result. |
| New key, identical body | Process it. Two genuinely separate payments can look identical, and it is not the ledger job to guess. |
How it goes wrong
The key itself
Idempotency is the load-bearing part of moving off a stored balance too, because a backfill has to be safe to re-run: migrating to a double-entry ledger. Kordio takes the key in a header or in the body, so scripting against it is not annoying, and every write inthe ledger requires one.
const key = `payout:${payout.id}:attempt`;
await kordio.transactions.create({
idempotencyKey: key,
postings: [
{ accountId: 'acc_merchant_payable', amount: -amountCents, currency: 'EUR' },
{ accountId: 'acc_bank_clearing', amount: amountCents, currency: 'EUR' },
],
});the same key with a changed amount
409 Conflict
{
"error": {
"code": "idempotency_key_reuse",
"hint": "payout:8821:attempt was used with a different body.
Use a new key, or replay the original request."
}
}Refusing here is the whole value. A system that quietly returns the first result for a changed request is worse than one with no idempotency at all, because it looks correct.
Questions
It is most of the way there, and it is the right primitive because it is atomic. What it does not give you on its own is the stored response for a replay, or the different-body check. Those are the parts teams skip.
Longer than the longest retry any of your callers can perform, including a queue that was paused over a weekend. Twenty-four hours is a common answer and it is too short more often than people expect.
The original result, unchanged, including the original identifiers. A caller replaying a request is trying to find out what happened the first time, not to start something new.
No, and it is not trying to. Idempotency makes a retry safe. It does not make two systems agree. If you need both to commit or neither, you still need that design; idempotency is what makes each attempt at it survivable.
Reads, and writes you can safely repeat. If replaying the operation cannot cost anyone money or create a duplicate obligation, none of this is worth the table.