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.
| Prefix | Purpose | Runs 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.
| Callback | When it fires | Signature |
|---|---|---|
handleDiscount | A discount offer is accepted | (offer: AcceptedOffer, customer) => Promise<void> | void |
handlePause | A pause offer is accepted | (offer: AcceptedOffer, customer) => Promise<void> | void |
handlePlanChange | A plan-change offer is accepted | (offer: AcceptedOffer, customer) => Promise<void> | void |
handleTrialExtension | A trial-extension offer is accepted | (offer: AcceptedOffer, customer) => Promise<void> | void |
handleCancel | The 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.
| Callback | When it fires | Signature |
|---|---|---|
onAccept | Any offer is accepted (catch-all) | (offer: AcceptedOffer, customer) => void | Promise<void> |
onDiscount | A discount is accepted | (offer: AcceptedOffer, customer) => void | Promise<void> |
onPause | A pause is accepted | (offer: AcceptedOffer, customer) => void | Promise<void> |
onPlanChange | A plan change is accepted | (offer: AcceptedOffer, customer) => void | Promise<void> |
onTrialExtension | A trial extension is accepted | (offer: AcceptedOffer, customer) => void | Promise<void> |
onCancel | Cancellation completes | (customer) => void | Promise<void> |
onClose | The modal closes | () => void |
onStepChange | The 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> defined | session provided | Behavior on accept |
|---|---|---|
| no | no | No action. Listener still fires. |
| no | yes | Churnkey applies the action via your connected provider. |
| yes | no | Your handler runs. Listener fires after. |
| yes | yes | Your 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
<CancelFlow>reference — the full prop list.- Concepts — the broader mental model behind handlers and listeners.
- Integration levels — when handlers run vs when Churnkey takes over.