The Browser Is Not Allowed to Tell You a Payment Succeeded
If your app marks an order paid because the customer reached the success page, you will ship orders nobody paid for and lose payments people did make. The provider has to tell your server.
By NinjaNext TechnologiesPublished 4 min read
Key takeaways
- A customer reaching the success page is not evidence that money moved.
- The payment provider must tell your server directly, and your server must verify the message is genuine.
- Webhooks arrive late, out of order, more than once, or not at all: handle all four.
- A scheduled re-check of anything still unresolved is what stops a lost message becoming a lost payment.
Almost every prototype gets payments wrong in the same way, and it is not an obvious mistake. The customer pays, the browser is redirected to a success page, and that page tells your app the order is paid. It works perfectly in testing, because in testing nothing goes wrong.
Real payments are messier. Cards are declined after the page has loaded. Customers close the tab during the bank's verification step. Phones lose signal between the payment and the redirect. A subscription lapses three months later. A refund happens next week. None of that reaches a success page, and some of it is money you will never see.
The two failures, and which is worse
Trusting the browser fails in both directions. Someone can reach the success page without a completed payment, and you ship goods nobody paid for. More commonly, a payment genuinely succeeds and the customer never reaches the page, so your app shows an unpaid order while the money is sitting in your account.
The second is worse than it sounds. The customer has been charged and believes they have bought something, and your system disagrees with them. That is a support conversation that starts with the customer already upset and you unable to see what they can see on their bank statement.
The provider tells your server
The pattern that works is the same with every provider. The payment provider sends a message directly to your server when something happens, your server verifies the message is genuinely from the provider, and that message, not the browser, decides what your records say.
The verification step is not optional. A webhook endpoint is a public URL, so anyone can send a request to it. Every provider gives you a way to prove a message came from them, and an endpoint that skips that check will happily accept an invented message saying an order was paid.
Four things webhooks do that break naive code
Once you are receiving messages from a provider, four behaviours cause most of the bugs, and all four are normal rather than exceptional.
- They arrive more than once. Providers retry when they are unsure you received a message, so the same event can be delivered several times. Record which event identifiers you have already processed and ignore repeats, or you will refund twice.
- They arrive out of order. A message saying a payment succeeded can arrive after one saying it was refunded. Check whether the state you are about to write is actually newer than the state you have, rather than applying whatever arrived last.
- They arrive late. Sometimes minutes later. Your interface needs an honest pending state rather than a guess, because the alternative is telling the customer something you do not yet know.
- They never arrive. A deployment, an outage or a misconfigured URL and the message is gone. This is the one nobody plans for.
The job that catches what was missed
The answer to the last one is a scheduled job that looks for anything still unresolved after a sensible interval and asks the provider directly what happened to it. Payments stuck in a pending state, orders with no outcome, subscriptions whose renewal should have occurred.
This is the part most teams leave out, and it is the difference between a payment system that mostly works and one you can leave running. We have built this pattern with three different gateways: Stripe on a flight booking platform, where webhooks from both the payment provider and the airline supplier plus scheduled status checks keep the booking and the payment in agreement; the Global Payments API with 3D Secure 2 on a workshop platform that can produce a payment proof pack for any transaction; and JazzCash mobile-wallet payments on a telemedicine product, where a request stays a draft until the server has been told the money arrived.
Different providers, same shape. The provider is the source of truth, the server verifies what it is told, and a scheduled check catches whatever went missing.
What to check in your own app
- Find where your code marks an order paid. If that line runs in response to a page load or a redirect, that is the bug.
- Confirm your webhook endpoint verifies the provider's signature before trusting anything in the message.
- Send the same test event twice and confirm the second one changes nothing.
- Check what your app shows a customer whose payment is still pending. If it shows failure, you are lying to people who have paid.
If you would rather have someone go through this properly, our payment and API integration page covers how we approach it, and you can tell us what you are building.