Guide

How to Build a Stripe Cancellation Flow

When a SaaS customer clicks “Cancel subscription,” you need to decide whether to send them through Stripe’s Customer Portal or handle the cancellation through your own flow. By default, your app either redirects to Stripe’s Customer Portal or calls the Subscriptions API directly. A Stripe cancellation flow is the voluntary path between that cancel intent and the final subscription state change in Stripe — including, when you choose, reason capture, an optional save step, and outcome tracking on your side.

This guide covers how to implement that path in Stripe. If you need help deciding what the experience should contain — which steps to show, what to ask, or which offers fit which reasons — start with ChurnIntel’s Cluster #1 guides on cancellation flow examples, cancellation survey questions, and retention offer strategy. This article stays in the implementation lane: portal vs custom architecture, API wiring, webhooks, and state sync.

Scope note: This guide covers voluntary cancellation only. Failed-payment recovery, dunning, and involuntary churn are out of scope. See voluntary vs involuntary churn for how to classify churn types and route readers appropriately.

Last updated October 1, 2026

What a Stripe cancellation flow actually is

A cancellation flow is not the same as calling DELETE /v1/subscriptions/{id}. At minimum, a flow is:

  • Customer expresses cancel intent in your product
  • Your system records context (optionally a reason)
  • Your backend updates Stripe (cancel now, schedule cancel, apply a save, or redirect to portal)
  • Your app reconciles subscription state via webhooks and internal records

Minimum viable vs retention-aware paths

For step-by-step examples of what belongs in a retention-aware flow, see cancellation flow examples. This guide explains how to wire those steps to Stripe.

PathWhat happensWhat you typically build
Minimum viableCancel button → Stripe API or portal → donePortal redirect or one server endpoint
Retention-awareCancel button → reason → optional offer → Stripe action → outcome loggedCustom in-app UI + server endpoints + webhook handler

What Stripe handles

  • Subscription lifecycle state (active, canceled, scheduled cancel via cancel_at_period_end, etc.)
  • Invoice generation and payment collection rules
  • Hosted Customer Portal UI (if you use the portal path)
  • Webhook event delivery for subscription changes

What you build

  • In-app intercept UI (or portal redirect/deep link entry)
  • Reason capture and storage (your database, or portal configuration)
  • Offer presentation and acceptance logic (custom path only)
  • Server-side API calls that mutate subscriptions
  • Webhook processing and idempotent event deduplication
  • Internal outcome records for analytics

Customer Portal vs custom in-app cancellation

Most Stripe SaaS products start with one of three patterns:

Portal path:     [Cancel in app] → redirect to Customer Portal → Stripe-hosted cancel
Custom path:     [Cancel in app] → your modal/page → your server → Stripe API
Hybrid path:     [Cancel in app] → your intercept → portal deep link to cancel flow

Portal capabilities

Stripe’s Customer Portal can handle subscription cancellation when enabled in Dashboard settings. Per Stripe’s cancellation page documentation:

  • Cancellation is enabled by default in the portal
  • You can collect a cancellation reason from a fixed list (plus optional free text for “Other reason”)
  • You can configure a retention coupon to deflect cancellations (one coupon for the portal cancellation flow)
  • Cancellation timing in the portal depends on your portal configuration and billing mode — verify current behavior in Dashboard and Stripe’s cancellation page docs

Portal limitations relevant to custom flows

CapabilityCustomer PortalCustom in-app flow
Branded in-product UXHosted Stripe UIFull control
Per-reason dynamic offersLimited — retention coupon deflection, not reason-routed offer logicFull control via your server
Pause / plan switch before cancelVerify current portal settings; custom flows use API (pause_collection, item updates)API-driven
Outcome/session analyticsPortal reason data via subscription/webhooks; less session contextYour DB + Stripe state
Engineering effortLowHigher

Portal behavior can change. Verify limitations against current Stripe Customer Portal documentation before relying on them in production.

When portal may be enough

  • You have a simple subscription model
  • Collecting a standard reason list is sufficient
  • A single retention coupon deflection meets your save strategy
  • You want the fastest path to compliant self-service cancel with minimal engineering

When a custom in-app flow may make sense

Again, what to show at each step is a product decision covered in Cluster #1. This guide covers how to connect your UI to Stripe.

  • You need per-reason offer routing (pause vs coupon vs plan switch) before cancel
  • You want a branded cancel experience inside your product
  • You need session-level outcome tracking tied to your analytics model

Step 1 — Intercept the cancel action

Replace a naive “redirect straight to portal” or “call cancel API immediately” pattern with a deliberate entry point:

  • Settings page: “Cancel subscription” opens your flow instead of mutating Stripe directly
  • Portal hybrid: Show a brief in-app step, then deep-link to portal cancel if the customer still wants to leave
  • Embedded widget: A third-party or in-house script hooks the cancel button (e.g. showCancelFlow() pattern)

Keep UX guidance short. For flow structure and anti-dark-pattern principles, see cancellation flow examples.

Step 2 — Capture the cancellation reason

Custom flow

Store the reason in your database at flow time:

cancel_sessions
  id
  user_id
  stripe_subscription_id
  reason_code        -- e.g. "too_expensive"
  reason_comment     -- optional free text
  created_at

Link the session to the Stripe subscription ID so you can join flow data to billing state later.

For what to ask and how to phrase survey options, see cancellation survey questions. For reason taxonomies and playbooks, see SaaS cancellation reasons. This section covers only storage and wiring.

Portal flow

Configure reasons in Stripe Dashboard → Settings → Customer portal → Cancellations. Stripe documents the available reason list in the cancellation page guide. Reason data can appear on the subscription and via customer.subscription.updated webhooks — verify field availability in current Stripe API references for cancellation_details.

Step 3 — Optional retention step (Stripe API wiring)

If the customer accepts a save offer, your server applies the corresponding Stripe change before canceling. If they decline, proceed to Step 4.

Which offer to show is a strategy question, not an implementation question. See retention offer strategy for coupon vs pause vs plan-switch decisions. Below is how to apply each type in Stripe.

Apply a coupon to the subscription

Create coupons in Stripe, then apply them to an existing subscription via the Subscriptions API. Per Stripe’s coupons documentation, discounts are applied by creating a Discount on the subscription — commonly via a discounts parameter when creating or updating a subscription:

curl https://api.stripe.com/v1/subscriptions/$SUBSCRIPTION_ID \
  -u "$STRIPE_SECRET_KEY:" \
  -d "discounts[0][coupon]=$COUPON_ID"

Only call this after the customer accepts the offer in your UI. Verify parameter names against the current Subscriptions API reference before shipping.

Pause collection instead of cancel

To temporarily stop collecting payment while keeping the subscription active, use pause_collection on subscription update. Per Stripe’s pause payment collection documentation:

  • The subscription remains active
  • Invoices may still be created depending on pause_collection[behavior] (keep_as_draft, mark_uncollectible, or void)
  • Payment collection resumes when you unset pause_collection or when resumes_at is reached
curl https://api.stripe.com/v1/subscriptions/$SUBSCRIPTION_ID \
  -u "$STRIPE_SECRET_KEY:" \
  -d "pause_collection[behavior]=keep_as_draft"

Pausing collection is not the same as canceling. Do not treat a paused subscription as churn in your internal analytics without explicit business rules.

Switch plan or downgrade

Update subscription items to a different price ID via subscriptions.update. Proration behavior depends on your subscription settings and update parameters — verify against Stripe’s update subscription documentation before implementing.

If the customer declines — proceed to cancel

After a fair offer is shown and declined, proceed to cancellation without adding unnecessary friction. UX principles for that handoff live in cancellation flow examples.

Step 4 — Cancel at period end vs immediate cancellation

Stripe supports two primary voluntary cancel mechanics. Neither is universally correct — choose based on your cancellation policy and customer experience.

Period-end cancellation (cancel_at_period_end)

Schedule cancellation at the end of the current billing period:

curl https://api.stripe.com/v1/subscriptions/$SUBSCRIPTION_ID \
  -u "$STRIPE_SECRET_KEY:" \
  -d cancel_at_period_end=true

Per Stripe’s cancel subscriptions documentation:

  • Setting cancel_at_period_end=true lets the subscription complete the period the customer already paid for
  • Stripe sends customer.subscription.updated when the scheduled cancel is set
  • When the period ends, Stripe sends customer.subscription.deleted
  • Practical differences:
  • Customer typically retains access through the paid period
  • Billing relationship continues until period end
  • You can reverse the scheduled cancel by setting cancel_at_period_end=false before the period ends (per Stripe docs)

Situations where teams may choose this: prepaid-period access policies, support workflows that need time to respond, or product models where immediate cutoff is not desired.

Immediate cancellation

Cancel the subscription right away via the Subscriptions API delete endpoint:

curl -X DELETE "https://api.stripe.com/v1/subscriptions/$SUBSCRIPTION_ID" \
  -u "$STRIPE_SECRET_KEY:"

Per Stripe’s cancel subscriptions documentation:

  • By default, cancellation takes effect immediately
  • After cancellation, the subscription is largely immutable except for metadata and cancellation_details
  • Stripe sends customer.subscription.deleted
  • Proration and refund behavior are configurable — verify options (prorate, invoice_now, etc.) in current API reference; do not assume defaults
  • Practical differences:
  • Access revocation can happen immediately (depending on how your app maps subscription status)
  • No remaining paid period on the subscription
  • Reversal requires creating a new subscription — you cannot “un-cancel” a deleted subscription

Situations where teams may choose this: explicit immediate-revoke policies, abuse or fraud handling, or regulatory requirements for prompt termination.

Choosing between period-end and immediate cancel

Portal path: Cancellation timing in Customer Portal is configured in Dashboard portal settings. Verify against Stripe’s cancellation page documentation.

DimensionPeriod-end (cancel_at_period_end)Immediate (DELETE subscription)
API patternsubscriptions.update with cancel_at_period_end=truesubscriptions.cancel / DELETE
Typical accessThrough current period (your app must enforce)Can end immediately
Primary eventssubscription.updated, then subscription.deleted at period endsubscription.deleted
ReversalSet cancel_at_period_end=false before period endsNot reversible — new subscription required
Policy ownerProduct / legal / support — not this guide

Reversing a scheduled cancellation

To stop a pending period-end cancel before the billing period ends:

curl https://api.stripe.com/v1/subscriptions/$SUBSCRIPTION_ID \
  -u "$STRIPE_SECRET_KEY:" \
  -d cancel_at_period_end=false

Per Stripe, you cannot reactivate a subscription that has already been fully canceled.

Step 5 — Handle Stripe webhooks

Subscription cancel flows are asynchronous. Your app must listen for Stripe events, not rely only on the synchronous API response.

Events that matter for cancellation

Per Stripe’s cancel subscriptions documentation:

EventWhen it fires (cancel context)
customer.subscription.updatedAny subscription update — including when cancel_at_period_end is set to true
customer.subscription.deletedSubscription is canceled — immediately or when a scheduled period-end cancel completes

For period-end cancels, subscription.updated fires when the cancel is scheduled; subscription.deleted fires when the subscription actually ends. If your app only listens for deleted, you may show incorrect access state during the remaining paid period.

Do not expand this guide into invoice.payment_failed or dunning handlers. Voluntary vs involuntary boundaries are covered in voluntary vs involuntary churn.

Webhook signature verification

Verify every webhook with your endpoint signing secret. Per Stripe’s webhooks documentation, use stripe.webhooks.constructEvent (or equivalent) with the raw request body, Stripe-Signature header, and your whsec_ secret. Reject requests that fail verification.

Return a 2xx response quickly. Do heavy processing asynchronously if needed to avoid timeouts and retries.

Idempotent webhook event processing / deduplication

Stripe may deliver the same event more than once. Your handler should deduplicate by event ID (e.g. store processed evt_ IDs) so retries do not:

  • Revoke access twice
  • Apply a save offer twice
  • Write duplicate outcome rows
on webhook received:
  if event.id already in processed_events:
    return 200  -- already handled
  verify signature
  handle event
  insert event.id into processed_events
  return 200

This is webhook event deduplication — not the same as Stripe API idempotency keys on subscriptions.update or subscriptions.cancel. You may use both: idempotency keys for API retries from your server, and event-ID deduplication for webhook retries from Stripe.

Keeping internal state synchronized

Map Stripe subscription fields to app access:

Race condition: A customer may accept a coupon in your UI while a subscription.deleted webhook from an earlier action is still in flight. Serialize updates per subscription_id or use transactional checks against the latest Stripe object.

Store internal outcomes in your database — these are your fields, not Stripe’s:

  • status — verify valid values in current Subscription object reference
  • cancel_at_period_end — customer scheduled to leave but may still have access
  • cancel_at — if using custom cancel dates (see Stripe cancel docs)
  • saved — customer accepted an offer; subscription still active
  • canceled_at_period_end — scheduled cancel confirmed
  • canceled_immediate — subscription deleted immediately
  • pending — flow started but Stripe state not yet confirmed

Recording outcomes for analytics

Log implementation events your analytics pipeline can consume later:

  • reason_code, optional reason_comment
  • offer_type_shown, offer_accepted (boolean)
  • stripe_subscription_id, stripe_customer_id
  • Subscription MRR snapshot at flow time (from your billing data)
  • Final Stripe subscription status after the flow completes

For how to analyze this data — weighting reasons by MRR, offer performance, review cadence — see customer churn analysis. This guide stops at what to record during implementation.

Testing a Stripe cancellation flow

Use Stripe test mode end to end:

  • Create a test subscription with a test clock or standard test card (verify current Stripe testing documentation)
  • Walk through each path in your test matrix:
ScenarioWhat to verify
Accept coupon offerSubscription updated; no cancel; internal outcome = saved
Accept pause offerpause_collection set; subscription still active
Decline offer → period-end cancelcancel_at_period_end=true; updated webhook; access through period
Decline offer → immediate canceldeleted webhook; access revoked per your rules
Already-canceled subscriptionAPI error handled gracefully
Scheduled cancel reversalcancel_at_period_end=false; subscription continues
Duplicate webhook deliverySecond event ignored via event-ID deduplication

For local webhook testing, use the Stripe CLI to forward events to your development endpoint.

Common implementation mistakes

MistakeWhy it hurts
Client-side cancel with secret keyExposes credentials; violates Stripe security model
Ignoring subscription.updated for period-end cancelsApp access does not match Stripe until deleted fires — or you revoke too early
No idempotent webhook event processing / deduplicationRetries double-revoke access, double-apply offers, or duplicate analytics rows
Canceling before capturing reasonYou lose structured churn data for later analysis
Applying coupon after cancelAPI errors or inconsistent subscription state
Assuming portal provides per-reason offer routingPortal retention is coupon deflection — not dynamic reason→offer logic
Treating pause_collection as cancelSubscription may still be active; metrics and access rules differ
Copying vendor save-rate benchmarksUnverifiable; not useful for implementation

Build vs embed vs portal — how to choose

There is no single correct choice. Early-stage teams often start with portal. Teams that need per-reason saves and session analytics typically move to custom or embedded implementations.

ApproachEngineeringRetention flexibilityMaintenance
Customer Portal onlyLowestLimited to portal featuresStripe maintains hosted UI
Custom buildHighestFull control over flow and API compositionYou own UI, API wiring, webhooks, and Stripe API changes
Embedded widget (third party)MediumConfigurable flows with less custom codeVendor maintains flow UI; you integrate and grant Stripe permissions

ChurnIntel as one implementation option

If you prefer an embedded path over building everything in-house, ChurnIntel is one option for Stripe-billed SaaS:

  • Embedded in-app widget — not a hosted no-code cancel page
  • Stripe Connect with read/write access to apply coupons, pauses, plan changes, and cancellations on your behalf
  • Voluntary cancellation scope only — not dunning or payment recovery
  • ChurnIntel fits the custom-flow architecture described in this guide: intercept in your app, capture reason, apply retention actions in Stripe, record outcomes. Teams where Customer Portal is sufficient may not need it. Teams building fully custom flows may prefer their own implementation. See Stripe integration details for how Connect permissions map to the API patterns above.

Conclusion

A Stripe cancellation flow connects customer intent to subscription state through a deliberate sequence: intercept → reason → optional save → Stripe API action → webhook reconciliation → internal outcome. Customer Portal, custom in-app builds, and embedded tools are all valid paths depending on your policy and engineering constraints.

Cluster #1 guides explain what the experience should contain. This guide explains how to wire it in Stripe. Keep voluntary and involuntary paths separate, verify Stripe behavior against official documentation as APIs evolve, and treat cancellation timing as a business decision — not a one-size-fits-all default.

Embed a voluntary Stripe cancellation flow in your app

Intercept cancel intent, capture reasons, apply retention actions in Stripe, and record outcomes — without building the full flow from scratch.