AK
All writing
February 10, 2026·4 min readpaymentsapi-designdistributed-systems

Designing Idempotent Payment APIs

How to prevent duplicate charges in distributed systems — the contract, the storage, and the failure modes I learned at scale.

A payment system that double-charges a customer once will lose your trust forever. A system that does it at scale will end your startup. Idempotency is how you prevent that — and most engineers implement it wrong.

Here's the playbook I've refined across Bolt, Delivery Hero, and consulting work.

The problem

Payments happen over unreliable networks. A client sends POST /charge, the server writes a row in the ledger, calls the PSP, and crashes before the response reaches the client. The client retries. Now you have two charges.

The only safe way out: the server must recognise that the retried request is the same request as before, and return the original response without charging again.

The contract

Every write endpoint that moves money must accept an Idempotency-Key header.

POST /v1/charges
Idempotency-Key: 5d7a9c2e-...-b44c
Content-Type: application/json
 
{ "amount": 4200, "currency": "EUR", "source": "card_xyz" }

Rules:

  1. The client generates the key (UUIDv4 works).
  2. The key is valid for a bounded time window — 24h is typical.
  3. The same key with a different body is an error (422), not a silent success. Otherwise replay attacks become easy.
  4. Idempotency is scoped to (merchant_id, key). Never global.

The storage

You need two things: a fast lookup (Redis), and a durable record of truth (Postgres). I use both.

  • Redis: 24h TTL, stores key → {status, response_hash, response_body}. Fast check on the hot path.
  • Postgres: unique(idempotency_key) constraint on the charges table. The database is the source of truth. Redis is a cache.

The critical move: the ledger write and the idempotency record go in the same transaction. If the row can exist without its idempotency marker, you have a race.

BEGIN;
INSERT INTO charges (id, merchant_id, amount, currency, idempotency_key, status)
VALUES (...)
ON CONFLICT (merchant_id, idempotency_key) DO NOTHING
RETURNING id;
-- if no row returned, another request already won — read and return it
COMMIT;

The state machine

A charge has a small set of states: pending → authorized → captured → settled (plus failed and refunded). Every state transition is its own idempotent operation with its own key. Don't try to make /charge do everything.

Handling the PSP

The PSP call is the genuinely dangerous part, because now you're idempotent on your side but the PSP might not be.

Three defences:

  1. Pass a deterministic idempotency key to the PSP too. Stripe, Adyen, and most serious providers support this. Use the same key you received, prefixed.
  2. Write an outbox row before calling the PSP. If your process dies mid-call, the retry worker picks it up and re-attempts with the same key — the PSP deduplicates.
  3. Never store authorized until the PSP says so. If the network times out before the PSP responds, you don't know the outcome. Schedule a reconciliation job that queries the PSP by your idempotency key.

The failure modes nobody talks about

  • Two requests with the same key and different bodies. Return 422 Idempotency-Key-Mismatch. Not a crash. Not a duplicate charge.
  • Redis is down, Postgres is up. Fine — degrade to Postgres only. Slower, still correct.
  • Key expired but client retried. This is your fault for having too short a TTL. Default to 24h minimum; charge-level can be 7 days.
  • Client generates the same key across two logically different charges. Reject with 409. Force them to think.

What I'd tell a team starting today

  1. Make the Idempotency-Key header required, not optional. Optional idempotency is no idempotency.
  2. Use unique constraints at the database level. Application logic will eventually lie to you.
  3. Write an outbox + retry worker from day one. Not day thirty.
  4. Put the idempotency key in every log line. When you're debugging a disputed charge at 2am, you'll thank yourself.

Idempotency isn't a feature you bolt on. It's a contract that shapes your schema, your logs, your retries, and your PSP integration. Design it in.


Want me to audit your payment system architecture? Let's talk.

Let's build

Have a system that needs to scale — or stop breaking?

I work with a small number of teams each month on architecture reviews, scaling, and hands-on backend engineering. If that sounds like you, let's talk.