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.
The events
Section titled “The events”| 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.
Why the redirect proves nothing
Section titled “Why the redirect proves nothing”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.
Idempotency
Section titled “Idempotency”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.
Adding a handler
Section titled “Adding a handler”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.
Locally
Section titled “Locally”stripe listen --forward-to localhost:4000/api/webhooks/stripePut the printed whsec_… in .env and restart the API. The restart is the step
people skip.