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:

  1. Accept. The SDK calls the matching handle<Type>, awaits the promise, and advances to success. Steps between the offer and success are skipped.
  2. Decline. The flow advances to the next declared step. In the example above, that's feedback, then confirm.
  3. Throw. A handler that throws aborts the transition. The customer stays on the offer step with error set 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:

  • reasonId is 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.

Next steps

  • Callbacks — full handler/listener contracts and per-type signatures.
  • Theming — customize the look without touching the flow logic.