dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

A retried request must not charge the customer twice

In short: the network does not tell you whether a payment went through — it simply goes quiet. The customer taps the button again, the mobile app retries on its own, and without protection you charge twice. Here is how idempotency works at the level of tables and code, and where it usually gets broken.

7 min read

In short

  • The idempotency key is generated by the client, and it belongs to an intent — not to a user or a session.
  • Race protection is a unique index in the database, not a “does one already exist” check before the insert.
  • The same key with a different body is an error, not a retry: otherwise the amount can be swapped.
  • The provider call moves into an outbox, or the transaction rolls back after the money has already left.

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.

ClientRetryPayment intakeIdempotency keyOperation logBank / acquirerSame response · Charged once
The retried request arrives with the same idempotency key, lands on the same ledger record and receives the same response. One charge reaches the provider.

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.

sql
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 request fingerprint is what separates an honest retry from a substitution: the same key with a different amount is a client error, not a retry.

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.

typescript
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;
}
The critical line is the one with 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.

How a nightly reconciliation works
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  1. 01RFC 9110 — HTTP Semantics, idempotent methodswhy POST requires a separate agreement about retries
  2. 02Stripe API — Idempotent requestskey lifetime, and behaviour when the key matches but the body differs
  3. 03Transactional outbox patternhow not to call a provider from inside a transaction
  4. 04PostgreSQL — Transaction Isolationwhy “check then insert” is not an atomic operation
dbit.one engineering desk
The engineers who build these systems

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

Related services

Read next

Questions about your project?

Describe your task — within 24 hours we’ll come back with an estimate, timeline and plan.

[email protected]