I rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
The generic cancel modal has never changed a mind
The cancel flow is where SaaS revenue goes to die politely. Most apps show a generic "Are you sure? You'll lose access to Premium features" - a sentence so vague it has never changed a single mind. Here's the thing the generic modal can't do: remind the user what they specifically would lose. Not "Premium features." Their 312 generations this month. The workflow they run every Tuesday. That's an argument, and it can be generated per user at the moment they click "Cancel."
The implementation
I use Vevee (@vevee/sdk) for usage metering, so every user's real consumption is already sitting there. When someone opens the cancel flow, the server composes a save offer from it. The compose type's intent prompt: "The user is about to cancel. From their usage this period and their most-used features, list concretely what they lose, then suggest the smallest plan that keeps their top workflow. Be honest; never hide the cancel option." Data sources: user usage, user events, user attributes. Output: the SaveOffer schema in the snippet - output comes back as typed JSON, not prose to parse. Two very different users open the same cancel flow and see two very different arguments:
- A heavy user, the headline: "Before you go - you used 312 generations and 41 HD exports this month."
- What they lose: "Your product-shots workflow ran 18 times in the last 2 weeks" and "HD export isn't available on Free."
- The alternative: "If cost is the issue: the Starter plan keeps HD exports at half the price."
- A user who stopped using the product 6 weeks ago sees something else entirely: "Looks like you haven't used the app since April. Fair enough - Free keeps your projects intact if you ever come back."
import { createClient } from "@vevee/sdk";
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
interface SaveOffer {
headline: string;
whatYouLose: string[]; // concrete, from their usage
alternative: string; // e.g. "switch to Free and keep X"
ctaKeep: string;
ctaCancel: string; // always present - dark patterns lose long-term
}
const offer = await vevee.compose<SaveOffer>("cancel-save-offer", userId);
const content =
offer.status === "generated" ? offer.output : GENERIC_CANCEL_COPY;Wire the outcome into a funnel
The SDK's analytics side has reserved subscription events, so measuring save-rate is three captures. Saves (kept or downgraded) vs. subscription_cancelled, split by variant - that's your composed-vs-generic save rate in one dashboard funnel. And the subscription change itself is one SDK call, since Vevee manages the plan state too: vevee.upsertSubscription to move them to free, or cancelSubscription if there's no free tier.
await vevee.capture({ distinctId: userId, event: "paywall_shown",
properties: { surface: "cancel_flow", variant: "composed" } });
// if they take the downgrade instead of cancelling:
await vevee.capture({ distinctId: userId, event: "subscription_downgraded" });
// if they leave:
await vevee.capture({ distinctId: userId, event: "subscription_cancelled" });Guardrails, because cancel flows attract dark patterns
The generic cancel modal fails because it asks users to weigh "Premium features" in the abstract. The composed one puts their own last month on the scale. Different conversation entirely - but only if the flow stays honest, so three rules are non-negotiable:
- The cancel button is always rendered, always one click. The composed copy makes the argument; the user still leaves whenever they want.
- Opted-out users get the generic copy automatically - compose() returns { status: 'opted_out' } for anyone who opted out of AI personalization, and the discriminated union forces you to handle it.
- Cost is bounded by a monthly compose budget set in the dashboard. A cancel flow is low-traffic anyway; this is the cheapest surface you'll ever personalize. usage.costMicroUsd on each result tells you exactly what it cost.
Keep reading
The trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.
ComposeStop making users do math on your pricing page: recommend their plan from their own usage
Every pricing page asks the user to solve an estimation problem: "How many credits will I need per month?" Nobody knows. But for any user who has actually used your product, their usage history IS the answer - here is how to put it on the pricing page.