Docs navigation
Build a flow with offers
Two ways to show offers in a flow: attach them to survey reasons, or declare a standalone OfferStep that runs up front.
Before you begin
You have a basic cancel flow running. This guide adds offers to it.
1. Attach offers to reasons
A reason can carry a single offer. Picking the reason routes the flow to that offer next, skipping any intervening declared steps. Reasons without an offer fall through to the next step.
The example maps three reasons to three offer types: a discount on "Too expensive," a pause on "Not using it enough," and a trial extension on "Switching to another tool."
const steps = [
{
type: 'survey',
title: 'Why are you leaving?',
reasons: [
{
id: 'expensive',
label: 'Too expensive',
offer: {
type: 'discount',
couponId: 'STRIPE_SAVE20',
percentOff: 20,
durationInMonths: 3,
},
},
{
id: 'not-using',
label: 'Not using it enough',
offer: { type: 'pause', months: 2 },
},
{
id: 'switching',
label: 'Switching to another tool',
offer: {
type: 'trial_extension',
days: 14,
},
},
{ id: 'missing', label: 'Missing a feature' },
],
},
{ type: 'feedback', title: 'Anything else?' },
{ type: 'confirm' },
]The fourth reason has no offer attached, so it falls through to the feedback step, then to confirm.
2. Wire one handler per offer type
Discount offers carry both an identifier and display fields. couponId is what your billing system uses to apply the discount. percentOff and durationInMonths are what the SDK shows the customer. The SDK doesn't validate that they match — keep them in sync yourself.
<CancelFlow
steps={steps}
handleDiscount={async (offer) => {
await fetch('/api/billing/discount', {
method: 'POST',
body: JSON.stringify({ couponId: offer.couponId }),
})
}}
handlePause={async (offer) => {
await fetch('/api/billing/pause', {
method: 'POST',
body: JSON.stringify({ months: offer.months }),
})
}}
handleTrialExtension={async (offer) => {
await fetch('/api/billing/extend-trial', {
method: 'POST',
body: JSON.stringify({ days: offer.days }),
})
}}
handleCancel={async () => {
await fetch('/api/billing/cancel', { method: 'POST' })
}}
onClose={() => setOpen(false)}
/>Three things can happen at an offer step:
- Accept. The SDK calls the matching
handle<Type>, awaits the promise, and advances tosuccess. Steps between the offer and success are skipped. - Decline. The flow advances to the next declared step. In the example above, that's feedback, then confirm.
- Throw. A handler that throws aborts the transition. The customer stays on the offer step with
errorset and can retry. Listeners don't fire, since no action committed.
Show an offer up front (standalone OfferStep)
Sometimes the offer shouldn't depend on a reason. To show a pause prompt the moment the customer clicks cancel, declare an OfferStep directly in steps.
const steps = [
{
type: 'offer',
guid: 'pause_prompt',
offer: { type: 'pause', months: 2 },
},
{
type: 'survey',
title: 'Why are you leaving?',
reasons: [
{ id: 'expensive', label: 'Too expensive' },
{ id: 'not-using', label: 'Not using it enough' },
{ id: 'missing', label: 'Missing a feature' },
],
},
{ type: 'confirm' },
]The customer sees the pause offer first. Accepting fires handlePause and advances to success. Declining moves to the survey, where reason-attached offers can still apply.
Copy is optional. The SDK synthesizes default headline, body, and button text from the offer config — the same defaults survey-attached offers receive. Override individual fields by providing a copy block:
{
type: 'offer',
guid: 'pause_prompt',
offer: {
type: 'pause',
months: 2,
copy: {
headline: 'Take a break instead?',
body: "We'll keep your data exactly where it is. Pick up where you left off in two months.",
cta: 'Pause subscription',
declineCta: 'No thanks, continue cancelling',
},
},
}Two differences from reason-attached offers:
-
reasonIdis absent on the accepted offer. A handler that varies by reason should branch on its presence:handlePause={async (offer) => { if (offer.reasonId) { await myBilling.pause(offer.months, { source: offer.reasonId }) } else { // Standalone offer — no reason context to record. await myBilling.pause(offer.months) } }} -
Set
guid. It's the stable identifier the SDK records on the session, so up-front offers can be grouped separately from reason-attached ones in analytics and A/B tests.
Standalone and reason-attached offers can coexist. The example above shows a pause up front followed by a survey whose reasons could also route to their own offers.