Stripe Cancel Subscription Flow: How to Add a Feedback Step
Learn how to add a cancellation feedback modal to your Stripe subscription cancel flow. Collect structured reasons before the subscription ends — with code examples.
The default Stripe cancel flow tells you nothing
When a user cancels their Stripe subscription through your app, the default flow is simple: your backend calls stripe.subscriptions.update() or stripe.subscriptions.cancel(), the subscription ends, and MRR goes down.
Stripe records that the cancellation happened. It does not record why. You get a webhook, a timestamp, and silence.
If you want to know whether the user left because of pricing, a missing feature, or a competitor — you need to add a feedback step between the moment the user clicks "Cancel" and the moment your backend processes it.
Where the feedback step fits
The idea is straightforward. Instead of immediately cancelling when the user clicks the button, you intercept the flow:
- User clicks Cancel subscription
- A modal appears asking why they're leaving
- User picks a reason (and optionally leaves a comment)
- Your app sends the feedback to your backend or a third-party tool
- Then the cancellation proceeds via the Stripe API
The feedback step sits between intent and action. This is critical — the user has already decided to leave, so the data is honest. But the action hasn't happened yet, so they're still engaged enough to answer.
Building it yourself: the basic approach
If you want to build this from scratch, here's the general pattern in a React + Next.js setup.
Frontend: intercept the cancel click
const [showFeedback, setShowFeedback] = useState(false);
const [reason, setReason] = useState("");
function handleCancelClick() {
// Don't cancel yet — show feedback modal first
setShowFeedback(true);
}
async function handleSubmitFeedback() {
// 1. Send feedback to your backend
await fetch("/api/cancel-feedback", {
method: "POST",
body: JSON.stringify({ reason, subscriptionId }),
});
// 2. Now actually cancel the subscription
await fetch("/api/cancel-subscription", {
method: "POST",
body: JSON.stringify({ subscriptionId }),
});
setShowFeedback(false);
}
Backend: cancel via Stripe API
// /api/cancel-subscription
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { subscriptionId } = await req.json();
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
return Response.json({ ok: true });
}
What you need to build
This approach works, but you'll need to handle several things yourself:
- The modal UI — a form with predefined reasons and a comment field
- The feedback storage — a database table or external service to store reasons
- A dashboard — some way to view, filter, and analyze the feedback over time
- Export — a way to get the data out for product reviews or BI tools
- Customization — the ability to change reasons without redeploying
For a one-person team or early-stage startup, this is a meaningful amount of work to build and maintain — especially when the core value is in the data, not the infrastructure.
The faster alternative: add Leavely to your Stripe cancel flow
Instead of building the modal, the storage, and the dashboard from scratch, you can use Leavely and be live in minutes. There are two ways to integrate it depending on how much control you want.
Simple integration — fire and forget
This is the fastest approach. When the user clicks "Cancel," Leavely opens the feedback modal and the cancellation proceeds immediately. The feedback is collected in parallel — it doesn't block the cancel action.
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Script from "next/script";
const PROJECT_KEY = "your-project-key";
async function cancelSubscription(): Promise<void> {
const res = await fetch("/api/billing/cancel", { method: "POST" });
if (!res.ok) throw new Error("Cancel failed");
}
export function CancelWithLeavely() {
const [ready, setReady] = useState(false);
useEffect(() => {
if (window.leavely && typeof window.leavely.open === "function")
setReady(true);
}, []);
const onCancel = useCallback(async () => {
const api = window.leavely;
// Open the feedback modal (non-blocking)
if (api && typeof api.open === "function") {
void api.open({
projectKey: PROJECT_KEY,
endpoint: "/api/leavely/uninstall",
});
}
// Cancel proceeds immediately
await cancelSubscription();
}, []);
return (
<>
<Script
src={`${window.location.origin}/sdk/leavely.js`}
strategy="afterInteractive"
onLoad={() => setReady(true)}
/>
<button type="button" onClick={onCancel} disabled={!ready}>
Cancel subscription
</button>
</>
);
}
This is ideal when you don't want the feedback step to interfere with the cancellation in any way. The user sees the modal, can respond or close it, and the subscription is cancelled regardless.
Advanced integration — cancel only after feedback
If you want to wait for the user's response before cancelling — for example, to show a targeted retention offer based on their reason — use the advanced pattern:
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Script from "next/script";
const PROJECT_KEY = "your-project-key";
async function cancelSubscription(): Promise<void> {
const res = await fetch("/api/billing/cancel", { method: "POST" });
if (!res.ok) throw new Error("Cancel failed");
}
export function CancelWithLeavely() {
const [ready, setReady] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (window.leavely && typeof window.leavely.open === "function")
setReady(true);
}, []);
const onCancel = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const api = window.leavely;
if (!api || typeof api.open !== "function") {
// Fallback: cancel without feedback if SDK isn't loaded
await cancelSubscription();
return;
}
const result = await api.open({
projectKey: PROJECT_KEY,
endpoint: "/api/leavely/uninstall",
});
// Only cancel if the user submitted feedback
if (result.status === "submitted") {
await cancelSubscription();
}
// If result.status === "closed", user dismissed the modal
// — you could cancel anyway, or let them stay
} finally {
setBusy(false);
}
}, [busy]);
return (
<>
<Script
src={`${window.location.origin}/sdk/leavely.js`}
strategy="afterInteractive"
onLoad={() => setReady(true)}
/>
<button type="button" onClick={onCancel} disabled={!ready || busy}>
{busy ? "Cancelling..." : "Cancel subscription"}
</button>
</>
);
}
The key difference: api.open() returns a promise with a result object. If result.status === "submitted", the user gave feedback and you proceed with the cancellation. If the status is "closed", they dismissed the modal — and you decide whether to cancel anyway or keep the subscription active.
This pattern also handles the edge case where the Leavely SDK fails to load: it falls back to cancelling without feedback so the user is never blocked.
Important design decisions
Whichever approach you choose, keep these principles in mind:
Never block the cancellation. If the user can't cancel without submitting feedback, you'll get angry users and unreliable data. Even the advanced integration should let users dismiss the modal and still cancel if they want.
Use cancel_at_period_end instead of immediate cancellation. This gives the user the rest of their billing period, which feels fairer and reduces support tickets. It also gives you a window to act on the feedback — some teams use this period to reach out with a targeted offer.
Keep the reason list short and actionable. Five to eight predefined options is the sweet spot. Every option should map to something your team can investigate or fix. Avoid catch-all options like "Other" as the primary choice — they generate noise, not signal.
Store the Stripe subscription ID alongside the feedback. This lets you cross-reference churn reasons with subscription data like plan tier, billing cycle, and lifetime value. A user on a $9/month plan cancelling for "too expensive" is a different signal than a $99/month user saying the same thing.
What to do after you ship
Adding the feedback step is day one. The real payoff comes from reviewing the data consistently:
- Weekly: Scan the latest reasons and comments. Flag anything urgent (bugs, outages, broken flows).
- Monthly: Look at reason distribution. Is "too expensive" growing? Is "missing feature" shrinking after your last release?
- Quarterly: Export the data and bring it to your product planning session. Churn feedback should directly influence your roadmap.
The Stripe cancel flow is one of the highest-signal touchpoints in your entire product. Every user who passes through it is telling you exactly what went wrong. The only question is whether you're set up to hear it.
Ready to understand why users cancel?
Start collecting cancellation feedback in your SaaS — free plan, live in 5 minutes.
Get started free →