LR-07

They Cancelled. They Still Have Access. You're Losing Money.

A customer cancels their subscription. Stripe processes the cancellation. The customer still has full premium access in your app. Tomorrow. Next week. Next month.

This is not a billing display bug. The app genuinely believes the user is still a paying subscriber. They can use every premium feature, consume resources, and generate costs — while paying nothing.

The opposite also happens: a customer is paying, but your app has revoked their access because it mishandled a billing event. They're being charged monthly for a product they can't use.

Both directions of this problem — cancelled users with access and paying users without access — stem from the same root cause: the app's subscription state has drifted from Stripe's reality. And in AI-generated apps, subscription lifecycle handling is one of the most consistently broken billing patterns.


Who This Is For

  • Founders whose SaaS has subscription-based billing through Stripe
  • Developers who handle Stripe webhook events for subscription changes but aren't confident all lifecycle events are covered
  • Teams that have noticed discrepancies between Stripe subscription counts and active user counts in their app
  • Anyone who implemented Stripe subscriptions with AI tools and hasn't verified the cancel/expire/fail flows

If your app only handles checkout.session.completed and doesn't process subscription update, deletion, and payment failure events, your billing state will drift over time.


What Founders Experience

  • Revenue looks healthy, but doesn't match access. The Stripe dashboard shows 50 active subscribers. The app shows 65 users with premium access. The 15 extra users cancelled in Stripe but were never downgraded in the app.
  • The drift is silent. No errors. No alerts. The app simply never received (or never processed) the event that should have revoked access. The user continues using the product. The founder has no signal that anything is wrong.
  • Paying users lose access. A payment fails. Stripe sends invoice.payment_failed. The app doesn't handle it — or handles it by immediately revoking access instead of giving the user a grace period. The customer is locked out, even though Stripe hasn't cancelled the subscription yet.
  • Discovery is manual. The only way to find the drift is to compare Stripe subscription data against your database, row by row. There's no automatic reconciliation in most AI-generated apps.
  • Involuntary churn compounds. Research suggests that 20–40% of all SaaS churn is involuntary — caused by failed payments, card expirations, and billing processing issues, not by dissatisfied customers. If your app doesn't handle these events correctly, you're losing customers who want to keep paying.

What's Actually Happening

Stripe subscription lifecycle is more complex than most AI-generated handlers account for. A subscription doesn't just "start" and "cancel." It transitions through multiple states, each generating different events.

Three failure modes cause state drift:

1. Only Handling the Happy Path

The most common pattern. AI generates a webhook handler that processes checkout.session.completed — the event that fires when a new customer subscribes. It correctly activates access.

But the handler ignores:

  • customer.subscription.updated — fires when a subscription changes (plan change, cancel at period end, payment method update)
  • customer.subscription.deleted — fires when a subscription is actually terminated
  • invoice.payment_failed — fires when a renewal payment fails
  • invoice.payment_succeeded — fires when a renewal payment succeeds (important for reactivation after failed payment)

Without these handlers, your app knows when someone subscribes but doesn't know when they cancel, downgrade, or fail to pay.

2. Confusing Cancel Types

Stripe has two cancellation modes, and they generate different events:

Immediate cancellation fires customer.subscription.deleted. The subscription is terminated now.

Cancel at period end fires customer.subscription.updated with cancel_at_period_end: true. The subscription remains active until the current billing period ends. Only then does customer.subscription.deleted fire.

Apps that only listen for customer.subscription.deleted miss the entire cancel-at-period-end flow. The user cancels, the app never processes the update, and access continues indefinitely after the paid period ends.

3. No Recovery Path for Failed Payments

When a payment fails, Stripe doesn't immediately cancel the subscription. It retries according to your Smart Retry settings. During the retry period, the subscription is in a past_due state — the customer hasn't cancelled, and the subscription hasn't ended.

If your handler revokes access on invoice.payment_failed, you're locking out customers during Stripe's retry window — before the subscription is actually cancelled. If your handler doesn't process payment failures at all, you're giving access to users whose payments have permanently failed.


What This Puts at Risk

Revenue leakage. Cancelled users with active access consume server resources, API quotas, and support bandwidth — without paying. For usage-based or resource-intensive products, this is a direct and growing cost.

Metric corruption. Active user counts that don't match subscriber counts make every business metric unreliable: churn rate, LTV, CAC payback, engagement per paid user. Investor reporting becomes fiction.

Customer dissatisfaction. Paying users who lose access due to mishandled payment events are some of the most frustrated customers. They want to pay. They are paying. And they're locked out because the app revoked access before Stripe finished retry.

Ghost subscription accumulation. One audit of 847 Stripe accounts found that 94% had some form of revenue leakage — from subscriptions stuck in past_due states, expired coupons still applied, or access states that had drifted from Stripe reality. The reported average was significant enough to impact unit economics.


How Trust Score Detects It

BIL-16: Webhook-only fulfillment. Checks that subscription state changes flow through verified webhook events — not through client-side state, success URLs, or manual database writes. This confirms the sync mechanism exists: if all access decisions come from Stripe events processed through your webhook handler, the foundation for staying synchronized is in place.

This check verifies the mechanism, not the completeness of all lifecycle branches. Whether your handler processes every relevant event (cancellations, payment failures, plan changes) requires deeper review in a Launch Readiness Assessment.


Real Incidents

Three months of zero revenue from silent webhook failure. A founder had demand and users signing up, but zero revenue for three months. The cause: a test-mode webhook secret was in the production .env. Every constructEvent() call threw silently (the catch block returned 200). Users paid in Stripe, but the app showed them as free-tier. Payments were silently failing to activate access.

47 subscriptions failed to renew — nobody noticed for 3 days. A deployment changed the webhook route from /api/webhooks/stripe to /api/v2/webhooks/stripe. Stripe hit the old URL, got 404s, retried for 72 hours, then disabled the endpoint. "Refund requests, support tickets, and a painful weekend of manually reconciling payments."

Rs 3.5 Lakh MRR evaporated in 48 hours. A company with Rs 75 Lakhs MRR had inverted payment status logic — successful payments were routed into the failure handler. Hundreds of users were auto-churned. The bug had been dormant since a code refactor weeks earlier. "Within 48 hours, over Rs 3.5L worth of MRR evaporated."

"My app broke, and I don't know why." A non-technical founder who built with AI tools described the experience: "Even when a user successfully subscribes and the subscription status is correctly saved in Supabase, my application fails to recognize it." The disconnect between Stripe and the app was invisible to the founder.

First real sale, payments broken. A founder tested their payment integration in test mode but never set up live webhooks. On launch day, a real customer with a real card tried to pay. It didn't work. "I had minutes. I fixed it on the fly, hands shaking, deployed, refreshed."


Detection: How to Check Your Own App

Check 1: Which events does your handler process?

Search your webhook handler for the events it listens for:

grep -r "checkout.session.completed\|customer.subscription\|invoice.payment" --include="*.ts" --include="*.tsx" --include="*.js" | grep -v node_modules

Interpretation: A complete subscription lifecycle handler should process at minimum:

  • checkout.session.completed (new subscription)
  • customer.subscription.updated (plan changes, cancel at period end)
  • customer.subscription.deleted (subscription terminated)
  • invoice.payment_failed (renewal failure)
  • invoice.payment_succeeded (renewal success / reactivation)

If your handler only processes checkout.session.completed, it knows when subscriptions start but not when they end.

Check 2: Compare Stripe vs app subscription counts

# In your database: count users with active subscription status
# In Stripe Dashboard: Subscriptions → Active

# Compare the two numbers.
# If your app shows MORE active subscribers than Stripe, cancelled users still have access.
# If your app shows FEWER, paying users may have lost access.

Interpretation: Any significant discrepancy indicates state drift. The direction of the drift tells you which events are missing from your handler.

Check 3: Test both cancellation flows

Create a test subscription, then test both cancellation paths in the Stripe Dashboard:

  1. Immediate cancellation: Cancel the subscription immediately. Check your app — does the user lose premium access right away?
  2. Cancel at period end: Create another test subscription, cancel it with "at end of period." Check your app — does it reflect the pending cancellation? After the period ends, does access get revoked?

Interpretation: If the user retains access after immediate cancellation, your handler doesn't process customer.subscription.deleted. If your app doesn't reflect cancel-at-period-end status, it doesn't process customer.subscription.updated with cancel_at_period_end: true. Both paths must work.


Related Launch Risks


FAQ

Our app handles checkout.session.completed. Isn't that enough?

No. That event tells you when a subscription starts. It tells you nothing about cancellations, payment failures, plan changes, or subscription expirations. Without handling lifecycle events, your app knows when to grant access but not when to revoke it.

What's the difference between customer.subscription.deleted and cancel at period end?

When a customer cancels immediately, Stripe fires customer.subscription.deleted. When they cancel at the end of the billing period (the more common case), Stripe fires customer.subscription.updated with cancel_at_period_end: true. The deleted event only fires later, when the period actually ends. If your handler only listens for deleted, it misses the cancel-at-period-end window entirely.

Should we revoke access immediately when a payment fails?

Generally no. Stripe retries failed payments according to your Smart Retry settings. During the retry window, the subscription is past_due, not cancelled. Revoking access immediately locks out customers who may successfully pay on the next retry. A better approach: show a warning on failed payment, and revoke access only when the subscription moves to cancelled or unpaid status.

How do we reconcile our database with Stripe?

Periodically query Stripe's API for active subscriptions and compare against your database. Any user with premium access in your app but no active subscription in Stripe should be investigated. Stripe's Customer Portal and Billing Portal webhooks can also help keep state synchronized.

Can this happen if we use Stripe's hosted customer portal?

Yes. The customer portal handles the Stripe side of cancellations, but your app still needs to process the resulting webhook events. If your handler doesn't process customer.subscription.updated and customer.subscription.deleted, the customer can cancel through the portal and your app will never know.


Is This Happening in Your App?

Run a free Trust Score scan — 24 safety checks across auth, billing, and admin. Results in seconds.