Taking a payment exactly once, over a network that will not let you
The client retried because it saw a timeout. The first request had already succeeded. Everything in this design exists because of that sentence.
The brief
A checkout service charges a customer through a third-party payment provider and records the result.
The network between any two of these can time out at any point, including after the work was done.
Requirements
Functional
- Charge a customer for an order
- A retried charge must not take a second payment
- Every charge must be reconstructable afterwards from stored records
Non-functional
- Charging twice is far worse than failing to charge — the failure modes are not symmetric and the design should not treat them as if they were
- The provider is a system you do not control and cannot roll back
- 'Exactly once' does not exist across a network. Design for at-least-once delivery with at-most-once effect.
Back-of-envelope
Assume
- 500 orders per minute at peak
- A provider call takes 300 ms–2 s, and times out on roughly 1 in 1,000 calls
- Clients retry a timeout once, automatically
Therefore
- Timeouts: 500/min × 1/1,000 ≈ 0.5 per minute, about 720 a day.
- Every one of those is a request whose outcome you do not know. Some succeeded at the provider and told you nothing.
- With automatic retry and no idempotency key, each of those is a potential double charge — on the order of hundreds a day, on 500 orders a minute.
A timeout rate of 0.1% sounds like a rounding error until it is multiplied by traffic and by a retry. That multiplication is the argument for idempotency keys, and it is much more convincing than the word 'best practice'.
The interface
What is stored
idempotencyKey (UNIQUE) · orderId · requestHash · state (IN_FLIGHT | SUCCEEDED | FAILED | UNKNOWN) · providerRef · responseBody · createdAtThe unique constraint IS the concurrency control — two replicas racing on the same key resolve at the database, not in application logic that reads and then hopes. requestHash is what makes the 422 above possible. responseBody is stored because a replay has to return what the first call returned, and reconstructing it is not the same as replaying it.
append-only: providerRef · kind · payload · receivedAt'Every charge must be reconstructable afterwards' is a functional requirement, and an append-only log is the only shape that survives the case where your own state machine is the thing that was wrong.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- The provider call times out. Was the customer charged? How does your system find out?
- Two identical requests with the same key arrive at two replicas simultaneously. Which one calls the provider?
- A refund fails. What state is the order in, and what does the customer see?
- Your database commit succeeds and the provider call fails. Now the reverse. Which is worse, and does your design agree?