Blog Details
Home/Blog Details

Idempotency is a product decision, not a webhook trick
Written by Abdulbasit Abdulalim| 4 min Read
The report that arrives twice
A payment provider tells you a payment succeeded. Sometimes they tell you twice, because their webhook delivery retried, because your server was restarting while they were delivering, or because a network blip made them unsure whether the first attempt landed. A naive implementation creates two orders. The customer is charged once and receives two shipments, and now there is a conversation with a real person about money.
The fix for that is usually described as a technical pattern: an idempotency key, a unique constraint, a deduplication table. It is worth saying plainly that the pattern is the easy half. The hard half is the decision about what the customer sees while the system is unsure.
The lifecycle, driven from the server
The rule that settles most of these questions: the order's state is driven by the payment provider's verified event, not by what the browser reports back.
A checkout that returns to your site with a success message is a hint. It is the browser saying "I think it worked". The authoritative event is the provider's webhook, signed and verified, carrying the provider's own transaction reference.
So the order lifecycle looks like this:
pending- the checkout is started, an order reference exists, no money has moved.paid- a verified payment event arrived. This transition is the one that matters, and it happens exactly once.confirmed- the order is ready to be fulfilled, after the stock and the fulfilment checks pass.cancelled- the checkout was abandoned, or the payment window expired without a payment event.
The browser's return can move an order from pending to a display state that says "we are confirming your payment". It cannot move it to paid. If the webhook has not arrived, the customer is told the truth: the payment is being confirmed, and they will get an email either way.
Making the transition exactly-once
Two things, and both of them are needed.
A unique constraint on the event. The provider's transaction reference is unique. Storing it with a unique index means a duplicate webhook cannot insert a second row - the insert fails, and the handler treats that as "already handled" and returns success. This is the part that actually makes it exactly-once, because it does not depend on the application code checking first and then racing.
An idempotency key on the way out. When the client creates an order, it sends a key it generated. The server stores it with a unique index, so a double-tapped button or a retried request returns the same order instead of creating a second one. This is the half that protects the customer, because it happens before any money is involved.
Note the ordering: the key protects the click, the constraint protects the webhook. Both are needed, and they solve different races.
The part that is a product decision
Here is where the engineering stops and the product starts. Between pending and paid there is a window in which the system does not know whether the money moved. What does the customer see?
Three options, and they are not equally good:
- Fail closed. Say the payment failed and ask the customer to try again. Simple, and it risks double-charging a customer who was actually charged - the exact failure this whole design exists to prevent.
- Fail open. Mark the order paid on the browser's word. The customer is happy immediately, and the business quietly ships goods for orders that were never paid.
- Show the truth. The order is in a pending state, the page says the payment is being confirmed, and a message tells them what will happen next - an email if it succeeds, a release if it does not, and a support line if something is wrong.
Option three is more work, and it is the only honest one. It means the interface has a state that most checkouts never build, because most checkouts never deal with the uncertainty.
The same choice appears in a smaller place: the "payment failed" screen. It should say what failed, and it should offer a retry that reuses the same idempotency key, so the retry cannot become a second order.
Verification before you trust
A webhook endpoint that accepts anything anyone posts is a hole in the side of the shop. The handler needs to check the signature against the provider's secret, and reject anything that does not verify - before looking at any of the payload.
The order of operations matters: verify the signature, check the event is one you handle, check you have not handled it, then apply it. Applying an event you have not verified is the bug that turns a webhook endpoint into a way to mark orders paid for free.
The rule I would keep
The customer's browser is allowed to start a payment. It is not allowed to complete one.
Everything else in this design - the state machine, the unique constraints, the idempotency key, the "we are confirming" screen - follows from taking that sentence seriously. The pattern is the easy part; the decision about what to tell a person who has just handed over money is the part that takes the actual thought.
The storefront in nelove is built this way, and the same lifecycle appears in Atelier, where the checkout is treated as the product rather than as a form in front of one.


