BlogCompose

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."
That last one matters. An honest cancel flow for genuinely-churned users costs you nothing - they were leaving anyway - and buys the kind of goodwill that brings people 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.
Never hide or delay the cancel option. FTC click-to-cancel rules require cancellation to be as easy as signup - and dark patterns lose long-term anyway. The composed copy informs; it doesn't trap.
See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist