Event Ticketing: Payments Rebuilt
Stripe wired without payment status tracking. Worked in demos. Would have failed on the first busy on-sale.
- 8 weeks
- audit to MVP
- ~30k
- tickets a year, first venue
- Nov 2026
- ticket sales launch
- Region
- US
- Role
- Solution architect, team of one
- Duration
- May – Jul 2026 · 3 months
- Status
- Handed off. Returning Nov 2026.
What was broken
I came in for an audit. The Go codebase worked in demos. Stripe was wired straight into checkout with no payment status on the order: the buyer paid, and the system trusted that the right thing had happened.
Webhook retries were not deduplicated. Stripe retries every event that does not get a 200. Each retry would have created a second set of tickets and sent a second confirmation email.
Refund routes had no tenant checks. Logins were staff or admin with no separation between the two and no venue scoping. Any staff account could refund another venue's order.
No incident had happened yet. The first venue sells about 30k tickets a year, so the first busy on-sale would have been the incident. I had eight weeks to replace the money path before that.
What I did
The order exists before Stripe does
POST /payment-intent validates the show, holds a reservation under a CHECK constraint (sold + reserved ≤ capacity), creates the Payment Intent, then writes the order as pending with an expiry. The client gets client_secret, order_id and expires_at. The server owns the clock.
WhyThe order is the source of truth, not the Stripe callback. If the webhook is late or never arrives, the order is still there in a known state, and the reservation prevents overselling in the meantime.
Idempotency at three levels, each with its own key
Webhook level: a stripe_events table keyed by event.id, INSERT … ON CONFLICT DO NOTHING. Data level: a unique index on tickets(order_id, ticket_seq), with the whole success path in one transaction. Stripe level: an idempotency key on Payment Intent creation, hashed from reservation, show, email, quantity and total.
WhyThe stripe_events check is check-then-act without a lock, so a race window exists. Instead of closing it with Redis SETNX, I made the handler safe to replay. If two paths run at once, the second insert fails on the unique index and rolls back. stripe_events became an optimization, not a guarantee. Redis would have added a second source of truth for money and one more thing to fail. A queue was not needed for one instance and tens of orders a day.
event.id, not payment_intent.id
One Payment Intent produces many events: created, processing, succeeded, payment_failed, canceled. account.updated, payout.failed and charge.dispute.* have no Payment Intent at all. For account and dispute events the handler is last-write-wins by event.created; stale events are acknowledged without a write.
WhyKeyed on payment_intent.id, the second legitimate event for the same intent looks like a duplicate and gets dropped.
Confirm on read, reconcile daily, sweep every 30 seconds
GET /checkout/status asks Stripe directly if the order is still pending and runs the same success handler synchronously. A daily job under an advisory lock finds confirmed orders with no tickets and finishes them. A sweeper releases expired reservations.
WhyWebhooks are the main path, not the only path. The buyer on the success page should not wait for a callback, and no order should stay stuck because one event was lost.
Tenant checks in the handler, with the trade-off stated
Row-level security is written in Postgres, but the Go backend connects with the service role and bypasses it. The effective enforcement: the auth middleware validates the Supabase JWT, every admin route carries venue_id in the path, and the handler checks the profile against venue membership. Nested entities are checked too. A show that belongs to another venue returns 404.
WhyA Supabase JWT is not a Postgres role. Making RLS work from Go means a transaction with SET LOCAL on every request and rewriting every policy. For two to five venues that buys a second access model. RLS stays as a second perimeter for the day the admin talks to the database directly. The weak spot: a handler check is something to remember, not an invariant. The path convention and a middleware variant cover it. I would rather name that than hide it.
Metrics on the money path before features
Prometheus on every money route, seven alert rules, a CI gate that runs migrations up, down and up again, a smoke test script and a runbook for the test-to-live cutover.
WhyFailures here are quiet. Nothing crashes when a webhook is dropped. You hear about it from a venue manager. Metrics turn that into an alert.
Proof
- 8 weeks
- audit to MVP
- ~30k
- tickets a year, first venue
- Nov 2026
- ticket sales launch
Payment path. Two entry points, one transaction.
The key fix
// Cheap dedupe first. The real guarantee is below, in the transaction.func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { event, err := webhook.ConstructEvent(body, sig, h.webhookSecret) if err != nil { http.Error(w, "bad signature", http.StatusBadRequest) return } seen, err := h.store.IsStripeEventProcessed(ctx, event.ID) if err == nil && seen { w.WriteHeader(http.StatusOK) // already handled, ack and exit return } // On a read error we keep going. A duplicate is safe. A lost event is not. if err := h.process(ctx, event); err != nil { h.store.MarkStripeEventFailed(ctx, event.ID, err) http.Error(w, "retry", http.StatusInternalServerError) // Stripe retries return } h.store.MarkStripeEventProcessed(ctx, event.ID) // INSERT … ON CONFLICT (id) DO NOTHING w.WriteHeader(http.StatusOK)}// One transaction. If a second path races us, the unique index on// tickets(order_id, ticket_seq) fails the insert and the whole tx rolls back.func (h *Handler) handlePaymentSuccess(ctx context.Context, pi *stripe.PaymentIntent) error { return h.db.Tx(ctx, func(tx *sql.Tx) error { order, err := q.LockOrderByPaymentIntent(tx, pi.ID) if err != nil { return err } if order.Status == "confirmed" { return nil // replay, nothing to do } if err := q.SetOrderStatus(tx, order.ID, "confirmed"); err != nil { return err } if err := q.ConsumeReservation(tx, order.ReservationID); err != nil { return err } for seq := 1; seq <= order.Quantity; seq++ { if err := q.InsertTicket(tx, order.ID, seq, newValidationCode()); err != nil { return err // unique violation: the other path won } } return q.AuditPaymentConfirmed(tx, order.ID, pi.ID) }) // The caller sends the email after commit. Never inside the transaction.}What I would do differently
Go fits a single-binary money path. I would still think twice before choosing it for a bigger product.
The buyer checkout is still Create React App. It is logged as debt in the proposal and it is the first thing I replace in November.
Order status transitions are strings set in several places. A transition table would make the state machine an invariant instead of a convention.
Stack
- Go
- PostgreSQL
- Supabase Auth
- Stripe Payment Intents
- Stripe Connect
- Railway
- Redis
- Next.js 15
- Prometheus
- Resend
Tell me what is broken.
I will tell you what I see.
30 minutes. You describe the system, I ask questions. No pitch.