Setting up Stripe correctly in a Next.js SaaS rarely takes less than 5 hours. Between signature verification, idempotent event handling, price-to-plan mapping, and atomic subscription updates in the database, every step hides a trap. Here is the complete method to do it cleanly with the App Router.
1. Create the webhook route
In app/api/stripe/webhook/route.ts, export a POST handler. The signature must be verified BEFORE parsing the body. Next.js 16 exposes request.text() to grab the raw body needed for verification.
const sig = request.headers.get('stripe-signature')!
const body = await request.text()
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)2. Handle the key events
At minimum, four events must be handled:
checkout.session.completed— subscription creation or one-time purchasecustomer.subscription.updated— sync plan changescustomer.subscription.deleted— mark subscription as canceledinvoice.paid— monthly credit refill
3. Map Stripe Price IDs to plans
Store plans in code (not the database). The webhook matches line_items[0].price.id to your configuration. This avoids migrations every time you tweak pricing.
4. Idempotency and atomicity
Stripe can re-emit an event. Persist the event.id and skip duplicates. For credits, use SQL RPCs (decrement_credits, add_credits) — never a direct UPDATE.
5. Test locally
Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/stripe/webhook then stripe trigger customer.subscription.created to simulate each event.
Don't waste 5 hours wiring Stripe. The complete, idempotent, production-ready code is included in Boilerplate-Stack — webhooks, credit packs, customer portal, and subscriptions.
Conclusion
Stripe webhooks are the pillar of modern billing but full of pitfalls. Once wired correctly, they become invisible. Boilerplate-Stack implements this entire flow and much more in a production-ready starter kit.