UserSay/ Docs

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.

diffus.app/settings/billing
Billing
Billing
Pro
$19 / month
Renews Aug 12, 2026
Visa •••• 4242Update
Manage subscription
→ Stripe customer portal
1Cancel subscription
→ UserSay.openPrompt('cancel')
Sarah
2
moment · cancel
before you go — what didn't work?
nothing — just finished a big project, won't need it in august
3your MCP server
pause_subscription
← { status: "paused" }
1
They click Cancel
Managing a subscription still goes to Stripe. Only the one button with money on it comes to her.
2
Sarah opens instead
She arrives holding the account record, and asks the question your exit survey can't.
3
She finishes it on your server
Pause, discount, refund — or the cancellation itself. Every outcome is a tool you wrote and capped.

Two halves, and the second is the one people skip:

  1. The button opens her. One line, where your cancel flow starts today.
  2. 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, not trigger. 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. openPrompt skips 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 for cancel is 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 openPrompt also no-ops if the Moment is switched off in your dashboard or if identify() 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 show cancel with "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.

ToolWhat she can ask forWhat your server alone decides
cancel_subscriptionEnd it — now or at period endWhich of the two, and whether a refund rides along
pause_subscriptionA break instead of an exitHow 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_trialMore time to evaluateThe number of days, and once per user
process_refundMoney back at the doorThe 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:

Sarah
Diffus · cancel
Sarah
hey, before you go — what didn't work? no pressure, just want to understand.
SIGNAL
don't react to 'cancel' — find out why first
honestly it worked great, I just finished a big project and won't need it in august
SIGNAL
timing, not value — most 'cancels' are 'not right now'; pause is the highest-comfort save in SaaS
Sarah
ah got it — sounds like a timing thing, not the tool. want me to pause for a month instead? card stays, no charge, picks up automatically.
MOVE
match the reason to the response — pause for timing, discount for price, an answer for a gap
oh that would be perfect actually
Tool call · pause_subscription
→{ resume_at: "2026-09-01" }
←{ status: "paused", until: "2026-09-01" }
EFFECT
highest-comfort save — no money lost on a month she won't use; relationship + card stay
paused till sept 1 — no charge in august, picks back up automatically. enjoy the break 🙂

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:

UserSay.identify()the record behind that conversation
{
uid: "u_8321",
the only required field. Your MCP server keys every action off it — which is why she never has to ask for an account email, and why a user can't act on someone else's account by naming it.
name: "Maya",
she opens with a name instead of a form.
plan: "pro",
a paying user, so the full ladder is live: pause, discount, refund.
subscribed: true,
routes this to the paid-cancel playbook, not the trial one.
trialing: false,
flip this to true and the whole ladder changes — more time or a fix, never a discount, a pause, or a refund. Nothing has been charged to give back.
signupAt: "2026-02-04",
six months in, not six days — so she doesn't open like someone who just got here.
credits: 240,
she's leaving with credits unspent — a usage problem wearing a cancel's clothes, worth one question.
traits: { seats: 1 },
anything else your app already knows, free-form. She reads it as context; there's no schema to declare.
}

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_visit fires 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.
  • cancel does 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_feedback needs 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 sayThe reason underneathThe lever
"Between projects", "won't need it this month"Timing, not valuePause — pause_subscription
"Never got to try it properly", trial about to renewNo time, and no money in play yetGive time — extend_trial
"A bit much for how often I use it"Long-term priceA matched discount, with a reason — apply_discount_to_subscription
"It's broken", "didn't do what I expected"A fixable problemFix it — words, or grant_credits
"Didn't mean to pay", "trial auto-renewed"Accidental chargeThe refund is theirs — process_refund
Company shut down, switched tools, project shippedGenuinely goneA clean exit — cancel_subscription, and the real reason

Three rules sit underneath all four plays:

  1. 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.
  2. 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.
  3. 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

play
Fires at: cancelCalls: pause_subscription · apply_discount_to_subscriptionToggle: Agent → Plays → Win-back

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

play
Fires at: cancelCalls: extend_trial · grant_creditsToggle: Agent → Plays → Win-back

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."

Sarah
Diffus · cancel
Sarah
hey, before you go — what's making you cancel? no pressure, just want to understand.
SIGNAL
read the trait first — `trialing: true` means nothing has been charged; do not talk price
honestly I haven't even had a chance to try it properly — work got crazy. just don't want to get charged tomorrow
SIGNAL
not 'no value' — 'no time' — the classic within-hours trial cancel: turn off auto-renew before the charge lands
Sarah
ah, totally fair — sounds like the week ran away, not that the tool let you down. want me to add a few more days to the trial so you can actually give it a real shot? still no charge.
MOVE
time, not price — extend the runway instead of letting a busy week cost you the user
oh yeah, that'd actually be great
Tool call · extend_trial
→{ reason: "no_time_to_evaluate" }
←{ status: "applied", trial_end: "2026-09-01" }
EFFECT
server fixes the length, once per user — a save lever, not a way to coast free forever
done — your trial runs through sept 1 now, nothing charged till then. ping me if anything's confusing when you dig in 🙂

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

action
Usually at: cancel · open_feedbackCalls: process_refund (+ your cancel tool)Toggle: auto — matches your refund/cancel MCP tools

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.

Sarah
Diffus · open_feedback
Sarah
hey, saw your refund note — happy to take care of it. mind if I ask what didn't work? want to make sure we either fix it or learn from it.
SIGNAL
diagnose first — 'I want a refund' usually hides a fixable problem; reach for the tool last
the images came out blurry — I expected something like the marketing examples
SIGNAL
onboarding friction, not a bad product — the kind of thing a single message could fix if caught early
Sarah
ah — sounds like you might've been on the standard model. the marketing shots are from the HD one (under Settings → Quality → HD). want to try one on the house before deciding?
MOVE
try the save first — if the issue is a wrong setting, one tip can recover the user and the money
no honestly I just want the refund, not going to use this
Sarah
totally fair. heads up — since the monthly counts as used once you generate, the refund will likely come back at $0 (cancel-only). still want me to try, or just cancel cleanly?
MOVE
preview the outcome before acting — never surprise the user with a $0 refund
cancel cleanly, sucks but ok
Tool call · process_refund
→{ reason: "early_cancel_no_use" }
←{ status: "refunded", amount_cents: 0, refund_id: "re_xx" }
COST
$0 < $15 chargeback fee — even when no money moves, the *honesty* is what prevents the dispute
done — subscription canceled, $0 back as I warned. sorry it didn't fit, and thanks for telling me which model — that's exactly the kind of thing we should be catching earlier 🙏

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

play
Fires at: cancelCalls: — (just words)

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.

Sarah
Diffus · cancel
Sarah
hey, sorry to see you go — anything you'd want me to pass on?
honestly the price was fine, I was happy to pay as long as it delivered. it didn't — I do sketch-to-promo art for our game and it never got close. switched to Krea.
SIGNAL
'we're done' — nothing to offer, and a discount here would insult them
Sarah
oh that's super useful, thank you for the honesty. what was Krea doing on the sketch→promo flow that we weren't?
MOVE
no save attempt — dig for the why instead — the conversation is the value; a discount here would burn it
the consistency between iterations mostly — same character holds across 5 generations, ours kept drifting
Sarah
got it — character consistency across iterations on sketch-to-art. that's exactly the kind of thing the team needs to hear. genuinely appreciate you walking me through it, and best of luck with the game 🎮
EFFECT
sharpest churn signal you'll ever get — the named competitor, the named use case, the named failure

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.

On this page