DEV Community

Cover image for Stripe shows an active subscription, but my app didn’t unlock — debugging webhooks in Next.js
riffluv
riffluv

Posted on • Edited on

Stripe shows an active subscription, but my app didn’t unlock — debugging webhooks in Next.js

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):

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.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.

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
Enter fullscreen mode Exit fullscreen mode

Then complete a test Checkout and watch for events like:

  • checkout.session.completed
  • customer.subscription.created
  • customer.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)
}
Enter fullscreen mode Exit fullscreen mode

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: userId
  • metadata: { userId }
  • subscription_data: { metadata: { userId } }

In webhook handling, resolve user by:

  • metadata user ID, and/or
  • persisted customerId -> userId mapping

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. App state shows user as paid (PRO)
  2. 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:

Top comments (0)