Blog · Payments
Stripe checkout for event tickets: what broke
By 2BKK · Published 27 September 2026
muaythaitickets.net sells tickets for Muay Thai fight nights in Bangkok. Each event repeats on a schedule, each night has several seat categories with their own quota, and some nights have their own prices. Payment is by card through Stripe Checkout. It has been live with real payments since the end of May 2026. Here is what broke, what did not, and the checks we now run after every change.
The outage that hid behind a port change
Three days after launch, orders stopped confirming. Customers paid, Stripe recorded the payment, and our order stayed "pending". The checkout page was fine. The site was fine. What failed was the one URL nobody looks at: the webhook.
The cause was not Stripe and not our code. The site had just moved to a new production container on a different port, and the web server in front of it still sent the webhook path to the old port. Stripe got a 502 for every event, retried on its schedule, and our order table never heard about the payments.
The fix took one line. The lesson took longer to absorb:
- A webhook is part of the deploy surface. Every change to the proxy, the ports or the container names needs a webhook test, not just a page load.
- The test is cheap: send an unsigned POST to the webhook URL. The right answer is a 400 from signature verification. A 502, a 404 or a redirect means the request never reached the code.
- Watch the "pending" count. An order that is pending for more than a few minutes after a successful Stripe session is an incident, not a customer who changed their mind.
Quotas per seat category
A ticket class is not just a price. Ringside has a different quota from second ring, and the promoter changes the quota per night. The first version stored one quota per event, which was wrong within a week.
Now each ticket class carries its own quota and each scheduled night can override prices per class. The admin (Payload CMS) shows both, the storefront reads the effective values for the night, and the checkout re-checks availability on the server before it creates the Stripe session. The client never decides how many tickets are left.
Changing that schema on a live database taught us the second rule: schema first, code second. The production admin does not push schema changes automatically, so every column is added by hand before the code that needs it is deployed. Deploying code that expects a column that is not there yet takes the site down in a way that looks like a random crash.
Idempotency and status
Stripe can send the same event more than once, and a nervous customer can retry a checkout. The order status moves in one direction only, every update is keyed on the Stripe session id, and a second "paid" event for the same session is a no-op. That is boring, and it is the reason a duplicated webhook has never produced a duplicated ticket.
Emails and what the door sees
The confirmation email goes out through Brevo from the same code path that marks the order paid, so a customer never gets a confirmation for an order that is not confirmed. The email is what the customer shows at the door, so it carries the event, the night, the class and the quantity in plain text near the top, before any styling.
What we test now, every deploy
- Load the storefront and one event page.
- Send an unsigned POST to the webhook URL and expect a 400.
- Check that the pending-orders count is not growing.
- Buy one real ticket on the cheapest class with a real card, then refund it in the Stripe dashboard.
Step four costs a small fee and a minute. It has caught two problems that the first three did not.
Still on the list
A cleanup job for sessions that were opened and never paid, so the pending list stays readable, and the Google Ads conversion label on the purchase event. Both are written down with the rest of the backlog on the case study page.