2 · Stop them leaving
Your cancel button opens Sarah instead of your confirm dialog — she finds the real reason, then completes the outcome that matches it (a pause, a discount, a refund, or the cancellation itself) through tools on your own server.
This layer is about revenue you already own. You paid to acquire every one of these users, so plugging a leak is the cheapest money there is — the only kind you get without finding a new person first.
It starts at your cancel button
The whole integration is one swap: your Cancel button stops cancelling and starts opening Sarah. She arrives already knowing who this is and what they pay, finds out why they're leaving, and then completes whatever the conversation decides — a pause, a discount, a refund, or the cancellation itself — by calling a tool on your server.
Two halves, and the second is the one people skip:
- The button opens her. One line, where your cancel flow starts today.
- She can finish what the conversation decides. Every outcome, including the cancel, is a tool on an Action MCP server you run and cap. Without that half, the best she can do is name the lighter path — "a pause would fix this" — and send the user back to the button. That's the exact friction they opened the chat to escape.
Split the one button into two
Most products have a single Manage subscription button that opens Stripe's customer portal, and everything happens in there: change plan, update card, download invoices, cancel. That last one is the problem. Stripe's portal is on Stripe's domain, so no script of yours runs on it — your SDK isn't there, Sarah isn't there, and the highest-intent moment in your product happens somewhere you can't see it. The cancellation lands in your webhooks as a fact, after the fact, with no reason attached.
So split the entry point in two, exactly as the mock above shows:
- Manage subscription → the Stripe portal, unchanged. Cards, invoices, plan changes: no revenue decision on any of it, no reason to intercept.
- Cancel subscription → your own button, on your own page, opening Sarah.
'use client';
// Boring half: still Stripe's portal, still one redirect.
export function ManageSubscriptionButton() {
return <button onClick={() => location.assign('/api/billing/portal')}>
Manage subscription
</button>;
}
// The half with money on it: hers.
export function CancelSubscriptionButton() {
function handleClick() {
// openPrompt, not trigger: `cancel` is a once-per-user Moment, so a second
// cancel attempt would hit a dead button. openPrompt has no dedup.
if (window.UserSay?.openPrompt) {
window.UserSay.openPrompt('cancel', { openMode: 'chat' });
return;
}
// Script blocked, offline, or still booting — your own flow is always reachable.
location.assign('/settings/billing/cancel');
}
return <button onClick={handleClick}>Cancel subscription</button>;
}Then close the back door, or the split leaks: turn cancellation off in the portal itself. Otherwise a user who clicks Manage can still cancel two screens deep on Stripe's domain, and you'll never know why.
stripe.billingPortal.configurations.update(configId, {
features: {
subscription_cancel: { enabled: false }, // cancelling is your page's job now
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
})Three things about the cancel handler are load-bearing:
openPrompt, nottrigger.trigger('cancel')is deduped — once per user for life, by default — which is right for a Moment that fires on its own and wrong for a button a person just pressed on purpose.openPromptskips dedup entirely (it's rate-limited to 50/hour instead), so the second and third cancel attempt open just like the first.openMode: 'chat'opens the conversation directly. The default forcancelis the small bubble, which a user on their way out will ignore — and if the button no longer cancels, ignoring it is a dead end.- The fallback is not optional. Ad blockers exist, and
openPromptalso no-ops if the Moment is switched off in your dashboard or ifidentify()hasn't run on that page. Never let a third-party script be the only path out of a subscription. After wiring it, confirm the Moment is live:curl "https://www.usersay.ai/api/sdk/status?project=YOUR_SLUG"should showcancelwith"firing": true.
Your cancel must never wait on the chat
The SDK tells your page when an interview completes — it does not tell you when someone closed the widget instead. So the tempting wiring, "open Sarah, then cancel in onComplete", silently strands every user who closes her rather than finishing the conversation.
Pick one owner, never half: either she owns the outcome (she has a cancel tool, and the button is hers), or your flow owns it and she rides alongside — see If you don't have an action server yet.
Closing her chat is then exactly like closing a confirm dialog: nothing happened, the button is still there, and clicking it opens her again.
What to expose on the server
The point of the handoff is that Sarah can ask for any of these and decide none of them. Your server picks the number, the coupon, and the answer — see Actions for the guardrail pattern and the Stripe calls.
| Tool | What she can ask for | What your server alone decides |
|---|---|---|
cancel_subscription | End it — now or at period end | Which of the two, and whether a refund rides along |
pause_subscription | A break instead of an exit | How long, how often, whether the card stays |
apply_discount_to_subscription | "A discount would fix this" | Which coupon. She passes a reason; you map reason → coupon |
extend_trial | More time to evaluate | The number of days, and once per user |
process_refund | Money back at the door | The cap, the window, and the real amount |
The discount row is where founders get nervous, and it's the row with the strongest guardrail. Sarah cannot name a coupon or an amount. She calls the tool with reason: "retention"; your server looks up which coupon that reason is allowed to hand out, refuses if the subscription already has a better one, and returns what actually happened. She relays that and nothing else. A whitelist of two coupons in your code is the entire blast radius.
If you only build one, build the cancel tool — the moment you take cancellation out of the Stripe portal, it's the one outcome that has nowhere else to happen. cancel_subscription and process_refund are often the same tool: Diffus exposes a single cancel_user_subscription_and_refund that does both atomically.
If you don't have an action server yet
Then don't hand over the button. Do the cancellation in your own flow and let her ask why on the way out:
// Your code still owns the outcome; she rides alongside for the reason.
await cancelSubscription();
UserSay.trigger('cancel');You give up the save — there was no lever to pull anyway — and you keep the thing that's worth having on its own: the real reason, in their words, which no exit survey has ever produced. When you're ready for the save, swap in the handoff above.
One prerequisite holds either way: if cancelling happens inside Stripe's portal today, it has to come out of there first. Nothing UserSay does can reach a page on Stripe's domain, so until the button is on a page you render, there's nowhere for her to open.
The reason has to come first
Most cancels aren't about price. They're a fixable setting, a wrong model, a bad month. Which one it is decides everything that happens next, so Sarah finds out before she reaches for anything:
She had a discount she could have offered. It would have been the wrong tool: this illustrator just finished a six-month client project and has nothing booked for August. That's a timing problem, not a price problem, and money off a month she won't use doesn't solve it. Diagnosing comes first, and then the lever has to match — timing gets a pause, a broken thing gets fixed, and only a price that's genuinely too high long-term gets a discount.
What she knows before she says hello
She doesn't open cold. Whatever you passed to identify() is already in front of her when the chat opens — this is Maya, the illustrator above, exactly as Sarah receives her:
Two of those fields are the difference between a save and an insult, and they're the two people approximate: plan and trialing. Collapse every paying tier into pro and an upsell disappears; report a trial as a paid plan and Sarah offers a refund on money that was never charged. If you tidy up two fields before wiring the button, make them these.
The states in this layer
- About to cancel — the real reason is in their head and nowhere in your exit survey.
- A trial about to lapse — no money has changed hands yet, so every money-shaped lever is the wrong shape.
- A refund request at the door — usually a fixable problem wearing a refund's clothes.
- Back after a long silence — she picks the thread up the moment they return.
What zero code already catches
Honestly: not much. This layer has the thinnest free coverage of any on the site.
return_visitfires on its own. The SDK counts sessions and gaps client-side, so a user reappearing after a long quiet stretch is the one state here you get for nothing.canceldoes not. It needs one line where your cancel flow lives — the wiring above. One line, and it's the moment with the most money sitting on it.open_feedbackneeds a button you add somewhere in your UI.- Every actual save lever is Layer 1.
pause_subscription,extend_trial,apply_discount_to_subscription,process_refund— none of them exist until you run an MCP server. Without one, Sarah can diagnose the reason and name the lighter path out loud ("a pause would fix this"), and you'll see it in the transcript. She cannot pull it, which is also why the button stays yours until she can.
That isn't nothing — Let the gone leave well is pure Layer 0 words, and a real reason you could never have gotten from a survey is worth having. But if you want a cancel saved rather than understood, this is the layer where the server pays for itself. Full breakdown of the line: How much you have to build.
The plays
The whole game is reading the reason and reaching for the one lever that fits it. Offering the wrong one is worse than offering nothing.
| What they actually say | The reason underneath | The lever |
|---|---|---|
| "Between projects", "won't need it this month" | Timing, not value | Pause — pause_subscription |
| "Never got to try it properly", trial about to renew | No time, and no money in play yet | Give time — extend_trial |
| "A bit much for how often I use it" | Long-term price | A matched discount, with a reason — apply_discount_to_subscription |
| "It's broken", "didn't do what I expected" | A fixable problem | Fix it — words, or grant_credits |
| "Didn't mean to pay", "trial auto-renewed" | Accidental charge | The refund is theirs — process_refund |
| Company shut down, switched tools, project shipped | Genuinely gone | A clean exit — cancel_subscription, and the real reason |
Three rules sit underneath all four plays:
- The cancel always goes through. The offer is made once. Accepted or not, you honor it — no second "are you sure," no hidden button. When the button is hers, honoring it means she calls your cancel tool right there in the conversation.
- Sarah detects the reason; your server decides what's on the table. A discount offered to someone whose company shut down is insulting. Offered to "it's a bit much for how often I use it," it's a hand.
- Do it here — don't send them hunting. If she holds the tool for what they're asking, she completes it in the chat. Telling a user to find a button in Settings, or to email support for a refund she could issue herself, is the precise friction they opened the chat to escape.
Save a saveable cancel
The conversation at the top of this page is this play, start to finish: a timing reason, met with a pause, in one move. Two other reasons turn up at cancel often enough to name. "A bit pricey for how often I use it" is a genuine long-term price objection — a matched discount fits (apply_discount_to_subscription; one click, the card's already on file). "I couldn't get X to work" needs no lever at all — answer it or route to the fix, because they were leaving over a gap that isn't real. Lead with understanding, make one genuine attempt, and if they still want out, respect it cleanly.
Save a trial before it churns
The trial canceller is a third kind of person the paid-cancel playbook doesn't cover. Nothing to refund, no period they've paid for, no price to soften. Most trial cancels aren't "this isn't worth it" — they're "I haven't decided yet, and I'm killing auto-renew so I'm not charged before I do."
extend_trial is the trial's version of a pause: it costs you nothing (no money was coming yet) and buys the one thing a busy evaluator needs. The guardrails live on your side of the wire — the server sets the length and caps it to once per user, so Sarah can ask for an extension but can't hand out free months.
When the trial reason is a fix rather than time — "the export was broken," "it generated the wrong thing" — more clock does nothing; they'll hit the same wall later. There the move is grant_credits plus answering or routing the real problem. And when they genuinely want out, don't cling. The whole ladder for a trial: more time, or a fix — never a discount, a pause, or a refund.
Handle a refund or cancel
A new user signed up two days ago, generated three images, and wrote "I want a refund." Most refund flows just hit the button. Sarah doesn't — yet. First she finds out what actually went wrong, because "I want a refund" almost always hides something fixable. Then she previews the result before acting: many monthly refunds come back at $0 because the active month counts as used, and surprising the user with that afterwards is exactly what turns a refund into a chargeback.
Four moves under all of it: diagnose first, try a save if it's fixable, preview the outcome so the user picks cancel-only vs attempt-the-refund, relay the real number. The arithmetic favors generosity — denying a $20 refund risks a chargeback ($20 back anyway, plus a ~$15 dispute fee, processor standing, and a one-star review), so for small amounts, refund instantly. It's math, not generosity. You set the cap; process_refund enforces it. See process_refund in the action server for the Stripe call.
Let the gone leave well
A subscriber ticked "price" on the exit survey. In the chat his actual story comes out: the price was fine — the tool didn't deliver on one specific use case, and he names the competitor he's switching to. None of that was reachable from the survey. He's gone, there's nothing to offer, and he's still the most valuable conversation you'll have this week.
The cancel moment produces some of the longest conversations in the product — because users who've decided to leave will explain themselves honestly, as long as it doesn't feel like a trap. Cancel cleanly, thank them, learn why. The save here isn't the user; it's the goodwill and the signal.
What she can't see yet
Two things sitting right next to this layer are outside her reach, and you should know that before you install rather than after.
A payment that failed. There is no billing-webhook listener. The expired card, the bounced charge — involuntary churn is the highest-ROI slice of churn there is, and it is invisible to her.
A user quietly going cold. return_visit catches someone when they return. While they're gone, nothing is watching and she can't tell. Read "re-warm the ones going cold" on this page as re-warm them on their next visit, never as reach out during the silence.
Both need the same missing capability — a way to notice a user who isn't on the page. See the user journey.
Next: Grow — the right offer for the hesitant, and the upgrade for the ones hitting their ceiling.
1 · Get them started
The survival gate — getting a new user from "I'm here" to "I made something I care about", and unsticking them the second they stall.
3 · Grow the account
The only upward money — conversion of the users on the fence, expansion for the ones who already outgrew their plan. Two of its four states cost you no code at all.