Hi - I'm Riff. I recently hit a Stripe + Next.js (App Router) issue where Stripe showed an active subscription, but my app never unlocked paid access.
This post is a free, complete checklist.
Optional paid reference implementation (same flow, prebuilt):
- Payhip (primary): https://payhip.com/b/NyvLJ
- Gumroad (mirror): https://riffluv.gumroad.com/l/nextjs-stripe-billing-template
- Launch discount code:
TOMATO69($20 off)
Here is the checklist I wish I had.
TL;DR checklist
- [ ] Webhooks are actually arriving (Stripe CLI output)
- [ ] Test vs live mode is not mixed (keys, prices, customers, webhook secrets)
- [ ] Signature verification uses the raw request body (
await req.text()) - [ ] Each event can be mapped to a user (metadata / customer -> user mapping)
- [ ] Idempotency is implemented (dedupe by
event.idbefore side effects) - [ ] Your entitlement store is persistent in production (not ephemeral filesystem)
The mental model (why this happens)
In most apps, paid access is decided by your app state, not Stripe's dashboard.
Typical chain:
Stripe Checkout -> Webhook -> Your store/DB -> Entitlement check (FREE/PRO)
If any link breaks, Stripe can be correct while your app still shows FREE.
Debugging goal: prove each link in that chain is working.
The symptom
- Stripe Dashboard: subscription is active
- App UI: still
FREE
This is usually not "Stripe code is broken." It is usually one of:
- Webhook never arrived
- Signature verification failed (wrong secret or wrong body handling)
- Event arrived, but you could not map it to a user
- Test/live mode mismatch
- State store resets in production (serverless filesystem / in-memory)
Step 1: Confirm webhooks are arriving
For local debugging, forward Stripe events to your webhook endpoint:
stripe listen --forward-to localhost:3002/api/webhook
Then complete a test Checkout and watch for events like:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updated
If nothing appears, nothing downstream can unlock access.
Common causes:
- Wrong URL/path/port
- Dev server not running
- Forwarding to the wrong endpoint
- Stripe CLI not installed / not on PATH
Step 2: Check test vs live mode
This mismatch wastes hours.
-
sk_test_...works only with test Prices/Customers/Webhook secrets -
sk_live_...works only with live Prices/Customers/Webhook secrets
Even if a Price ID looks valid (price_...), it may exist only in one mode.
Also, webhook signing secrets are different:
- Local Stripe CLI forwarding gives one
whsec_... - Dashboard production webhook endpoint gives another
whsec_...
They are not interchangeable.
Step 3: Signature verification (raw body + correct secret)
If events arrive but your handler returns 400/401, check these two points.
1) Use raw request body
Stripe signs the raw body string. If you parse JSON first, verification can fail.
export const runtime = "nodejs";
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get("stripe-signature") ?? "";
// stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET)
}
Do not call req.json() before verification.
2) Use the correct whsec_...
"Signature verification failed" usually means:
- wrong environment secret
- using Dashboard secret while testing with CLI forwarding (or the reverse)
Step 4: Map events to users (or nothing unlocks)
Even verified events cannot unlock access unless you can map them to your internal user.
Reliable pattern at Checkout creation:
client_reference_id: userIdmetadata: { userId }subscription_data: { metadata: { userId } }
In webhook handling, resolve user by:
- metadata user ID, and/or
- persisted
customerId -> userIdmapping
Why this matters:
- Subscription events often include
customer, not your internal user ID - Without mapping, events are unattributed and entitlement never updates
Step 5: Idempotency (Stripe retries events)
Stripe retries webhooks. Duplicates are normal.
Dedupe by event.id before side effects:
if (await wasEventProcessed(event.id)) {
return Response.json({ received: true, duplicate: true });
}
// ... side effects ...
await markEventProcessed(event.id);
This becomes critical once you add non-idempotent actions (emails, provisioning, analytics).
Step 6: Production storage (the local success trap)
If entitlement state lives in:
- memory, or
- filesystem on serverless
it can reset unexpectedly, and users appear FREE again.
In production, store both in a real DB:
- current entitlement/subscription state
- processed webhook event IDs
Success criteria (what "working" looks like)
After Checkout:
- App state shows user as paid (
PRO) - Webhook logs show events as
processed(not missing/failing)
If both are true, your integration is working.
If you are learning / building this yourself
AI can generate Stripe code fast. The time sink is operational debugging.
If you build your own integration, add:
- setup validator (env, CLI, price IDs, common pitfalls)
- minimal webhook debug view (
processed / duplicate / error) - explicit success criteria (
after Checkout, entitlement becomes PRO)
That turns billing from "I think it works" into "I can prove what happened."
If you want the exact reference implementation used for this flow:
- Payhip (primary): https://payhip.com/b/NyvLJ
- Gumroad (mirror): https://riffluv.gumroad.com/l/nextjs-stripe-billing-template
- Discount code:
TOMATO69
Top comments (0)