NestJS Stripe boilerplate: webhooks, subscriptions and one-time payments
CrabStack's NestJS 12 backend ships a Stripe integration with subscriptions, a one-time product, and a webhook handler built for the way Stripe delivers events: late, twice, and out of order. This page explains how the integration works, what the tests cover, and where it stops.
Why the webhook is the hard part
A Stripe checkout is a few lines of code. The webhook decides who has paid access, and it is where most billing bugs live. Stripe retries deliveries, so the same event can arrive twice. Events can arrive out of order. Two events can carry the same timestamp to the second. A handler has to give the right answer in every one of those cases.
How the webhook works
The route is POST /webhooks/stripe in stripe-webhook.controller.ts.
- Raw body. An
express.raw()parser is mounted for that path only, because Stripe signs the exact bytes it sent. - Signature. The handler verifies the
stripe-signatureheader over those bytes before it reads the event. A missing header gets 400. - Mode check. An event whose live or test mode differs from the secret key's mode gets 400, and nothing is written. Without that check, free test-card events could grant paid access under a live key.
- Guarded write. Subscription state is written only by
BillingSubscriptionStore. An update lands only if the row is not locked by a dispute, the event id has not been applied, and the event is strictly newer than the last one applied. First deliveries that race resolve withINSERT ... ON CONFLICT DO NOTHING. When two events about the stored subscription tie to the second, the store reads the live subscription from Stripe and writes that. - Log. Every event is recorded with its outcome.
Which events it handles
checkout.session.completed, for subscriptions and one-time paymentscheckout.session.async_payment_succeededand..._failedcustomer.subscription.created,updatedanddeletedcharge.refunded, full refunds onlycharge.dispute.funds_withdrawn, which drops the user to free and locks the row as disputed
invoice.payment_failed is left unhandled on purpose. Stripe moves the
subscription to past_due and sends customer.subscription.updated for
the same change.
Subscriptions and access
Checkout opens a Stripe Checkout session in subscription mode. Customers
manage their plan in the Stripe Customer Portal. The rest of the app asks
one question, getEntitlement(userId), and branches on paidAccess. The
shipped example is the free-plan cap of 100 items.
The access rules are written down: past_due keeps access while Stripe
retries the card, incomplete never grants it, and paid access ends three
days after the period end if renewals stop arriving.
One-time payments
A sample lifetime product lives in backend/src/purchases/.
POST /purchases/checkout takes a product key, and the server looks up
the Stripe price, so a client cannot choose its own price. A second paid
purchase of a product the user already holds is recorded as a duplicate
and refunded. A full refund or chargeback revokes the grant.
What the tests cover
The billing and purchases specs hold over 300 test cases. Most mock Stripe's
signature check. One spec, billing-webhook.http.spec.ts, runs the real
check over HTTP. It accepts the exact signed bytes, refuses the same
signature over a re-serialized body, refuses a request with no signature
header, and refuses an event from the other Stripe mode. Integration
specs run the subscription store against a real Postgres.
Local development
npm run dev:stripe runs stripe listen and forwards events to the local
backend. The Stripe API version is pinned in billing.config.ts, so an
upgrade is a deliberate change. Without Stripe keys, billing stays off.
With a secret key and no webhook secret, the backend refuses to boot.
Who it fits
- You sell subscriptions, a one-time product, or both, through Stripe.
- You want refunds, disputes and duplicate deliveries handled before your first customer finds them.
Who it does not fit
- You need Paddle, Lemon Squeezy or another provider. Stripe is the only one wired in. A guide for swapping providers is included.
- You need several paid plans out of the box. One plan, Pro, ships configured. A second plan needs code in two places.
- You need per-seat billing. A team's plan is its owner's plan.
Related
- NestJS SaaS boilerplate: everything that ships
- NestJS Postgres boilerplate: where billing state is stored
- NestJS multi-tenant boilerplate: how plans apply to workspaces
- Claude Code SaaS boilerplate: the rules agents read before touching billing
- Docs: Billing, Webhook semantics, Webhook internals, One-time payments, Gating features
- Compare: vs ShipFast, vs Makerkit, vs supastarter
FAQ
How does CrabStack verify Stripe webhook signatures in NestJS?
A raw body parser is mounted for the webhook path only, and the handler
verifies the stripe-signature header over those exact bytes before it
reads the event. A test runs that check over real HTTP.
What happens when Stripe sends the same event twice? The second delivery changes nothing. The store skips an event id it has already applied and any event older than the last one applied.
Does it handle subscriptions and one-time payments? Yes. Subscriptions use Stripe Checkout and the Customer Portal. A sample one-time product has its own checkout endpoint, refund handling and duplicate protection.
Can I run it without Stripe? Yes. With no Stripe keys, the billing module stays off and the rest of the app runs.
How do I test webhooks locally?
Run npm run dev:stripe in backend/. It runs the Stripe CLI's
stripe listen and forwards events to localhost:4000/webhooks/stripe.