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.
| Path | What happens | What you typically build |
|---|---|---|
| Minimum viable | Cancel button → Stripe API or portal → done | Portal redirect or one server endpoint |
| Retention-aware | Cancel button → reason → optional offer → Stripe action → outcome logged | Custom 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 flowFor a longer product-level comparison, see Stripe Customer Portal vs embedded cancellation 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
To send a customer directly into the cancel flow, create a Billing Portal session with a deep link. Per Stripe’s portal deep links documentation, set flow_data[type]=subscription_cancel and pass the subscription ID:
curl https://api.stripe.com/v1/billing_portal/sessions \
-u "$STRIPE_SECRET_KEY:" \
-d customer="$CUSTOMER_ID" \
--data-urlencode return_url="https://yourapp.com/account" \
-d "flow_data[type]=subscription_cancel" \
-d "flow_data[subscription_cancel][subscription]=$SUBSCRIPTION_ID"Redirect the customer to the returned session url. The portal UI is hosted by Stripe — you cannot iframe it as a native in-app component.
Portal limitations relevant to custom flows
| Capability | Customer Portal | Custom in-app flow |
|---|---|---|
| Branded in-product UX | Hosted Stripe UI | Full control |
| Per-reason dynamic offers | Limited — retention coupon deflection, not reason-routed offer logic | Full control via your server |
| Pause / plan switch before cancel | Verify current portal settings; custom flows use API (pause_collection, item updates) | API-driven |
| Outcome/session analytics | Portal reason data via subscription/webhooks; less session context | Your DB + Stripe state |
| Engineering effort | Low | Higher |
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
Recommended architecture for a custom Stripe cancellation flow
- Server-side rule: Never call Stripe subscription mutation endpoints from the browser with your secret key. The client triggers your flow; your server owns Stripe writes.
- Confirm-before-cancel: Only call cancel endpoints after the customer confirms in your UI (or completes the portal flow). Accidental double-clicks should not create duplicate API calls without guards on your side.
- Cancellation timing is a policy decision. The sections below explain period-end and immediate cancellation technically. Your product, legal, and support teams choose which policy fits your customers.
[Cancel button in your app]
│
▼
[Your cancellation UI — modal or dedicated page]
│
▼
[Reason capture → stored in your database]
│
▼
[Optional retention step — customer accepts or declines offer]
│
▼
[Your backend — server-side Stripe API calls only]
│
▼
[Stripe subscription updated, paused, discounted, or canceled]
│
▼
[Webhook handler → reconcile internal access + outcome]
│
▼
[Record outcome: saved | canceled_immediate | canceled_at_period_end | pending]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_atLink 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=truePer 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.
| Dimension | Period-end (cancel_at_period_end) | Immediate (DELETE subscription) |
|---|---|---|
| API pattern | subscriptions.update with cancel_at_period_end=true | subscriptions.cancel / DELETE |
| Typical access | Through current period (your app must enforce) | Can end immediately |
| Primary events | subscription.updated, then subscription.deleted at period end | subscription.deleted |
| Reversal | Set cancel_at_period_end=false before period ends | Not reversible — new subscription required |
| Policy owner | Product / 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=falsePer 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:
| Event | When it fires (cancel context) |
|---|---|
| customer.subscription.updated | Any subscription update — including when cancel_at_period_end is set to true |
| customer.subscription.deleted | Subscription 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 200This 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:
| Scenario | What to verify |
|---|---|
| Accept coupon offer | Subscription updated; no cancel; internal outcome = saved |
| Accept pause offer | pause_collection set; subscription still active |
| Decline offer → period-end cancel | cancel_at_period_end=true; updated webhook; access through period |
| Decline offer → immediate cancel | deleted webhook; access revoked per your rules |
| Already-canceled subscription | API error handled gracefully |
| Scheduled cancel reversal | cancel_at_period_end=false; subscription continues |
| Duplicate webhook delivery | Second event ignored via event-ID deduplication |
For local webhook testing, use the Stripe CLI to forward events to your development endpoint.
Common implementation mistakes
| Mistake | Why it hurts |
|---|---|
| Client-side cancel with secret key | Exposes credentials; violates Stripe security model |
| Ignoring subscription.updated for period-end cancels | App access does not match Stripe until deleted fires — or you revoke too early |
| No idempotent webhook event processing / deduplication | Retries double-revoke access, double-apply offers, or duplicate analytics rows |
| Canceling before capturing reason | You lose structured churn data for later analysis |
| Applying coupon after cancel | API errors or inconsistent subscription state |
| Assuming portal provides per-reason offer routing | Portal retention is coupon deflection — not dynamic reason→offer logic |
| Treating pause_collection as cancel | Subscription may still be active; metrics and access rules differ |
| Copying vendor save-rate benchmarks | Unverifiable; 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.
| Approach | Engineering | Retention flexibility | Maintenance |
|---|---|---|---|
| Customer Portal only | Lowest | Limited to portal features | Stripe maintains hosted UI |
| Custom build | Highest | Full control over flow and API composition | You own UI, API wiring, webhooks, and Stripe API changes |
| Embedded widget (third party) | Medium | Configurable flows with less custom code | Vendor 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.
- Cancellation flow examples — flow structure and scenarios
- Cancellation survey questions — what to ask
- Retention offer strategy — which offer to show
- Customer churn analysis — how to analyze outcomes
- Voluntary vs involuntary churn — scope boundaries
- Stripe Customer Portal comparison — portal vs embedded decision
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.