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

EventTypical cancellation meaningWhat to read on the object
customer.subscription.updatedScheduled cancel set or cleared; cancel_at changed; status still active or trialing in many casescancel_at_period_end, cancel_at, status, cancellation_details, current_period_end (field names depend on billing mode — verify in API reference)
customer.subscription.deletedSubscription ended and was deleted in StripeFinal snapshot; status canceled; ended_at
customer.subscription.createdNot a cancel event — useful when a “save” replaces the subscription itemCompare 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)

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 200

Sync 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

ScenarioRiskMitigation
Duplicate webhook deliveryDouble email, double analytics, flip-flopping accessEvent id deduplication
Out-of-order eventsDeleted processed before late updatedCompare event.created or retrieve latest subscription
Cancel UI succeeds but webhook delayedUser still sees paid features while Stripe already canceledOptimistic UI with short TTL + webhook reconciliation
Retention save after user clicked cancelRace between coupon update and scheduled cancelPer-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.