How Alipay exposed my payment flow problem

Introduction

Hi!

I run a small side project where people pay once to receive a service, with Stripe Checkout handling the payment. For a year everything looked fine, and I activated pretty much every payment method I could.

Then I tried to expand to China and things got weird. While everything seemed fine for the first Chinese customers, one day I received an email: someone paid, but their order didn’t process on the website. They even tried to pay again but it still didn’t work. 🤔

Checking the backend dashboard, their order simply did not exist. Skeptical, I opened Stripe and surprise, I found their payment using their provided email, marked as successful.

How it worked

As it was my first time implementing a payment flow, I was pretty naive:

  1. My backend creates a Checkout Session which redirects to /payment-success?session_id={CHECKOUT_SESSION_ID} of my website on success.
  2. The customer pays on Stripe’s page, and Stripe redirects them to /payment-success.
  3. That page calls my backend, which asks Stripe whether the session is paid. If yes, it starts processing the order.
1
2
3
4
5
6
7
sess, err := session.Get(req.SessionID, nil)
// ...
if sess.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
http.Error(w, "Payment not completed", http.StatusBadRequest)
return
}
ProcessOrder(sess.Metadata["order_id"])

If anything went wrong, the page just sent the customer back to the home page (oops).

The backend does check the real payment status with Stripe, so nobody can cheat by just calling /payment-success manually. I thought that made it safe. Can you see the problem already?

Never trust the client to do critical work

When paying with a card, Stripe receives the payment confirmation and then redirects the browser to the /payment-success page.

But these customers paid using Alipay and WeChat Pay and those seem asynchronous: they pay using the app on their phone and Stripe confirms it a bit later (or the browser gets redirected too early, unfortunately I don’t have an Alipay account to reproduce it). All in all, it means we can lose a payment when:

  • The browser comes back too early: My backend asks Stripe if the payment is successful, Stripe says not yet (since Stripe is still processing), and the /payment-success page sends the customer back to the homepage. They’re confused and when Stripe confirms the payment a few seconds later it’s already too late: they would need to go back to /payment-success for my backend to verify again.
  • The browser never comes back: When using a phone, the customer switches to the Alipay app to pay and if their browser gets closed or killed in the background my backend will never be aware of this payment since they never got redirected to /payment-success.

Check the payment flows for the different cases:

"After" is the fix we're going to do, don't spoil yourself!

What bothers me the most is that they may land back on the homepage, think there was a bug and try to pay again for the same thing. Then I have to refund them on top of it, I feel guilty and I lose money.

Why didn't I catch this in testing? ▶

For testing I only used the Stripe test card, which is synchronous: payments were confirmed instantly, before the redirect happened. In production I had 1000+ orders before Alipay payments started coming in and then the problem appeared. I guess I was lucky with the timing on the other asynchronous payment methods (they informed Stripe faster than the redirect happened).

When I coded the payment flow I just imagined a flow that should work, and did not read Stripe’s fulfillment guide, which says to fulfill orders from a webhook precisely because you can’t count on the customer’s browser to always land back on your success page at the right time. Oops.

Webhooks all the way

Why try to verify payments ourselves when Stripe can just tell us while we do nothing? Their webhook calls my server directly when something happens, signs the request and even keeps retrying until my server answers with a 2xx HTTP status code (which is really good if the server happens to be down at that time).

I kept the /payment-success call but only as a shortcut so that card payments still unlock instantly if the redirect is faster than the webhook (and I didn’t have the motivation to remove the old code completely). Both go through the same fulfillCheckoutSession function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), os.Getenv("STRIPE_WEBHOOK_SECRET"))
if err != nil {
// Anyone can POST a fake "payment succeeded" event otherwise
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}

switch event.Type {
case "checkout.session.completed", "checkout.session.async_payment_succeeded":
// - still unpaid (Alipay processing)? answer 200, async_payment_succeeded will come later
// - paid? process the order and answer 500 on failure so that Stripe retries (shouldn't happen, but just in case)
case "checkout.session.async_payment_failed":
// Declined after Checkout, nothing was charged, just send an alert for me to review
}
Did I expose my backend server for this webhook? ▶

My Go backend has no public port, so Stripe posts to a Next.js route that forwards the request. I just had to make sure to preserve the request exactly so the signature stays valid, and return the backend's status code instead of a blanket 200.

Having two ways of triggering the processing of an order while wanting the order to be processed only once is a new problem though. I just put a unique index on the Checkout Session ID and let the database return a constraint error if it’s already processed. Incidentally it also handles the case when Stripe’s webhook delivers the same event twice, which it can do.

Finally, I modified the /payment-success page so it doesn’t send anyone home on error anymore. It will show whether the payment is still processing, declined or never completed.

API versions shenanigans

Of course it didn’t work on the first try, that would be too easy :) Now every request from Stripe to my webhook was rejected by webhook.ConstructEvent.

Stripe maintains different API versions and stripe-go (the library I used) pins one API version per major release. The library refuses to handle events whose version doesn’t match (understandably), and I was using stripe-go v78 which is quite “old” as the pinned version (2024-04-10) isn’t even offered for webhooks in the Stripe dashboard anymore.

I upgraded to stripe-go v86 (2026-08-26.dahlia API version) and recreated the webhook endpoint with that. Until Stripe decommissions that version I should be safe, but it’s still something to be careful about since it means manual changes:

  • either I update the stripe-go version and need to check whether the API version changed (which means I would need to recreate my webhook in Stripe).
  • or Stripe decommissions this API version and I need to update the library (I doubt this would happen before I receive multiple warnings by email though).

Conclusion

I had 8 affected customers, some never got their order while others paid twice or even thrice. I refunded everyone and even the legitimate charges as a goodwill gesture for the trouble.

Next time I’ll read the docs carefully, especially for a payments API :’) I’ll also be careful never to “silence” an error on the client side, and to clearly communicate what’s going on..

That’s it, thanks for reading!