Stayput / Guides

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.

OptionWhat you buildLimit
Stripe Customer Portal with retention rulesRules in the Dashboard: conditions and one discount or downgrade per branchPortal cancellations only; no offer chosen from the subscriber's own words
Your own cancel pageA reason question, an offer step, API calls to Stripe, the webhook belowAll of it is yours to build and keep correct
A hosted flowA link or one script tag; the flow runs on someone else's pageYou rely on that tool for offers and logging

Compare the first and third in Stripe retention rules vs a cancel flow. 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.

  1. Schedule it: set cancel_at_period_end to true. Stripe sends customer.subscription.updated.
  2. Offer something (optional): the subscriber is still active, so there is time to show an offer and apply it.
  3. Stop it: set cancel_at_period_end to false at any time before the period ends. Stripe sends customer.subscription.updated again. You cannot reactivate a subscription that has already been cancelled.
  4. 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" };
    }
    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, 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" };
  };
}

What was tested. The handler runs 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: 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_off or amount_off, lasting once (the default), repeating for a set number of months, or forever. Then set cancel_at_period_end back to false.
  • Downgrade: switch the subscription item to a cheaper price and set cancel_at_period_end to false.
  • Pause: pause_collection with a behavior (keep_as_draft, mark_uncollectible or void) and an optional resumes_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.deleted or on status, not when the cancel button is clicked.
  • Every offer path ends with cancel_at_period_end set to false, 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.

Related

Sources, read 3 Nov 2026