If Stripe says a subscription is canceled but your app still shows Pro, Stripe has not automatically failed to revoke anything. Stripe owns the billing subscription; your application owns its access record. A webhook or reconciliation job must carry the current subscription state into your database, and every authorization check must use that updated state.
The quickest diagnosis is to trace one subscription ID through five places: its current state in Stripe, the webhook event Stripe generated, the endpoint’s delivery result, the account row your handler changed, and any cache or token that can still report the old plan. The first point where the state stops changing is the broken link.
Why a canceled Stripe subscription can still show as active
Many generated Checkout integrations implement only the grant path:
if (event.type === 'checkout.session.completed') {
await db.users.update(event.data.object.client_reference_id, {
plan: 'pro',
});
}
That code can pass a purchase demo. It never answers what happens when the subscription changes later. Stripe sends subscription activity asynchronously, including changes made in the customer portal or Dashboard, so the application must process the relevant lifecycle events and update its own entitlement state.
A 2xx webhook response means the endpoint accepted a delivery. It does not prove that the handler recognized the event, found the correct local account, committed a database change, or invalidated a cached plan. A handler that ignores customer.subscription.deleted and returns 200 tells Stripe not to retry even though the user remains Pro.
This one-way shape is common enough to measure, and it is not limited to webhooks. Across the 21 third-party apps AxonBuild audited in June and July 2026, 10 wrote a client-supplied value straight to the database with no verification on the way in, prices and plan fields among them. A plan column nobody re-confirms against Stripe once it is written is the entitlement side of the same mistake: something gets trusted once and never checked again.
Cancellation is one leak in a larger set. The other ways a vibe-coded Stripe checkout leaks money share this shape: the path that grants access is wired, and the paths that take it back are not.
Scheduled cancellation and immediate cancellation are different states
“Canceled” is often used for two different moments:
| Stripe state or event | Typical access decision |
|---|---|
| cancel_at_period_end: true with an active or trialing subscription | Record the scheduled end; retain access until the agreed access boundary |
| customer.subscription.deleted with status: canceled | The subscription has ended; remove subscription-backed access |
| cancel_at_period_end changed back to false | Clear the pending cancellation; do not downgrade at the old date |
Setting cancel_at_period_end to true updates the subscription now but lets it continue to the end of the paid billing period. Stripe sends customer.subscription.updated for that change. The subscription does not become canceled merely because a cancellation is scheduled, and the customer can reverse the pending cancellation before the end.
When a subscription actually ends, Stripe sends customer.subscription.deleted. Stripe documents that event for both immediate cancellation and a scheduled cancellation reaching its end. The event’s subscription object has status: 'canceled'; that is the state an application normally uses to remove subscription-backed access.
A pending cancellation is a future billing decision. A canceled subscription is a current access-state change.
Do not revoke immediately just because cancel_at_period_end became true unless your customer agreement explicitly says access ends at that click. Doing so can remove time already paid for. Conversely, do not leave an ended subscription mapped to a permanent plan: 'pro' row.
The cancellation event reached Stripe but not your database
Open the subscription in Stripe and copy its sub_... ID. Then inspect the event destination’s delivery history for customer.subscription.updated or customer.subscription.deleted carrying that ID.
There is no matching event delivery
Confirm that the live webhook destination is enabled and subscribed to the event type, and that you are looking at the same Stripe environment as the subscription. A sandbox event, live subscription, and Stripe CLI listener each belong to different delivery contexts. Also check whether the cancellation is only scheduled; in that case the deletion event has not happened yet.
Stripe shows a failed delivery
Use the recorded HTTP status, delivery attempts, and application logs to find the failure. Stripe retries failed live deliveries for up to three days, but a disabled or deleted destination can still leave a gap. Fix the receiver and resend the event, then verify the local state rather than stopping at a 2xx response.
Stripe shows a successful delivery
The gap is now inside the application. Common causes are:
- no branch for
customer.subscription.deleted; - the endpoint subscribed to the event but returned before the database write;
- lookup by email instead of stable Stripe customer or subscription ID;
- a database error caught and hidden behind a successful response;
- a plan update in one table while authorization reads another;
- a stale session, JWT claim, edge cache, or billing-provider cache still saying Pro;
- an older event overwriting newer state because the handler assumes delivery order.
That hidden-error shape is the webhook version of an app failing silently behind 200 OK. None of this replaces checking that the webhook verifies Stripe’s signature in the first place; that verification is a different check entirely.
Sync subscription state instead of writing one-way plan flags
A durable model stores the Stripe customer ID, subscription ID, current subscription status, pending-cancellation fields, relevant product, and the local access decision. It does not treat checkout.session.completed as a permanent instruction to set Pro.
For subscription lifecycle events, use one reconciliation function:
async function handleSubscriptionEvent(event) {
const incoming = event.data.object;
// A deletion is definitive for this subscription ID. For created or updated
// events, retrieve current state so an older delivery cannot overwrite newer state.
const subscription = event.type === 'customer.subscription.deleted'
? incoming
: await stripe.subscriptions.retrieve(incoming.id);
await db.transaction(async (tx) => {
const firstProcessing = await tx.stripeEvents.insertOnce(event.id);
if (!firstProcessing) return;
await syncSubscriptionAndAccess(tx, subscription);
});
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleSubscriptionEvent(event);
break;
}
The example is a pattern, not drop-in schema code. syncSubscriptionAndAccess must encode your product’s access policy: which Stripe statuses grant access, how trials work, whether a grace period exists, and what local features each product enables. Stripe’s subscription webhook guide recommends listening to created, updated, and deleted events and checking the subscription’s current status before provisioning access.
Stripe does not guarantee event delivery order and can deliver an Event more than once. Retrieving current state helps with out-of-order deliveries; recording event.id prevents one delivery from repeating the same side effect. If your handler triggers additional Stripe API writes, use idempotency keys for those outbound requests as well. Webhook deduplication and API idempotency solve different problems.
After the database commit, invalidate every derived access representation. If a JWT contains plan: 'pro' until it expires, the database may be correct while the interface and authorization layer remain wrong. Prefer short-lived claims or server-side authorization checks for privileges that must end promptly.
Use the correct cancellation date for your Stripe API version
Older integrations often read subscription.current_period_end. Starting with Stripe’s 2025-03-31.basil API version, billing-period start and end fields moved from the Subscription to its individual Subscription Items. Current code may therefore need subscription.items.data[].current_period_end, and subscriptions with multiple items can have more than one period end.
For a scheduled cancellation, store Stripe’s explicit cancel_at when present and define which item boundary controls access. Do not silently take the first item in a multi-item subscription. Stripe’s current cancellation documentation also provides minimum- and maximum-period helpers for flexible billing cases; choose the boundary that matches what you sold.
This field change is why a fallback job should reconcile subscription state, not merely compare an old top-level timestamp copied from a tutorial.
Add reconciliation for missed or stale webhook state
Webhooks should update access quickly, but they should not be the only way your application can discover reality. Run a scheduled reconciliation that selects locally active subscriptions whose cancellation date has passed, whose state is stale, or whose last webhook processing failed. Retrieve their current Stripe state and run the same syncSubscriptionAndAccess function.
The job should report mismatches before or while fixing them, retain enough identifiers for investigation, and avoid granting access solely because Stripe could not be reached. Its exact failure policy depends on how critical the product is and what your customer agreement promises.
If you use Stripe Entitlements, Stripe exposes entitlements.active_entitlement_summary.updated specifically for provisioning and de-provisioning features. If you maintain your own access tables, the reconciliation loop remains your responsibility. Either model still needs stable customer/subscription mapping and cache invalidation.
Refunds and disputes are not cancellation events
I audited a multi-tenant B2B SaaS starter this year that sold access through a token-credit system: buy a pack, spend the credits on the product’s AI features. Its Stripe integration granted the credits on checkout and did nothing on the way back: no refund handler, no dispute handler, nothing. A customer could buy a pack, spend a few of the credits, ask Stripe for the money back, and keep spending what was left. Stripe closed the loop on its side the moment the refund posted. The app never asked Stripe again, so from its own point of view, nothing had changed.
A refund does not automatically cancel a subscription, and a cancellation does not automatically refund the customer. Stripe now recommends refund.created for refund details; charge.refunded can represent a partial refund. A dispute can be open, won, or lost. None of those facts alone defines what your application promised about continued subscription access.
Write a separate refund and dispute policy that covers full versus partial refunds, consumable credits, already-delivered service, dispute status, and whether the subscription itself should be canceled. Then implement and test those branches. Do not add “revoke on every charge.refunded” to a cancellation handler: a partial refund would trigger it too, and the charge-to-subscription relationship still has to be resolved.
This article owns the scheduled-to-ended subscription lifecycle. Granting and revoking entitlements across every payment event is broader than the cancellation query and needs its own model.
A different failure produces a similar complaint: free users reaching premium features that were never granted at all. That is paywall enforcement rather than cancellation state, and the checks that find it are different ones.
Test the complete cancellation path
Use subscriptions from a Stripe testing environment tied to real rows in your test database, not only synthetic event payloads.
- 01 Create a test subscription and record its customer ID, subscription ID, local account ID, current Stripe status, and local access state
- 02 Schedule cancellation at period end and confirm customer.subscription.updated is delivered, cancel_at_period_end is stored, and access remains available
- 03 Reverse that pending cancellation and confirm the stored cancel date is cleared so no delayed job downgrades the customer later
- 04 On a separate test subscription, cancel immediately and confirm customer.subscription.deleted changes the database and every authorization surface
- 05 Resend the same deletion event and confirm the handler records one logical transition without duplicate side effects
- 06 Deliver relevant events out of order or retrieve current state during the handler, then confirm an older update cannot restore access
- 07 Disable or bypass the webhook in a controlled testing-environment test and confirm scheduled reconciliation finds the mismatch
- 08 Repeat against the production configuration before launch using a controlled account and the policy your business actually offers
Run this against a test key; moving the same setup from test mode to a live key safely is a separate question. The Stripe CLI can create fixtures for trigger commands, so make sure the subscription ID in the event is actually mapped to the local account you are observing. Testing a throwaway Stripe object while watching a different database row produces a false failure.
The cancellation lifecycle is deliberately narrower than the full payment-entitlement system. Entitlements can drift for reasons beyond cancellation alone, and the full grant, revoke, replay, and enforce loop deserves the same event-by-event care as the paths above.
Common questions about Stripe subscription cancellation
Why is my cancelled subscription still active?
Either the cancellation is only scheduled, or your application never recorded that it ended. With cancel_at_period_end set to true, the subscription stays active through the paid period by design, so an active status is correct until that period ends. If Stripe already reports status: 'canceled', the subscription has ended and your own access record did not follow: trace the customer.subscription.deleted delivery, the account row the handler should have changed, and any session or cached claim still carrying the old plan.
Does canceling a subscription in Stripe revoke access automatically?
No. Stripe changes the subscription in Stripe. Your webhook handler, Stripe Entitlements integration, or reconciliation job must update the access decision your application enforces.
What is the difference between cancel_at_period_end and canceling immediately?
cancel_at_period_end: true schedules cancellation while the subscription normally remains active through the paid period. Immediate cancellation ends it now. Stripe sends customer.subscription.updated when the scheduled flag changes and customer.subscription.deleted when the subscription actually ends.
Can a canceled subscription be reactivated, and what should access do?
A pending cancellation can be reversed; a finished one cannot. Stripe documents that you stop a scheduled cancellation by setting cancel_at_period_end back to false, at any time up to the end of the period, and that a subscription which has already been canceled cannot be reactivated: the customer needs a new subscription. So access should continue uninterrupted when the pending cancellation is cleared, with the stored cancel date removed so no delayed job downgrades the account later, and it should be granted again against the new subscription ID when someone returns after the old one ended.
Which Stripe event should remove subscription access?
For a Stripe Billing subscription, customer.subscription.deleted signals that the subscription ended. Reconcile the subscription’s current status and your product policy, update local access idempotently, and invalidate cached claims. If Stripe Entitlements controls features, process its active-entitlement summary event as documented.
Why did the event deliver successfully while the user stayed Pro?
A successful HTTP delivery does not prove the business update ran. Check the event branch, stable ID mapping, transaction result, plan source used by authorization, and any session or cache containing the old plan.
Should a refund always remove subscription access immediately?
No universal Stripe rule says that. Refunds can be partial or can later fail, and issuing one does not cancel the subscription. Define the commercial policy first, listen to the current refund events you need, map the payment to the correct account, and change subscription access only when that policy requires it.
When the app carries customer access or revenue
AxonBuild fixes the payment, billing, access, or data-handling failure, verifies the result, and adds a check that catches it before it interrupts the business again.