Skip to content
Ledger by RODMEN/A
Menu

Insights · 2026-09-05

Safe retries: how an idempotency key stops a payment happening twice

Send the same request twice and you should get the same answer once. Getting that right under a crash is harder than it looks.

The most expensive bug in payments is not a wrong calculation. It is a correct calculation performed twice. A client sends a transfer, the connection drops before the reply arrives, the client cannot tell whether the transfer happened, and it sends it again. Now the customer has been paid twice, or charged twice, and somebody has to find out and reverse it.

The key is yours, not ours

Ledger requires every write to carry an idempotency key: a string the client chooses. The right choice is a value derived from your own record of the movement — the order number, the payout batch and line, the statement line you are reconciling — rather than something generated at the moment of sending. A key made at send time is different on every retry, which defeats the purpose.

When the ledger receives a write with a key it has seen before, and the body matches, it returns the original response and does nothing. The reply is marked so you can tell it was a replay. If the body does not match, the request is refused: a key is bound to the first request it was used for, and reusing it for something else is treated as the mistake it almost always is.

The hard part is the crash in the middle

Replaying a stored response is easy. The difficult case is when the service itself fails halfway. Consider the naive design: first record “this key was used”, then move the money, then store the response. A crash after the first step leaves a key marked as used with no money moved; the retry is rejected and the customer is never paid. Reverse the order and a crash after the money moves leaves no record; the retry moves it again.

Ledger’s answer is that the money movement and its receipt are written in the same database transaction. Either both exist or neither does. A retry after a crash therefore finds exactly one of two states: the receipt, which it replays, or nothing, in which case it does the work. There is no third state in which money moved but the client was never told.

Not every failure should count

There is a subtler trap. Suppose the ledger refused a request for a temporary reason — a lock could not be acquired in time because another transaction held it. If that refusal were recorded against the key, every retry would replay the refusal forever, and the client could only escape by minting a new key, which is precisely the double-spend path the key exists to prevent.

So Ledger distinguishes. A temporary failure leaves the key usable, and the retry performs the work. A real business refusal — insufficient funds, an unbalanced request — is recorded and replayed, because retrying it unchanged would produce the same answer and the client should learn that quickly. The response tells you which kind you received and whether to retry.

What this means for your integration

Three rules cover almost everything:

  1. Derive the key from your own document, so a retry is identical to the original.
  2. On any ambiguous failure — a timeout, a dropped connection, an error you cannot classify — retry with the same key. Never mint a fresh one to “get past” an error.
  3. Treat a refusal that carries a retry-after time as temporary; treat every other refusal as a fact about your request.

What the response tells you

Every reply from a write carries enough to decide what to do next, without guessing:

You receiveIt meansDo
A success with a replay markerThe original request had already completed; this is its answerNothing. Do not send again.
A refusal with a retry-after timeA temporary condition inside the serviceWait the stated time and send the same request with the same key
A refusal naming a business ruleThe request was wrong as writtenFix the request or surface the refusal; retrying unchanged will answer identically
A refusal because the key was used with a different bodyTwo different requests shared a keyInvestigate: the key derivation is not unique
A server errorThe service failed before decidingRetry with the same key; the key was not consumed

The table is short because the protocol is short. The difficulty was never in the client’s logic; it was in guaranteeing that the service keeps exactly one of two states.

Designing keys: three patterns that work

The document’s own identifier. A payout line, an invoice number, an order reference. If your system already has a unique name for the movement, use it. Two different movements can never share one, and a retry of the same movement always carries the same key.

A composite of source and line. For a batch, the batch identifier plus the line number: payroll-2026-09:0417. Stable across retries of the whole batch, unique within it.

A hash of the intent. When no natural identifier exists, hash the fields that define the movement — accounts, amount, purpose, your own timestamp of the decision — and use the hash. Two identical intents map to one key; two different intents never collide.

The pattern to avoid is generating the key when the request is sent. It is the most common mistake and the one the protocol cannot protect you from, because every retry then looks like a new movement.

The getting-started guide shows the protocol with real responses, and the guarantees page names the checks that keep it true under a crash.

Free today. Access is provisioned personally by RODMENA.

Tell us what you keep books of and we will set up a tenant, mint your first key and walk you through the tour.