Guide
Stripe Subscription Cancellation Webhooks: Events, Timing, and State Sync
When a customer cancels a Stripe subscription, your product’s source of truth is not the moment they click “Confirm cancel.” It is the subscription object in Stripe plus the webhook events Stripe delivers afterward. If your app grants or revokes access from a local flag set in the cancel UI, you will eventually disagree with Stripe — usually after a retry, a scheduled cancel, or a Customer Portal session you did not initiate.
This guide is for engineers implementing voluntary cancellation in Stripe Billing. It focuses on customer.subscription.updated and customer.subscription.deleted: what changes on each event, how immediate cancellation differs from cancel-at-period-end, and how to process events idempotently. For the full flow (portal vs custom, API calls, retention actions), see how to build a Stripe cancellation flow. For local webhook forwarding, see test a Stripe cancellation flow locally.
Last updated October 13, 2026
Why cancellation needs webhook reconciliation
Stripe may apply a cancellation from your API, from the Dashboard, from the Customer Portal, or from a scheduled job when cancel_at is reached. Your cancel button is only one entry point. Webhooks are how you observe the authoritative outcome.
Treat the Subscriptions API response from your cancel endpoint as a hint, not the final state. Network retries, concurrent updates (for example a retention coupon applied in the same second), and portal sessions can produce additional customer.subscription.updated events before customer.subscription.deleted arrives.
Which webhook events matter for cancellation
| Event | Typical cancellation meaning | What to read on the object |
|---|---|---|
| customer.subscription.updated | Scheduled cancel set or cleared; cancel_at changed; status still active or trialing in many cases | cancel_at_period_end, cancel_at, status, cancellation_details, current_period_end (field names depend on billing mode — verify in API reference) |
| customer.subscription.deleted | Subscription ended and was deleted in Stripe | Final snapshot; status canceled; ended_at |
| customer.subscription.created | Not a cancel event — useful when a “save” replaces the subscription item | Compare subscription id if you recreate subscriptions on plan change |
Stripe documents additional subscription-related events (for example invoice events). This guide stays on subscription lifecycle events that directly change cancel state.
Immediate cancel vs cancel at period end on webhooks
Per Stripe's cancel subscriptions guide, you can cancel immediately or at the end of the current billing period. The webhook sequence differs.
Cancel at period end (scheduled)
- First signal is usually customer.subscription.updated with cancel_at_period_end: true (classic behavior) or a future cancel_at timestamp depending on how you called the API and your billing mode
- Access: many SaaS products keep entitlements until current_period_end even though the customer has already “canceled”
- Final transition: when the period ends, expect customer.subscription.deleted (or an updated event with status canceled before deletion — always branch on the latest retrieved object if unsure)
Immediate cancel
- Stripe ends the subscription promptly; you typically receive customer.subscription.deleted soon after a successful cancel API call or portal action
- Do not wait for period end to revoke access unless your product policy explicitly allows grace access
- If you use proration or invoice adjustments, related invoice events may arrive in the same window — handle subscription state first, then reconcile invoices separately
Billing mode and field availability
Stripe’s billing mode comparison documents differences between classic and flexible billing behavior, including how period boundaries and cancellation timestamps are represented. Before you hard-code checks like “cancel_at_period_end only,” confirm which fields your account’s subscriptions expose in test mode.
Designing a cancellation-aware webhook handler
Verify signatures on the raw body
Per Stripe webhooks documentation, verify Stripe-Signature against the raw request body with your endpoint secret before parsing JSON or touching your database.
Deduplicate by event id
Stripe retries webhooks. Store processed event ids (or use a durable idempotency layer) so duplicate deliveries do not double-revoke access or send duplicate lifecycle emails.
This is separate from idempotency keys on API requests, which protect your outbound cancel calls — you need both.
on webhook POST:
rawBody = read raw bytes
event = stripe.webhooks.constructEvent(rawBody, signature, secret)
if already_processed(event.id):
return 200
switch event.type:
case customer.subscription.updated:
sync_subscription(event.data.object)
case customer.subscription.deleted:
mark_subscription_canceled(event.data.object)
mark_processed(event.id)
return 200Sync strategy: event payload vs retrieve
- Fast path: update from event.data.object when fields you need are present
- Safe path: on critical transitions (deleted, or cancel scheduled), retrieve the subscription from Stripe and merge with your row
- Always key internal state by subscription id and customer id; portal cancellations use the same ids as API cancellations
Mapping webhook fields to product access
A common bug is revoking access on customer.subscription.updated whenever cancel_at_period_end is true. That frustrates customers who paid through the period. Instead, define explicit entitlements:
- active and not scheduled to cancel → full access
- active with scheduled cancel → full access until period end (if that is your policy)
- canceled or deleted subscription → no paid access
Involuntary churn (failed payments) uses different signals. See the voluntary vs involuntary churn guide for scope boundaries.
cancellation_details on subscription events
Reason and feedback from the Customer Portal or from your API may appear under cancellation_details on the subscription object carried by customer.subscription.updated. Feedback can arrive on a later update after the customer submits the portal form. See Stripe cancellation reasons and the API for persistence patterns.
Failure modes to test deliberately
| Scenario | Risk | Mitigation |
|---|---|---|
| Duplicate webhook delivery | Double email, double analytics, flip-flopping access | Event id deduplication |
| Out-of-order events | Deleted processed before late updated | Compare event.created or retrieve latest subscription |
| Cancel UI succeeds but webhook delayed | User still sees paid features while Stripe already canceled | Optimistic UI with short TTL + webhook reconciliation |
| Retention save after user clicked cancel | Race between coupon update and scheduled cancel | Per-subscription locking or last-write-wins with Stripe retrieve |
Need an embedded cancellation flow on Stripe?
ChurnIntel provides an in-app voluntary cancellation widget with Stripe Connect — reason capture, retention actions, and outcome tracking without building the full UI from scratch.