Skip to content

Stripe webhooks (inbound)

POST /api/webhooks/stripe is mounted above the JSON body parser and the storefront gate, receives the raw body, and verifies it against STRIPE_WEBHOOK_SECRET before anything else. Without the secret the route refuses everything, and no order is ever marked paid.

Event What Beluga does
checkout.session.completed Records the order as paid, decrements inventory (flagging oversold if it ran out), links it to a verified customer account if the email matches one, sends the Ordered email, queues order.paid.
checkout.session.expired If abandoned-cart recovery is on, salvages the cart for a signed-in customer and sends the reminder immediately. Otherwise nothing.
charge.refunded Adds to refundedCents, moves the order to refunded once the whole charge is covered, restocks on a full refund (once), sends the Refunded email, queues order.refunded.

Subscribe to exactly those three when you create the endpoint in the Stripe dashboard.

A buyer can close the tab between paying and being redirected. The success URL can be typed in by hand. So the confirmation page does not assume anything; it polls GET /api/checkout/:sessionId until the webhook has done its work. This is invariant 3, and it is why testing without stripe listen looks like a broken checkout.

Stripe delivers at-least-once. Every event id is recorded before its handler runs; a duplicate is acknowledged and ignored. If a handler fails, the record is released so Stripe’s retry is actually processed rather than dismissed as a duplicate. That is recordWebhookEvent and forgetWebhookEvent in db/orders-repository.ts, and invariant 4.

stripe events resend <id> is the test: stock must not move a second time.

Add it in server/routes/webhook.ts, inside the same verified, deduplicated path. Do not write a payment state anywhere else. If the new handler needs to notify something outside Beluga, queue an outbound event rather than calling out inline; a slow call from inside the Stripe handler trips Stripe’s own retry.

Terminal window
stripe listen --forward-to localhost:4000/api/webhooks/stripe

Put the printed whsec_… in .env and restart the API. The restart is the step people skip.