Trust the Webhook, Not the Page: Why Payments Go Missing Between Three Systems That All Report Success
The site, the payment processor and your records each work correctly on their own, and the money that goes missing is lost in the gaps between them. Confirming on the landing page loses every payment where the browser never came back. The processor retries for up to three days, may deliver the same event twice, and guarantees no ordering, so the handler has to be idempotent and the state has to be derived from a ledger rather than incremented on arrival.
Trust the Webhook, Not the Page: Why Payments Go Missing Between Three Systems That All Report Success
The short answer. Three systems are involved in taking a payment: your site, the processor, and your own records. Each one works correctly in isolation, and every payment that goes missing is lost in the gap between two of them, which is nobody's component and therefore nobody's job. Confirming the payment on the thank-you page loses every case where the browser closed, the connection dropped or the redirect failed, and the processor's own guidance says not to do it. Confirm on the webhook, make the handler idempotent because delivery is at-least-once over a retry window measured in days, derive state from an append-only ledger rather than incrementing on arrival because ordering is not guaranteed, and reconcile against the processor because it is the only system that knows whether money actually moved.
The most expensive bugs in payment integrations are not in anybody's code. They are in the space between three systems that each believe they did their job correctly, and they stay invisible because every individual component reports success.
The gap nobody owns
A payment involves your site, a processor, and your records. Your site collected the intent. The processor moved the money. Your records are supposed to reflect that.
Each of those is owned by someone. The relationships between them are owned by nobody, and that is where the money goes.
The clearest case: a customer pays, the payment succeeds at the processor, and the browser never comes back. They closed the tab, the train went into a tunnel, the redirect failed, the phone died. The processor has a successful charge. Your records have nothing.
Nothing in that sequence errored. The site did its job. The processor did its job and will tell you so if you ask. Your records are consistent with everything they were told. The customer has been charged and, as far as your system is concerned, does not exist.
- The customer paysyour site collected the intent and did its job
- The charge succeeds at the processorthe processor moved the money and will tell you so if you ask
- The browser never comes backthe tab closed, the train went into a tunnel, the redirect failed, the phone died
- Your records have nothingand they are internally consistent with everything they were told
- The customer has been charged and does not existno error anywhere, which is why nobody observes the cases the thank you page misses
Why the thank-you page cannot be the record
Confirming on the landing page is the default because it is immediate, it needs no infrastructure, and it is correct most of the time. Most of the time is the problem: the cases it misses are exactly the ones nobody observes.
The structural issue is that the browser is not a reliable participant in the transaction. It is a client you do not control, on a network you do not control, on a device that can be closed at any moment. Making it responsible for recording a financial event means your records depend on a user's connection quality.
Stripe's own fulfillment guidance says explicitly not to rely on the landing page to confirm a payment. This is not a subtle architectural preference, it is documented advice from the processor, and it is routinely ignored because the happy path works in testing.
What the processor actually promises
The webhook is the right mechanism, but it is worth being precise about what it guarantees, because the guarantees are weaker than people assume and the design follows directly from them.
Delivery is retried for up to three days. In live mode the processor keeps trying for days if your endpoint is unavailable. So an event can arrive long after the payment, into a system whose state has moved on.
The same event may be delivered more than once. Delivery is at-least-once, not exactly-once. Your handler will see duplicates.
Ordering is not guaranteed. Events can arrive out of order. The event describing a later state can land before the one describing an earlier state.
One payment intent yields at most one successful charge. This is the one strong guarantee, and it is what makes the whole thing tractable: the payment intent is a stable identity for a single payment.
Three weak guarantees and one strong one. Everything below follows from that list.
Idempotent on the event identifier
Because delivery is at-least-once, the handler must be safe to run repeatedly on the same event. Key on the processor's event identifier, record which ones you have processed, and make a repeat a no-op.
This is not defensive coding against an unlikely case. Duplicates are a documented property of the transport. A handler that is not idempotent is incorrect, not merely fragile, and it will double-count on an ordinary day rather than an unlucky one.
The failure is quiet in the worst way. A duplicated donation or a duplicated charge record does not error. It produces a number that is wrong by one, in a ledger nobody reconciles, until someone notices the totals do not match.
Derive state, do not increment it
This is the part that people get wrong even after they handle duplicates, and it matters most for recurring payments where the event volume is higher.
The intuitive handler takes an event and updates a balance or a status. That is only correct if events arrive exactly once and in order, and they do neither.
Instead: append every accepted event to a ledger, keyed and deduplicated on the event identifier, and derive the current state from the ledger. A late event arriving out of order is incorporated into the history correctly rather than overwriting something newer. The subscription's state becomes reconstructable rather than a value whose provenance nobody can explain.
The ledger is append-only. It is never edited, which is what makes it usable as evidence rather than as a cache of the current state.
This gives you exactly-once in effect over an at-least-once, unordered stream, which is the strongest thing available and is achieved by changing where the state lives rather than by trying to make the transport behave.
Reconcile, because the ledger only knows what it received
The ledger is correct about every event it received. It knows nothing about events it did not.
So a standing reconciliation pass compares your records against the processor, which is the only system that actually knows whether money moved. Discrepancies become visible findings rather than silence.
This changes the class of problem. Without reconciliation, a missing payment is invisible, and invisible problems are not fixed because nobody knows to look. With it, a missing payment is a row in a report. It is still a problem, but it is a problem you can act on, and that distinction is most of the value.
Reconciliation also sets the authority question. The processor is the source of truth. Your records are a projection that should agree with it, and when they disagree, the processor is right.
What this does not fix
Reconciliation makes a missing payment visible and recoverable. It cannot create a receipt for someone who never completed payment, and it does not make your records authoritative.
There is also one case the event-identifier approach does not catch on its own: an event replayed with a new identifier. Deduplication keys on the identifier, so a genuinely new identifier is a genuinely new event as far as the handler is concerned. That case is caught by reconciliation rather than by the handler, which is slower and visible, rather than fast and wrong.
Say that explicitly. "Exactly-once in effect, dependent on the processor's event identifier being a sound deduplication key, with reconciliation as the backstop" is an honest description. "Exactly-once" on its own is not.
The shape, in four moves
Confirm on the webhook, not the page, because the browser is not a participant you can rely on.
Make the handler idempotent on the event identifier, because delivery is at-least-once over a window measured in days.
Append to a ledger and derive state from it, because ordering is not guaranteed and an incremented counter cannot survive a late event.
Reconcile against the processor, because it is the only system that knows whether money moved, and because a problem you can see is a different problem from one you cannot.
None of this is specific to payments. It is the standard handling for any at-least-once, unordered event stream. Payments are just where the cost of getting it wrong is denominated in money and trust rather than in a metric.
FAQ
Why shouldn't I confirm a payment on the thank-you page? Because the browser is not a reliable participant. If the customer closes the tab, loses connection or the redirect fails, the payment still succeeded at the processor and your records have nothing. Stripe's own fulfillment guidance says explicitly not to rely on the landing page. The happy path works in testing, which is why the pattern survives.
Why does a webhook handler need to be idempotent? Because delivery is at-least-once by design: the processor may deliver the same event more than once, and retries continue for up to three days in live mode. A handler that is not idempotent will double-count on an ordinary day. Key on the processor's event identifier and make a repeat delivery a no-op.
Why derive state from a ledger instead of updating a balance when each event arrives? Because ordering is not guaranteed. Updating on arrival is only correct if events come exactly once and in order, and they do neither, so a late event can apply a stale change over a newer one. Appending every event to a ledger and deriving state from it means an out-of-order arrival is incorporated into the history correctly.
What does reconciliation against the processor actually buy? It converts an invisible problem into a visible one. The ledger is correct about every event it received and knows nothing about events it did not receive. Comparing your records against the processor, which is the only system that knows whether money moved, turns a silently missing payment into a row in a report that somebody can act on.
Can webhook handling be made exactly-once? Exactly-once in effect, yes, by deduplicating on the event identifier and deriving state from an append-only ledger. It depends on that identifier being a sound deduplication key, so an event replayed under a new identifier is caught by reconciliation rather than by the handler. Describe it that way rather than claiming exactly-once outright.
Who owns the bug when a payment goes missing? Nobody, which is the problem. The site collected the intent correctly, the processor moved the money correctly, and your records are consistent with everything they were told. The failure lives in the relationship between two components rather than inside either one, and relationships between systems are not usually anybody's assigned responsibility.
Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.
Bring us the program