<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: riffluv</title>
    <description>The latest articles on DEV Community by riffluv (@riff9045).</description>
    <link>https://dev.to/riff9045</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3751642%2Fe5ebf8bc-1db0-474a-8fd6-25cbb77332a4.png</url>
      <title>DEV Community: riffluv</title>
      <link>https://dev.to/riff9045</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/riff9045"/>
    <language>en</language>
    <item>
      <title>Stripe shows an active subscription, but my app didn’t unlock — debugging webhooks in Next.js</title>
      <dc:creator>riffluv</dc:creator>
      <pubDate>Thu, 05 Feb 2026 08:25:39 +0000</pubDate>
      <link>https://dev.to/riff9045/stripe-shows-an-active-subscription-but-my-app-didnt-unlock-debugging-webhooks-in-nextjs-28kh</link>
      <guid>https://dev.to/riff9045/stripe-shows-an-active-subscription-but-my-app-didnt-unlock-debugging-webhooks-in-nextjs-28kh</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This post is a free, complete checklist.&lt;br&gt;
Optional paid reference implementation (same flow, prebuilt):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payhip (primary): &lt;a href="https://payhip.com/b/NyvLJ" rel="noopener noreferrer"&gt;https://payhip.com/b/NyvLJ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gumroad (mirror): &lt;a href="https://riffluv.gumroad.com/l/nextjs-stripe-billing-template" rel="noopener noreferrer"&gt;https://riffluv.gumroad.com/l/nextjs-stripe-billing-template&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Launch discount code: &lt;code&gt;TOMATO69&lt;/code&gt; ($20 off)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the checklist I wish I had.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Webhooks are actually arriving (Stripe CLI output)&lt;/li&gt;
&lt;li&gt;[ ] Test vs live mode is not mixed (keys, prices, customers, webhook secrets)&lt;/li&gt;
&lt;li&gt;[ ] Signature verification uses the raw request body (&lt;code&gt;await req.text()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Each event can be mapped to a user (metadata / customer -&amp;gt; user mapping)&lt;/li&gt;
&lt;li&gt;[ ] Idempotency is implemented (dedupe by &lt;code&gt;event.id&lt;/code&gt; before side effects)&lt;/li&gt;
&lt;li&gt;[ ] Your entitlement store is persistent in production (not ephemeral filesystem)&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  The mental model (why this happens)
&lt;/h2&gt;

&lt;p&gt;In most apps, paid access is decided by your app state, not Stripe's dashboard.&lt;/p&gt;

&lt;p&gt;Typical chain:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Stripe Checkout -&amp;gt; Webhook -&amp;gt; Your store/DB -&amp;gt; Entitlement check (FREE/PRO)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If any link breaks, Stripe can be correct while your app still shows &lt;code&gt;FREE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Debugging goal: prove each link in that chain is working.&lt;/p&gt;


&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe Dashboard: subscription is active&lt;/li&gt;
&lt;li&gt;App UI: still &lt;code&gt;FREE&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is usually not "Stripe code is broken." It is usually one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook never arrived&lt;/li&gt;
&lt;li&gt;Signature verification failed (wrong secret or wrong body handling)&lt;/li&gt;
&lt;li&gt;Event arrived, but you could not map it to a user&lt;/li&gt;
&lt;li&gt;Test/live mode mismatch&lt;/li&gt;
&lt;li&gt;State store resets in production (serverless filesystem / in-memory)&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Step 1: Confirm webhooks are arriving
&lt;/h2&gt;

&lt;p&gt;For local debugging, forward Stripe events to your webhook endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:3002/api/webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then complete a test Checkout and watch for events like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customer.subscription.created&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customer.subscription.updated&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If nothing appears, nothing downstream can unlock access.&lt;/p&gt;

&lt;p&gt;Common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrong URL/path/port&lt;/li&gt;
&lt;li&gt;Dev server not running&lt;/li&gt;
&lt;li&gt;Forwarding to the wrong endpoint&lt;/li&gt;
&lt;li&gt;Stripe CLI not installed / not on PATH&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 2: Check test vs live mode
&lt;/h2&gt;

&lt;p&gt;This mismatch wastes hours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sk_test_...&lt;/code&gt; works only with test Prices/Customers/Webhook secrets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sk_live_...&lt;/code&gt; works only with live Prices/Customers/Webhook secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if a Price ID looks valid (&lt;code&gt;price_...&lt;/code&gt;), it may exist only in one mode.&lt;/p&gt;

&lt;p&gt;Also, webhook signing secrets are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local Stripe CLI forwarding gives one &lt;code&gt;whsec_...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dashboard production webhook endpoint gives another &lt;code&gt;whsec_...&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are not interchangeable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Signature verification (raw body + correct secret)
&lt;/h2&gt;

&lt;p&gt;If events arrive but your handler returns 400/401, check these two points.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Use raw request body
&lt;/h3&gt;

&lt;p&gt;Stripe signs the raw body string. If you parse JSON first, verification can fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not call &lt;code&gt;req.json()&lt;/code&gt; before verification.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Use the correct &lt;code&gt;whsec_...&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;"Signature verification failed" usually means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wrong environment secret&lt;/li&gt;
&lt;li&gt;using Dashboard secret while testing with CLI forwarding (or the reverse)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 4: Map events to users (or nothing unlocks)
&lt;/h2&gt;

&lt;p&gt;Even verified events cannot unlock access unless you can map them to your internal user.&lt;/p&gt;

&lt;p&gt;Reliable pattern at Checkout creation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;client_reference_id: userId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metadata: { userId }&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;subscription_data: { metadata: { userId } }&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In webhook handling, resolve user by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metadata user ID, and/or&lt;/li&gt;
&lt;li&gt;persisted &lt;code&gt;customerId -&amp;gt; userId&lt;/code&gt; mapping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Subscription events often include &lt;code&gt;customer&lt;/code&gt;, not your internal user ID&lt;/li&gt;
&lt;li&gt;Without mapping, events are unattributed and entitlement never updates&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 5: Idempotency (Stripe retries events)
&lt;/h2&gt;

&lt;p&gt;Stripe retries webhooks. Duplicates are normal.&lt;/p&gt;

&lt;p&gt;Dedupe by &lt;code&gt;event.id&lt;/code&gt; before side effects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wasEventProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ... side effects ...&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markEventProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This becomes critical once you add non-idempotent actions (emails, provisioning, analytics).&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Production storage (the local success trap)
&lt;/h2&gt;

&lt;p&gt;If entitlement state lives in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;memory, or&lt;/li&gt;
&lt;li&gt;filesystem on serverless&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;it can reset unexpectedly, and users appear &lt;code&gt;FREE&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;In production, store both in a real DB:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current entitlement/subscription state&lt;/li&gt;
&lt;li&gt;processed webhook event IDs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Success criteria (what "working" looks like)
&lt;/h2&gt;

&lt;p&gt;After Checkout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;App state shows user as paid (&lt;code&gt;PRO&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Webhook logs show events as &lt;code&gt;processed&lt;/code&gt; (not missing/failing)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If both are true, your integration is working.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you are learning / building this yourself
&lt;/h2&gt;

&lt;p&gt;AI can generate Stripe code fast. The time sink is operational debugging.&lt;/p&gt;

&lt;p&gt;If you build your own integration, add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;setup validator (env, CLI, price IDs, common pitfalls)&lt;/li&gt;
&lt;li&gt;minimal webhook debug view (&lt;code&gt;processed / duplicate / error&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;explicit success criteria (&lt;code&gt;after Checkout, entitlement becomes PRO&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That turns billing from "I think it works" into "I can prove what happened."&lt;/p&gt;




&lt;p&gt;If you want the exact reference implementation used for this flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payhip (primary): &lt;a href="https://payhip.com/b/NyvLJ" rel="noopener noreferrer"&gt;https://payhip.com/b/NyvLJ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gumroad (mirror): &lt;a href="https://riffluv.gumroad.com/l/nextjs-stripe-billing-template" rel="noopener noreferrer"&gt;https://riffluv.gumroad.com/l/nextjs-stripe-billing-template&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Discount code: &lt;code&gt;TOMATO69&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>stripe</category>
      <category>webhooks</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
