Guide · facts checked 3 Nov 2026
Build a cancel flow with Stripe
A cancel flow on Stripe is three pieces: a place that catches the cancel click, an offer step, and a webhook that tells your app whether the cancellation was scheduled, stopped or finished. Cancel with cancel_at_period_end set to true so the subscriber keeps what they paid for, and revoke access only when customer.subscription.deleted arrives.
Where to catch the cancel click
There are three places, and the choice decides how much of the rest you write.
| Option | What you build | Limit |
|---|---|---|
| Stripe Customer Portal with retention rules | Rules in the Dashboard: conditions and one discount or downgrade per branch | Portal cancellations only; no offer chosen from the subscriber's own words |
| Your own cancel page | A reason question, an offer step, API calls to Stripe, the webhook below | All of it is yours to build and keep correct |
| A hosted flow | A link or one script tag; the flow runs on someone else's page | You rely on that tool for offers and logging |
The rest of this guide covers what every option needs: the Stripe calls and the webhook.
Cancel at period end, not immediately
By default, cancelling a subscription through the API takes effect immediately and no more invoices are generated. To let the subscriber keep the period they already paid for, update the subscription with cancel_at_period_end=true.
- Schedule it: set
cancel_at_period_endtotrue. Stripe sendscustomer.subscription.updated. - Offer something (optional): the subscriber is still active, so there is time to show an offer and apply it.
- Stop it: set
cancel_at_period_endtofalseat any time before the period ends. Stripe sendscustomer.subscription.updatedagain. You cannot reactivate a subscription that has already been cancelled. - Let it end: when the period ends Stripe cancels the subscription and sends
customer.subscription.deleted. That is the event that tells you to revoke access.
Stripe recommends using the subscription status, rather than the flag, as the trigger for starting and stopping service.
The webhook handler
This Node handler verifies the signature, ignores repeats and reacts to the three situations above. It takes the raw request body, the Stripe-Signature header and a small store you provide (a database table is enough). Pass the exact bytes Stripe sent: a body that has been parsed and re-serialised fails the signature check.
import Stripe from "stripe";
// Handles the Stripe events a cancel flow cares about.
// rawBody must be the exact bytes Stripe sent (string or Buffer), not re-serialised JSON.
export function createCancelWebhook({ webhookSecret, store }) {
return async function handle(rawBody, signatureHeader) {
let event;
try {
event = Stripe.webhooks.constructEvent(rawBody, signatureHeader, webhookSecret);
} catch {
return { status: 400, body: "bad signature" };
}
// seen() must record the id in the same transaction as the writes below, so a failed write is retried, not skipped.
if (await store.seen(event.id)) return { status: 200, body: "duplicate" };
const sub = event.data.object;
const prev = event.data.previous_attributes ?? {};
if (event.type === "customer.subscription.updated") {
// A late "updated" must not reopen a subscription whose "deleted" already arrived.
if (await store.hasEnded(sub.id)) return { status: 200, body: "stale" };
// Classic mode schedules with cancel_at_period_end; flexible billing mode and the Customer Portal set cancel_at instead.
// Read both, taking the "before" value from previous_attributes when that field changed, else from the current object.
const isScheduled = (o) => o.cancel_at_period_end === true || (o.cancel_at ?? null) !== null;
const before = { cancel_at_period_end: "cancel_at_period_end" in prev ? prev.cancel_at_period_end : sub.cancel_at_period_end, cancel_at: "cancel_at" in prev ? prev.cancel_at : sub.cancel_at };
const touched = "cancel_at_period_end" in prev || "cancel_at" in prev;
if (touched && isScheduled(sub) && !isScheduled(before)) {
// A cancellation was scheduled: the subscriber keeps access until the period ends.
await store.cancelScheduled({
subscription: sub.id,
customer: sub.customer,
reason: sub.cancellation_details?.feedback ?? null,
comment: sub.cancellation_details?.comment ?? null,
});
} else if (touched && !isScheduled(sub) && isScheduled(before)) {
// The scheduled cancellation was stopped: a candidate save, to be counted after your waiting period.
await store.reactivated({ subscription: sub.id, customer: sub.customer });
}
} else if (event.type === "customer.subscription.deleted") {
// The real end, from a direct cancel or from reaching the end of the paid period: revoke access here.
await store.accessEnded({ subscription: sub.id, customer: sub.customer });
}
return { status: 200, body: "ok" };
};
}
The dedupe contract. seen() must record the event id in the same database transaction as the writes the handler makes, so that if a write fails Stripe's retry is processed again instead of being skipped as a duplicate.
Two Stripe behaviours the handler allows for. First, scheduling a cancellation does not always flip cancel_at_period_end: in flexible billing mode, and when a customer cancels in the Customer Portal, Stripe sets cancel_at to a timestamp and leaves the flag false. The handler therefore treats a subscription as scheduled when either field says so, and reads the "before" value of each from previous_attributes. Second, Stripe does not guarantee that events arrive in the order they were generated, so a delayed customer.subscription.updated can land after customer.subscription.deleted. hasEnded() lets the handler drop that late update instead of reopening access. Stripe also warns that created is too coarse to order events, which is why the check is on your own recorded state and not on a timestamp.
What was tested. The handler runs in ten tests with stripe-node 23.1.0, using events signed the way Stripe signs them: a wrong secret and a tampered body are rejected, a scheduled cancellation records the reason and comment, a stopped one is reported as a reactivation, an unrelated update does nothing, a deleted subscription revokes access, a repeated event id is processed once, a cancellation scheduled through cancel_at is recorded once, even when both fields change together, clearing cancel_at counts as a reactivation while moving the date does not, and an update that arrives after the deletion does not reopen access. Before you deploy it, send a test event from your Dashboard or the Stripe CLI and watch it arrive end to end.
Where the reason lives: Stripe's portal cancellation page lists eight preset reasons plus an optional comment, and says they arrive in the subscription.updated webhook. In the subscription object they sit under cancellation_details (fields comment, feedback and reason). The handler stores whatever string arrives and does not depend on a fixed list of values.
Applying an offer
Once the reason is known, the three standard offers map to three Stripe changes.
- Discount: a coupon with
percent_offoramount_off, lastingonce(the default),repeatingfor a set number of months, orforever. Then setcancel_at_period_endback tofalse. - Downgrade: switch the subscription item to a cheaper price and set
cancel_at_period_endtofalse. - Pause:
pause_collectionwith a behavior (keep_as_draft,mark_uncollectibleorvoid) and an optionalresumes_at. The subscription stays active and the customer keeps access, so decide in your app what a paused subscriber may use. Stripe's separate Pause subscriptions feature stops service delivery and invoices, and works with flexible billing mode only.
Choosing between them is the harder part; see what counts as a save for how to tell whether an offer worked.
Checklist before you ship
- The webhook endpoint reads the raw body and checks the signature.
- Event ids are stored, so a retry changes nothing.
- Access ends on
customer.subscription.deletedor on status, not when the cancel button is clicked. - Every offer path ends with
cancel_at_period_endset tofalse, and a “Cancel anyway” button is always one click away. - Every cancel attempt is logged with the reason, the offer shown and what the subscriber chose, so you can count saves later.
See what a decision looks like before you build one
Run a sample cancel session: pick a subscriber, give a reason, and watch the offer, the wording and the Stripe change come back.
90 seconds, no sign-up. You play the subscriber; the AI answers, and “Cancel anyway” is always one click away.