Skip to content
CodeNest

Idempotency in payment systems: the one property you cannot skip

Why every money-moving API needs idempotency keys, how to implement them correctly in PostgreSQL, and the edge cases that bite in production.

CodeNestPublished 3 min read

Networks fail in the most inconvenient place: after your server has charged the card, but before the client received the response. The client retries — as it should — and without protection, the customer pays twice.

Idempotency means that performing the same operation more than once has the same effect as performing it once. In payment systems it is not a nice-to-have. It is the difference between a support ticket and a regulatory incident.

The idempotency key pattern

The standard approach, popularised by Stripe’s API, is simple:

  1. The client generates a unique key (a UUID) for each logical operation and sends it in a header, e.g. Idempotency-Key.
  2. The server records the key together with the request fingerprint and, once processing completes, the response.
  3. Any retry with the same key returns the stored response instead of executing again.

The devil is in the concurrency.

Doing it correctly in PostgreSQL

A naive “check, then insert” has a race: two identical requests arriving milliseconds apart both see no record and both proceed. Let the database enforce uniqueness instead:

CREATE TABLE idempotency_keys (
  key            text        NOT NULL,
  account_id     uuid        NOT NULL,
  request_hash   text        NOT NULL,
  status         text        NOT NULL DEFAULT 'in_progress',
  response_code  int,
  response_body  jsonb,
  created_at     timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);

The handler then follows a strict sequence:

const inserted = await db.query(
  `INSERT INTO idempotency_keys (key, account_id, request_hash)
   VALUES ($1, $2, $3)
   ON CONFLICT DO NOTHING
   RETURNING key`,
  [key, accountId, hash],
);

if (inserted.rowCount === 0) {
  const existing = await loadKey(accountId, key);
  if (existing.request_hash !== hash) return conflict('Key reused with a different payload');
  if (existing.status === 'in_progress') return retryLater();
  return replay(existing); // same status code and body as the first time
}

// First time we've seen this key: do the work, then persist the response.

Three details matter here:

  • Scope keys per account. A global key space lets one tenant’s bug collide with another’s request.
  • Fingerprint the payload. Reusing a key with a different amount is a client bug; reject it loudly rather than replaying an unrelated response.
  • Handle “in progress” explicitly. A retry that arrives while the first attempt is still running should get a 409 or 425 and back off — not start a second execution.

Idempotency all the way down

An idempotent API is only as safe as the calls it makes. If your service calls a payment provider, pass a deterministic idempotency key downstream too (derived from your own key), so a retry of your operation does not create a second charge at the PSP.

The same applies to asynchronous work. Kafka consumers and queue workers will see duplicates — at-least-once delivery is the norm. Make handlers idempotent by recording processed message IDs in the same transaction as the side effect, or by designing writes as upserts keyed on a natural identifier.

Ledgers make it auditable

Idempotency prevents duplicates; a double-entry ledger proves you prevented them. When every movement of money is a balanced pair of entries tied to the originating idempotency key, reconciliation becomes a query rather than a spreadsheet exercise — and your auditors can follow every cent.

A checklist

  • Every POST that moves money or creates a financial record accepts an idempotency key.
  • Uniqueness is enforced by the database, not by application logic.
  • Keys are scoped per tenant and expire on a documented schedule (24 hours to 7 days is common).
  • Payload fingerprints are compared on replay.
  • Downstream provider calls carry their own deterministic keys.
  • Consumers of queues and event streams are idempotent.

None of this is exotic, but skipping any one of these steps is how double charges reach production. If you are building or auditing a payment flow and want a second pair of eyes, talk to us.

~/next-step

Have something that needs to ship — and keep running?

Tell us what you are building. Within one business day you get a reply from a senior engineer, not a sales rep — with honest questions, a rough approach, and whether we are the right fit.

// NDA available before the first call.