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:
- The client generates a unique key (a UUID) for each logical operation and sends it in a header, e.g.
Idempotency-Key. - The server records the key together with the request fingerprint and, once processing completes, the response.
- 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
409or425and 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
POSTthat 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.