Hi — I'm Riff. I recently ran into a Stripe + Next.js (App Router) problem where Stripe showed an active subscription, but my app never unlocked paid access.
Optional (paid): I built a small reference implementation of this checklist: https://payhip.com/b/NyvLJ
Here's the checklist I wish I had.
TL;DR checklist
- [ ] Webhooks are actually arriving (Stripe CLI output)
- [ ] Test vs live mode isn't 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.id before 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.
A typical flow:
Stripe Checkout → Webhook → Your store/DB → Entitlement check (FREE/PRO)
If any link breaks, Stripe can be "correct" (subscription is active) while your app still shows "Free".
The goal of debugging is simple: prove each link in the chain is working.
The symptom
- Stripe Dashboard: subscription is active
- App UI: still "Free" (entitlement didn't update)
The problem is rarely "Stripe code is wrong". It's usually one of these:
- Webhook never arrived
- Signature verification failed (wrong secret or wrong body format)
- Event arrived, but you couldn't map it to the user
- Test/live mode mismatch
- Your state store resets in production (serverless filesystem / in-memory)
Step 1: Confirm webhooks are arriving
For local debugging, use the Stripe CLI and forward events to your webhook endpoint:
stripe listen --forward-to localhost:3002/api/webhook
Then complete a test Checkout and watch the terminal output. You should see events like:
- checkout.session.completed
- customer.subscription.created / customer.subscription.updated
If nothing appears, your endpoint isn't being hit — so nothing downstream can unlock access.
Common causes:
- Wrong URL/path/port
- Dev server not running
- Forwarding to the wrong endpoint (typo)
- Stripe CLI isn't installed / not on PATH
Step 2: Check test vs live mode
This is the fastest way to waste hours.
- sk_test_... works only with test-mode Prices/Customers/Webhook secrets
- sk_live_... works only with live-mode Prices/Customers/Webhook secrets
Even if a Price ID looks valid (price_...), it may only exist in one mode.
Also: webhook signing secrets (whsec_...) are separate:
- Local forwarding (stripe listen) gives you one whsec_...
- Production Dashboard webhook endpoint gives you a different whsec_...
They are not interchangeable.
Step 3: Signature verification (raw body + correct secret)
If events arrive but your handler returns 400/401, check two things:
1) Are you using the raw request body?
Stripe computes the signature over the raw string body. If you parse JSON first, verification can fail.
In Next.js Route Handlers, a safe pattern is:
export const runtime = "nodejs"; // avoid Edge for Stripe webhooks
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)
}
Key point: don't call req.json() before verification.
2) Are you using the correct whsec_...?
"Signature verification failed" usually means:
- you're using the whsec_... from a different environment, or
- you're using the Dashboard secret while testing with Stripe CLI forwarding (or vice versa)
Step 4: Map events to users (otherwise nothing unlocks)
Even if the webhook arrives and verifies, your app can't unlock access if you can't connect the event to a user.
A reliable pattern:
- When creating the Checkout Session, include a stable user identifier:
- client_reference_id: userId
- metadata: { userId }
- subscription_data: { metadata: { userId } }
- In the webhook handler, read userId from metadata, and/or maintain a mapping:
- customerId → userId
Why it matters:
- Subscription update events often only have customer, not your internal user id
- If you don't persist the mapping, you'll receive events you can't attribute
Step 5: Idempotency (Stripe retries)
Stripe retries webhooks. You will see duplicates.
Before doing any side effects, dedupe by event.id:
if (await wasEventProcessed(event.id)) {
return Response.json({ received: true, duplicate: true });
}
// ... do side effects ...
await markEventProcessed(event.id);
This matters even more if you later add non-idempotent actions (emails, provisioning, analytics, etc.).
Step 6: Production storage (the "it worked locally" trap)
If your entitlement state is stored in:
- in-memory variables, or
- filesystem on serverless
…it may reset unexpectedly. Then users appear "Free" again even though Stripe is still active.
In production, store entitlements + processed event IDs in a real DB.
What "working" looks like (success criteria)
After Checkout:
- App state: your store/DB shows the user as paid (e.g. "PRO")
- Webhook logs: events show as processed (not missing / failing)
If both are true, your integration is working.
If you're learning / building it yourself
AI can generate Stripe code quickly. The time sink is debugging the operational failures.
If you're building your own integration, consider adding:
- a setup validator (env vars, Stripe CLI, price IDs, common pitfalls)
- a minimal webhook debug view (arrived / processed / duplicate / error)
- explicit success criteria you can verify quickly ("after Checkout, entitlement becomes PRO")
That turns billing from "I think it works?" into "I can see exactly what happened."
If you spot anything incorrect, please comment — happy to refine the checklist.
Top comments (0)