Guide ยท 8 min read

Idempotency is easy to describe and easy to get subtly wrong.

Everyone knows the retry story. Almost nobody writes down what a replayed write means, how long the key lives, or what happens when the same key turns up with a different amount. That last one is how people get paid twice.
the same key, twicereplayed

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

Five cases, and what each one has to do.

Write this table down before you write the code. Most idempotency bugs are not implementation errors, they are cases nobody decided on, resolved later by whoever was on call.
CaseCorrect behaviour
Same key, same body, first timeProcess it. Store the result against the key.
Same key, same body, replayReturn the stored result. Do not process again, and do not report an error.
Same key, different bodyRefuse loudly. This is a caller bug and swallowing it moves money twice.
Same key, still in flightRefuse with a retryable status. The caller tries again and gets the stored result.
New key, identical bodyProcess it. Two genuinely separate payments can look identical, and it is not the ledger job to guess.

How it goes wrong

Five failure modes that all look like working code.

Each of these ships, passes review, and runs for months before it costs anyone money.
The key that is generated per attempt
A fresh UUID on every retry is not an idempotency key, it is a request id. The key has to be derived from the thing being paid for, so the second attempt presents the same one.
The key with no lifetime
Kept forever, it is a table that grows without bound. Kept for an hour, a delayed retry from a stuck queue creates a second payment. Pick a window longer than your longest retry, and say what it is.
The same key, a different body
The dangerous one. A caller reuses a key with a changed amount. Returning the original result silently is wrong, and so is processing the new amount. It has to be a loud error.
The key scoped too widely
Keys unique per account, not globally, or two tenants collide and one gets the other result. Scope is part of the key.
The concurrent duplicate
Two identical requests in flight at once, before either has committed. A uniqueness check that reads then writes lets both through. It has to be one atomic write that the second one loses.

The key itself

Derive it, do not generate it.

The key has to be reproducible by the caller without any memory of the first attempt, because the caller that retries is often a different process from the one that failed.

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.

deriving a keyreproducible
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

The ones with non-obvious answers.

Is a unique constraint on the key enough?+

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.

How long should a key live?+

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.

What should a replay return, the original status or a fresh one?+

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.

Does this replace a distributed transaction?+

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.

Where does this stop mattering?+

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.

Try to double-post on purpose.

Test mode takes the same keys as live. Send a write twice, then send it again with a changed amount, and check that the second one is refused rather than absorbed.