Docs navigation

Callbacks

There are two callback families. handle<Type> callbacks change billing state — apply a discount, pause a subscription, cancel a customer. on<Type> callbacks run after the change commits and are for side effects: analytics, toasts, route changes. The split keeps slow analytics calls from delaying transitions and prevents billing failures from firing analytics events.

PrefixPurposeRuns when
handle<Type>The action. Apply the discount, pause the subscription, cancel the customer.The customer accepts.
on<Type>A listener. Side effects: analytics, toasts, route changes.After the action completes.

When a customer accepts an offer, the SDK walks a short decision tree:

1. Is handle<Type> defined?
     yes → call your handler. Done.
     no  → continue.
 
2. Are we in connected mode?
     yes → POST to Churnkey's server action.
     no  → no action runs.
 
3. Fire on<Type> listener (if defined).
4. Fire onAccept catch-all (if defined).

Listeners run only on success. If a handler throws (or the server action returns an error in connected mode), the transition aborts. The customer stays on the offer step with error set, can press the action again, and no listener fires.

Handlers

Handlers are the only callbacks that can change billing state. In open source and analytics they're the only path that runs anything at all. In connected mode they override Churnkey's default behavior per offer type.

CallbackWhen it firesSignature
handleDiscountA discount offer is accepted(offer: AcceptedOffer, customer) => Promise<void> | void
handlePauseA pause offer is accepted(offer: AcceptedOffer, customer) => Promise<void> | void
handlePlanChangeA plan-change offer is accepted(offer: AcceptedOffer, customer) => Promise<void> | void
handleTrialExtensionA trial-extension offer is accepted(offer: AcceptedOffer, customer) => Promise<void> | void
handleCancelThe customer confirms cancellation(customer) => Promise<void> | void

Every offer handler receives the union AcceptedOffer, not a narrowed per-type shape. To read type-specific fields like couponId or months, narrow on offer.type first:

handleDiscount={async (offer) => {
  if (offer.type !== 'discount') return    // narrows offer to DiscountOffer
  await myBilling.applyCoupon(offer.couponId)
}}

See Offer types for each shape.

The customer argument carries whatever is in the flow's customer state — the customer prop in open source / analytics, or the customer Churnkey resolved from the token in connected mode. It's null only when no customer is in scope.

Custom offers don't have a dedicated handle<Type> slot. Accepts flow through the catch-all onAccept — see Custom offer types below.

Listeners

Listeners fire after the action commits. They receive the same offer payload as the handler plus the customer, but can't change the flow's outcome.

CallbackWhen it firesSignature
onAcceptAny offer is accepted (catch-all)(offer: AcceptedOffer, customer) => void | Promise<void>
onDiscountA discount is accepted(offer: AcceptedOffer, customer) => void | Promise<void>
onPauseA pause is accepted(offer: AcceptedOffer, customer) => void | Promise<void>
onPlanChangeA plan change is accepted(offer: AcceptedOffer, customer) => void | Promise<void>
onTrialExtensionA trial extension is accepted(offer: AcceptedOffer, customer) => void | Promise<void>
onCancelCancellation completes(customer) => void | Promise<void>
onCloseThe modal closes() => void
onStepChangeThe flow advances or goes back(step, prevStep) => void

Per-type listeners fire first, then the catch-all onAccept. Use per-type listeners for type-specific behavior — one analytics event per offer type. Use onAccept when you only care that some offer was accepted, like a single "save event" for your funnel. Listener errors are swallowed, so a broken analytics call can't break the flow.

AcceptedOffer

The argument every accept callback receives is the offer config the SDK was rendering, plus a couple of fields the SDK adds on the way out.

type AcceptedOffer = OfferConfig & {
  reasonId?: string                     // present when a survey reason routed here
  result?:   Record<string, unknown>    // custom-offer payload
}

reasonId is the survey reason that routed to this offer. It's absent when the offer was declared as a standalone OfferStep — those don't come from a survey choice. Branch on its presence if your handler varies by reason.

result is for custom offers — whatever your component passed to onAccept(result). Built-in offer types don't use it.

Narrow on offer.type to access type-specific fields:

onAccept={(offer) => {
  switch (offer.type) {
    case 'discount':
      analytics.track('Discount Accepted', { reason: offer.reasonId, percent: offer.percentOff })
      break
    case 'pause':
      analytics.track('Pause Accepted',    { reason: offer.reasonId, months: offer.months })
      break
    case 'change-seats': {                // custom offer
      const { seats } = offer.result as { seats: number }
      analytics.track('Seats Reduced',     { from: offer.data.currentSeats, to: seats })
      break
    }
  }
}}

Connected mode behavior

The matrix below covers the four configurations of handle<Type> and session. "Defined" means your code wins; "undefined" means Churnkey wins in connected mode, or nothing happens otherwise.

handle<Type> definedsession providedBehavior on accept
nonoNo action. Listener still fires.
noyesChurnkey applies the action via your connected provider.
yesnoYour handler runs. Listener fires after.
yesyesYour handler runs. Churnkey does not. Listener fires after.

The most common production pattern is the mixed case: Churnkey handles discount, pause, trial extension, and cancel through the connected provider, while you keep one or two handlers client-side where your logic doesn't fit a server abstraction — custom proration, downstream system writes, audit-log requirements. Each offer type is decided independently.

Custom offer types

Custom offers go through onAccept rather than a dedicated handler because their semantics are open-ended. A built-in discount always applies a coupon, so the SDK can call your billing provider for you. A custom change-seats offer might call your billing API, queue a job, or write to a sibling system — the SDK can't infer the right action.

<CancelFlow
  steps={[{
    type: 'survey',
    reasons: [{
      id: 'too-many-seats',
      label: 'Too many seats',
      offer: { type: 'change-seats', data: { currentSeats: 10 } },
    }],
  }, { type: 'confirm' }]}
  customComponents={{
    'change-seats': ({ offer, onAccept }) => (
      <SeatPicker
        current={offer.data.currentSeats}
        onConfirm={(seats) => onAccept({ seats })}
      />
    ),
  }}
  onAccept={async (offer) => {
    if (offer.type === 'change-seats') {
      const { seats } = offer.result as { seats: number }
      await myBilling.changeSeats(seats)
    }
  }}
/>

offer.result is whatever your component passed to its internal onAccept(result). The SDK carries it through to the catch-all listener and records it with the session. See Custom step + offer types.

Next steps