Webhooks are the source of truth, not the redirect
The single most common mistake in a first payment integration is marking an order paid because the customer's browser landed back on a success page. That page proves nothing on its own.
3 min read
The trap, stated plainly
Customer clicks "Pay" -> gateway's hosted checkout -> customer redirected
back to https://yourapp.com/checkout/success?order=123
It's tempting to write: "if the browser reaches /checkout/success, mark the order as paid." Don't. That URL is just a page the merchant's own frontend defined — nothing stops a customer from typing it in directly, bookmarking it from a previous session, or the redirect firing after a payment that was later declined. The browser landing on a "success" URL is a UX signal, not a financial one.
Why the redirect genuinely can't be trusted
- It travels through the customer's own browser — a channel the customer fully controls. Nothing server-side observed this request happen as a direct consequence of a successful charge.
- Redirects can fail to fire at all (closed tab, network drop, browser crash) even after a real, successful payment — so relying on it also creates false negatives, not just false positives.
- Query parameters on a redirect URL are trivially editable by hand.
The webhook: a server-to-server call the customer never touches
Gateway's servers -- POST --> Merchant's own backend endpoint
(https://yourapp.com/api/webhooks/payments)
This is a completely separate channel: the payment gateway's own infrastructure calling the merchant's backend directly, with no browser, no customer, and no client-side code in between. This is the signal a backend should actually act on — mark an order paid, provision access, send a confirmation — because it originates from the party that actually processed the charge.
So what is the redirect actually for?
Purely the customer-facing experience: showing a "Thanks, here's your receipt" page versus a "Something went wrong, try again" page, ideally by querying the merchant's own database for that order's current status (which the webhook already updated) rather than trusting anything in the redirect URL itself. The redirect decides what the customer sees; the webhook decides what actually happened.
Correct division of responsibility:
Redirect -> "which page should I show this browser"
Webhook -> "did this payment actually succeed, and should
anything in my system change because of it"
The practical rule for a payment integration
Never write order-fulfilling logic (granting access, shipping a product, marking an invoice paid) inside a redirect-handling route. That logic belongs in the webhook handler, full stop. The redirect handler's only job is to read the current status from the database and render the right page — it should not be the thing that sets that status in the first place.
A subtlety worth knowing: the webhook can still be too early or too late
Even the webhook isn't instantaneous or perfectly ordered — a gateway can (rarely) deliver events out of order, or a webhook can be delayed by seconds. This is why the customer-facing "success" page usually shows a brief "confirming your payment…" state rather than assuming the webhook has already landed by the time the redirect completes — the two requests race, and the UI should tolerate the webhook winning that race a moment after the redirect does.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. A developer writes: "if the browser reaches /checkout/success, mark the order as paid." What's wrong with this?
2. Where should the logic that actually marks an invoice as paid and grants product access live?
3. A real, successful payment sometimes never triggers the redirect-back at all (closed tab, dropped connection). What does this imply about a design that only fulfills orders via the redirect?
4. Why might a payment confirmation page show a brief loading/confirming state rather than immediately rendering success or failure?