Events
Every event emitted by the widget, with payload shapes and subscription instructions.
Event names
Sourced from packages/widget/src/events.ts:
// packages/widget/src/events.ts
export const WIDGET_EVENTS = Object.freeze({
STEP_VIEWED: 'unchurn:step_viewed',
STEP_ACCEPTED: 'unchurn:step_accepted',
STEP_DECLINED: 'unchurn:step_declined',
} as const);| Constant | Event name | When it fires |
|---|---|---|
WIDGET_EVENTS.STEP_VIEWED | unchurn:step_viewed | A flow step becomes visible to the customer |
WIDGET_EVENTS.STEP_ACCEPTED | unchurn:step_accepted | The customer accepts the offer on the current step |
WIDGET_EVENTS.STEP_DECLINED | unchurn:step_declined | The customer declines the offer on the current step |
Payload shapes
unchurn:step_viewed
// packages/widget/src/events.ts
export type StepViewedPayload = {
step: Step;
sessionId: string;
};| Field | Type | Description |
|---|---|---|
step | Step | The step that became visible (see Step union below) |
sessionId | string | Session identifier from createSession |
unchurn:step_accepted
// packages/widget/src/events.ts
export type StepAcceptedPayload = StepAcceptedData & { sessionId: string };
export type StepAcceptedData =
| {
step: 'discount';
kind: WidgetDiscountTerms['kind'];
durationMonths: WidgetDiscountTerms['durationMonths'];
percentOff: WidgetDiscountTerms['percentOff'];
}
| { step: 'pause'; durationMonths: WidgetPauseDurations[number] }
| { step: 'plan-switch'; targetPriceId: WidgetPlanSwitchTarget['priceId'] }
| { step: Exclude<Step, 'discount' | 'pause' | 'plan-switch'> };| Field | Type | When present | Description |
|---|---|---|---|
step | Step | always | The step that was accepted |
sessionId | string | always | Session identifier |
kind | 'percent' | step === 'discount' | Discount kind (only 'percent' ships at launch) |
percentOff | number | step === 'discount' | Discount percentage |
durationMonths | number | step === 'discount' or step === 'pause' | Duration in months |
targetPriceId | string | step === 'plan-switch' | Stripe price ID of the selected plan |
unchurn:step_declined
// packages/widget/src/events.ts
export type StepDeclinedPayload = {
step: Step;
sessionId: string;
};| Field | Type | Description |
|---|---|---|
step | Step | The step that was declined |
sessionId | string | Session identifier |
Step union
All valid values for the step field across all event payloads:
// packages/widget/src/flow-routing.ts
export type Step =
| 'feedback'
| 'discount'
| 'pause'
| 'missing-feature'
| 'technical-issue'
| 'switching-product'
| 'confirm'
| 'uncancel-prompt'
| 'resume-prompt'
| 'trial-extension'
| 'plan-switch'
| 'success';| Step | Description |
|---|---|
feedback | Initial reason-selection screen |
discount | Discount offer screen |
pause | Pause offer screen |
missing-feature | Missing feature capture screen |
technical-issue | Technical issue capture screen |
switching-product | Switching product capture screen |
confirm | Cancel confirmation screen |
uncancel-prompt | Prompt for subscriptions already scheduled to cancel |
resume-prompt | Prompt for paused subscriptions |
trial-extension | Trial extension offer screen |
plan-switch | Plan switch (downgrade) offer screen |
success | Final success screen |
Subscribing from merchant code
Events are emitted on window.unchurn.events. Only on is exposed publicly — emit is not, to prevent co-resident scripts from injecting authenticated telemetry.
// your-analytics.ts
window.unchurn.events.on(
window.unchurn.events.EVENTS.STEP_VIEWED,
(data) => {
myAnalytics.track('cancel_step_viewed', {
step: data.step,
sessionId: data.sessionId,
});
}
);
window.unchurn.events.on(
window.unchurn.events.EVENTS.STEP_ACCEPTED,
(data) => {
myAnalytics.track('cancel_step_accepted', data);
}
);The on function returns an unsubscribe function:
// packages/widget/src/events.ts
export function on<K extends EventKey>(
event: K,
handler: (data: Payloads[K]) => void,
): () => voidCall the returned function to stop receiving events.
Security contract
- Event payloads never contain the session JWT. Any merchant-origin script can subscribe, so including the JWT would be a cross-script exfiltration surface.
emitis not exposed onwindow.unchurn.events. A public emit would let co-resident scripts inject authenticated telemetry under the user’s session.- Handlers run in a
try/catch. A throwing handler never halts other subscribers or the emitter. - Events are deferred to a macrotask (
setTimeout 0) so handlers never block React renders.
Cross-links
- Widget API —
showCancelFlow—onCompletecallback for terminal outcomes - Architecture — how sessions and telemetry flow
Last updated on