<?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: Amer tech</title>
    <description>The latest articles on DEV Community by Amer tech (@amer_tech).</description>
    <link>https://dev.to/amer_tech</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4011519%2F1926143e-e907-4e94-b06c-83acabcd2858.png</url>
      <title>DEV Community: Amer tech</title>
      <link>https://dev.to/amer_tech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amer_tech"/>
    <language>en</language>
    <item>
      <title>Stripe webhooks can work and your app access can still be wrong</title>
      <dc:creator>Amer tech</dc:creator>
      <pubDate>Thu, 02 Jul 2026 01:14:17 +0000</pubDate>
      <link>https://dev.to/amer_tech/stripe-webhooks-can-work-and-your-app-access-can-still-be-wrong-332d</link>
      <guid>https://dev.to/amer_tech/stripe-webhooks-can-work-and-your-app-access-can-still-be-wrong-332d</guid>
      <description>&lt;p&gt;Most Stripe billing bugs get described as webhook bugs.&lt;/p&gt;

&lt;p&gt;Did the event arrive? Did the signature verify? Did the handler return &lt;code&gt;200&lt;/code&gt;? Is the handler idempotent? Can we replay failed events?&lt;/p&gt;

&lt;p&gt;Those are good questions. But they miss another one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does the access state in your app match the billing state in Stripe right now?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That check is final-state reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;A common SaaS setup looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe is the billing source of truth.&lt;/li&gt;
&lt;li&gt;Your database decides who gets access.&lt;/li&gt;
&lt;li&gt;Webhooks and custom code keep the two in sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That works until it doesn't.&lt;/p&gt;

&lt;p&gt;A few normal ways it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer.subscription.deleted&lt;/code&gt; arrives during a deploy and the handler fails.&lt;/li&gt;
&lt;li&gt;The webhook handler returns &lt;code&gt;200&lt;/code&gt;, but the database write rolls back.&lt;/li&gt;
&lt;li&gt;Support manually enables or disables access in the admin panel.&lt;/li&gt;
&lt;li&gt;A migration changes plan/status fields and misses old rows.&lt;/li&gt;
&lt;li&gt;A customer cancels and later resubscribes, leaving multiple subscription records.&lt;/li&gt;
&lt;li&gt;Lazy sync only runs when someone opens the billing page, but the user keeps hitting API endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now Stripe says one thing and your app says another.&lt;/p&gt;

&lt;p&gt;There are two different problems here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unpaid but active&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Stripe says canceled, unpaid, or past due, but the app still grants access. This is usually silent. Nobody opens a support ticket to say they are still getting free compute.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Paid but blocked&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Stripe says active or paid, but the app blocks or downgrades the customer. This is urgent because the customer will probably notice before your cron does.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two cases should not be handled the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhook reliability is not the same check
&lt;/h2&gt;

&lt;p&gt;A reliable webhook pipeline asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did we receive the event?&lt;/li&gt;
&lt;li&gt;Did we process it once?&lt;/li&gt;
&lt;li&gt;Can we retry failed deliveries?&lt;/li&gt;
&lt;li&gt;Can we inspect what happened?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Final-state reconciliation asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What does Stripe say now?&lt;/li&gt;
&lt;li&gt;What does the app grant now?&lt;/li&gt;
&lt;li&gt;Do those states agree?&lt;/li&gt;
&lt;li&gt;If not, which side needs review?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You probably need both.&lt;/p&gt;

&lt;p&gt;Webhook infrastructure prevents a lot of failures. Reconciliation catches the ones that still escape. It also catches problems that never went through the webhook path: admin overrides, migrations, backfills, legacy status fields, and access logic that drifted over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a small reconciliation check needs
&lt;/h2&gt;

&lt;p&gt;You do not need a huge system to start.&lt;/p&gt;

&lt;p&gt;From Stripe, export or query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;customer ID&lt;/li&gt;
&lt;li&gt;subscription ID&lt;/li&gt;
&lt;li&gt;subscription status&lt;/li&gt;
&lt;li&gt;product or plan&lt;/li&gt;
&lt;li&gt;amount/MRR, if you want exposure estimates&lt;/li&gt;
&lt;li&gt;current period end / cancel-at-period-end, if relevant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From your app, export:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;internal user or workspace ID&lt;/li&gt;
&lt;li&gt;Stripe customer ID&lt;/li&gt;
&lt;li&gt;access flag or entitlement status&lt;/li&gt;
&lt;li&gt;plan/tier, if your app stores it&lt;/li&gt;
&lt;li&gt;any field your request path actually reads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters.&lt;/p&gt;

&lt;p&gt;If your middleware checks &lt;code&gt;access_enabled&lt;/code&gt;, your rate limiter checks &lt;code&gt;plan_tier&lt;/code&gt;, and your billing page checks &lt;code&gt;subscription_status&lt;/code&gt;, your first drift problem might be inside your own database.&lt;/p&gt;

&lt;p&gt;Start with the fields that actually grant or deny access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not auto-fix on day one
&lt;/h2&gt;

&lt;p&gt;It is tempting to auto-suspend every unpaid-but-active account.&lt;/p&gt;

&lt;p&gt;I would not start there.&lt;/p&gt;

&lt;p&gt;A first reconciliation job should usually flag, not fix. There are too many legitimate edge cases: trials, grace periods, dunning windows, enterprise comps, test accounts, manual support exceptions, and custom contracts.&lt;/p&gt;

&lt;p&gt;A safer first workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the comparison nightly or weekly.&lt;/li&gt;
&lt;li&gt;Split findings by direction.&lt;/li&gt;
&lt;li&gt;Treat paid-but-blocked as urgent.&lt;/li&gt;
&lt;li&gt;Put unpaid-but-active and ambiguous cases into review.&lt;/li&gt;
&lt;li&gt;Add notes for known exceptions.&lt;/li&gt;
&lt;li&gt;Only automate actions after you trust the classification.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first version should help you see drift, not create a new production incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built a small local-first prototype
&lt;/h2&gt;

&lt;p&gt;I built EntitleGuard to test this workflow as a free local audit.&lt;/p&gt;

&lt;p&gt;It compares:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a Stripe CSV export&lt;/li&gt;
&lt;li&gt;a minimal app users/workspaces CSV export&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison runs in the browser.&lt;/p&gt;

&lt;p&gt;No Stripe API key. No database credentials. No account. No upload.&lt;/p&gt;

&lt;p&gt;It flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unpaid-but-active&lt;/li&gt;
&lt;li&gt;paid-but-blocked&lt;/li&gt;
&lt;li&gt;missing billing links&lt;/li&gt;
&lt;li&gt;orphaned Stripe subscriptions&lt;/li&gt;
&lt;li&gt;ambiguous cases that need review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source is public, so the local-only claim is easy to inspect.&lt;/p&gt;

&lt;p&gt;Live audit:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://entitleguard.amertech.online/audit" rel="noopener noreferrer"&gt;https://entitleguard.amertech.online/audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/impara/EntitleGuard" rel="noopener noreferrer"&gt;https://github.com/impara/EntitleGuard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The product question I am testing now is whether this should stay as a one-time diagnostic or become recurring monitoring: nightly diff, alerting, review history, and an evidence trail for each mismatch.&lt;/p&gt;

&lt;p&gt;My guess is that most teams only care about this after they have seen drift once.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run a Stripe SaaS
&lt;/h2&gt;

&lt;p&gt;A practical first check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export active and non-active Stripe subscriptions.&lt;/li&gt;
&lt;li&gt;Export the app table that controls access.&lt;/li&gt;
&lt;li&gt;Join on &lt;code&gt;stripe_customer_id&lt;/code&gt; if you store it.&lt;/li&gt;
&lt;li&gt;Treat customer ID as more stable than subscription ID for access-level reconciliation.&lt;/li&gt;
&lt;li&gt;If a customer can have multiple subscriptions, rank by status instead of assuming one row.&lt;/li&gt;
&lt;li&gt;Keep the first version read-only.&lt;/li&gt;
&lt;li&gt;Review both directions separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a replacement for correct webhook handling.&lt;/p&gt;

&lt;p&gt;It is a backstop for the final state your users actually experience.&lt;/p&gt;

&lt;p&gt;If Stripe and your app disagree, the user does not care that the webhook pipeline looked healthy.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
