Why “check before charging” does not work
This is one of three pieces that cannot be retrofitted into a fintech product (the other two are an immutable ledger and reconciliation; the wider picture is in the fintech development guide). Let us go through it properly. The client sends a charge request. The server accepts it, posts the operation — and at that moment the connection drops. The client never gets a response. It does not know whether the charge happened, and by every rule it is obliged to retry: POST is not idempotent without an additional agreement (RFC 9110).
The first idea that comes to mind is to check before charging whether a similar operation already exists: same amount, same recipient, last five minutes. It does not work, for two reasons. First, a person has every right to send a friend the same amount twice. Second, time passes between the check and the insert, and two parallel requests will pass the check both.
The idempotency key: who creates it and what it points at
The key is generated by the client — the app or the front end — at the moment the user forms an intent. Not by the server: the server cannot tell a retry from a new intent. And not at the moment the request is sent, but when the form is opened: otherwise a second tap creates a new key and the whole construction falls apart.
- One key, one user intent. Open the transfer form and you get a key; tap “send” three times and the key stays the same.
- The key must be random (a UUID), not derived from amount and timestamp: two identical transfers in a row are a legitimate scenario.
- Keys expire. Twenty-four hours is a sensible window: it covers any retry and stops the table becoming permanent storage.
What this looks like in the database
The minimal schema is a single table. Three things in it matter: a primary key on the idempotency key, a fingerprint of the request, and the stored response.
create table payment_request (
idempotency_key uuid primary key,
account_id bigint not null,
request_hash text not null,
status text not null
check (status in ('in_progress', 'succeeded', 'failed')),
response jsonb,
operation_id bigint references operation(id),
created_at timestamptz not null default now()
);
-- The ledger: append only. A cancellation is a new record with the opposite
-- sign, never an update and certainly never a delete.
create table operation (
id bigserial primary key,
account_id bigint not null,
amount numeric(20, 4) not null,
kind text not null,
reverses bigint references operation(id),
created_at timestamptz not null default now()
);The balance here is not a column but sum(amount) over the account. The moment a balance becomes a stored value that somebody updates, a second source of truth appears, along with the question “why do the operations not add up to the balance”, which has no good answer.
The handler: order of operations matters
Race protection is an insert against a unique key, not a select followed by an insert. Checking first does not save you: two parallel requests both pass the check, because time passes in between. The database can solve this for us — a unique index is atomic.
async function charge(req: ChargeRequest) {
const hash = sha256(canonicalJson(req.body));
// Try to claim the key. If it is already taken, this is a retry.
const claimed = await db.insertIgnoreConflict('payment_request', {
idempotency_key: req.key,
account_id: req.accountId,
request_hash: hash,
status: 'in_progress',
});
if (!claimed) {
const prev = await db.get('payment_request', req.key);
// Same key, different body — the client is wrong or swapping the amount.
if (prev.request_hash !== hash) throw new Conflict('key_reused');
// The first request is still running: ask for a retry, do not post twice.
if (prev.status === 'in_progress') throw new Retry('in_progress');
// A retry of a finished request returns THE SAME response, not a new one.
return prev.response;
}
const result = await db.transaction(async (tx) => {
const op = await tx.insert('operation', {
account_id: req.accountId,
amount: -req.body.amount,
kind: 'charge',
});
// The provider call does NOT belong here: a network call inside a
// transaction is how you end up with a rolled-back database and money
// that already left. Queue the job in the same transaction instead.
await tx.insert('outbox', { type: 'provider.charge', operation_id: op.id });
return { operationId: op.id };
});
await db.update('payment_request', req.key, {
status: 'succeeded',
response: result,
operation_id: result.operationId,
});
return result;
}onConflict: the race is resolved by the database, not by our code.Reconciliation: what finds the errors you did not anticipate
Idempotency protects against duplicates but not against divergence: the provider may have declined an operation after answering “accepted”, a fee may arrive as a separate line, a refund may be issued outside your system. The only way to learn about this is to compare your ledger against the provider’s statement every day.
- 01
Fetch the statement for the day
Exactly as the provider gives it, and store it raw — before any processing. You will need the original when someone questions the result.
- 02
Match on the operation identifier
Not on amount and time: matching amounts are not matching operations. The provider’s identifier must be stored next to your operation from day one.
- 03
Sort the divergences by type
Present here, missing there. Present there, missing here. Present in both but with different amounts. The third type is the nastiest and the most informative.
- 04
Open a task, not an email
A divergence with no owner and no deadline turns into a daily notification that people stop reading within a week.
What idempotency does not give you
It does not turn at-least-once delivery into exactly-once — that is impossible in a distributed system. It makes reprocessing safe, which is a different promise: the system will still receive the request twice, it simply will not do anything the second time. Everything you build around money should assume that any message will arrive more than once.
Frequently asked questions
Is a unique index enough without a separate request table?
A unique index will prevent a second operation, but the client receives an error instead of the first result — and cannot tell “already done” from “did not go through”. The point of the request table is not only protection but returning the same response to the retry that the first request received.
How long should idempotency keys be kept?
Twenty-four hours covers essentially any retry: no client retries for longer. Records older than that are deleted on a schedule, otherwise the table grows forever. The operation itself is retained for as long as bookkeeping requires — different tables, different retention.
What should be returned while the first request is still running?
A 409 with a clear body and an instruction to retry later. Returning 200 is not an option: the client will decide the operation succeeded and show the user a success that has not happened yet. Waiting for completion inside the handler is also a bad idea — that is how you get a queue of blocked connections.
Is this only needed in payments?
No. Any action with an external effect — sending an email, granting loyalty points, creating an order, publishing a document — benefits from the same technique. In payments the cost of getting it wrong is simply highest, which is why it is thought about first there.
Sources
Every claim here can be checked: below are the primary sources, not a retelling.
- 01RFC 9110 — HTTP Semantics, idempotent methods — why POST requires a separate agreement about retries
- 02Stripe API — Idempotent requests — key lifetime, and behaviour when the key matches but the body differs
- 03Transactional outbox pattern — how not to call a provider from inside a transaction
- 04PostgreSQL — Transaction Isolation — why “check then insert” is not an atomic operation
These articles are written by engineers working on the projects — but they are not signed by name. The reason is the same one that keeps client logos off this site: nearly every project runs under an NDA or white-label, and a byline on a piece about a payment core points at the client as clearly as a logo would. Instead of names, we stand behind the text with rules — except for articles about our own open-source code, which carry the author’s name.
How we write and what we verify