Skip to content
Ledger by RODMENA
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

Every write to Ledger carries an idempotency key, and you choose the string. Derive it from your own record of the movement, such as the order number, the payout batch and line, or the statement line you are reconciling. A key made at the moment of sending is different on every retry, and that 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 almost always a mistake.

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. The naive design records “this key was used”, then moves the money, then stores 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 at all. The retry then 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 one of two states. Either the receipt is there and the retry replays it, or nothing is there and the retry does the work. There is no third state in which money moved and 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, such as a lock it could not acquire in time because another transaction held it. If that refusal were recorded against the key, every retry would replay it forever. The client’s only escape would be to mint a new key, and that is the double-spend path the key exists to prevent.

So Ledger tells the two apart. A temporary failure leaves the key usable, and the retry performs the work. A real business refusal, such as insufficient funds or an unbalanced request, is recorded and replayed, because retrying it unchanged would produce the same answer and you 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. A retry is then identical to the original.
  2. On any ambiguous failure, whether a timeout, a dropped connection or 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 for you to decide what to do next:

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, but in guaranteeing that the service keeps one of those two states and never a third.

Three key designs 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, which are the accounts, the amount, the purpose and your own timestamp of the decision. Two identical intents map to one key, and two different intents do not collide.

The pattern to avoid is generating the key when the request is sent. It is the most common mistake, and the protocol cannot protect you from it, 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.