Build a Stripe cancel flow: cancel_at_period_end and a webhook handler that doesn't double-count
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. Set cancel_at_period_end to true so the subscriber keeps what they paid for, and revoke access only when customer.subscription.deleted arrives.
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_endtofalseany 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 three 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") { if (sub.cancel_at_period_end === true && prev.cancel_at_period_end === false) { // 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 (sub.cancel_at_period_end === false && prev.cancel_at_period_end === true) { // The scheduled cancellation was stopped: a candidate save, 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.
The handler ran in six 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, and a repeated event id is processed once. 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: in the subscription object it sits 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.
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.
Choosing which offer to show is the harder part. If you want to see one decided from a subscriber's own words, the sample cancel session runs in 90 seconds with no sign-up, and shows the offer, the wording and the Stripe change it would make.
Full guide with sources: stayput.grain64.com/guides/build-a-cancel-flow-with-stripe