Events
Every event emitted by the widget, with payload shapes and subscription instructions.
Server-side integration
Unchurn does not deliver webhooks to your server today. The events documented on this page are browser-side only, emitted on window.unchurn.events for client-side analytics. They are not a substitute for server-side state sync, and the session token is never included in their payloads.
To react server-side to retention outcomes, subscribe to Stripe events in your Stripe account. Every outcome Unchurn writes surfaces as one of two events on the affected subscription:
| Stripe event | When it fires for Unchurn outcomes |
|---|---|
customer.subscription.updated | Discount applied, pause set or cleared, plan switched, cancel-at-period-end set or unset, trial extended |
customer.subscription.deleted | Subscription cancellation completes at the end of the billing period |
The customer.subscription.updated payload includes the full subscription object. Compare against your last-known state to detect what changed: a new discount, a populated or null pause_collection, a different items[0].price.id, a new cancel_at, an extended trial_end.
Note: customer.subscription.paused and customer.subscription.resumed do NOT fire for the pauses Unchurn creates. Those events only fire when a subscription transitions to status: 'paused', which is a separate Stripe pause API that Unchurn does not use. Unchurn sets pause_collection on subscriptions that remain status: 'active'. For pause state changes, watch customer.subscription.updated and check the pause_collection field on the new subscription object.
Native Unchurn webhooks are on the roadmap. When they ship, payload shape and delivery semantics will be documented here.
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 StepAcceptedData =
| {
step: 'discount';
kind: 'percent';
duration: 'once' | 'repeating' | 'forever';
duration_in_months: number | null;
percent_off: number;
}
| { step: 'pause'; durationMonths: number }
| { step: 'plan-switch'; targetPriceId: string }
| { step: Exclude<Step, 'discount' | 'pause' | 'plan-switch'> };
export type StepAcceptedPayload = StepAcceptedData & { sessionId: string };| Field | Type | When present | Description |
|---|---|---|---|
step | Step | always | The step that was accepted |
sessionId | string | always | Session identifier |
kind | 'percent' | step === 'discount' | Discount kind |
percent_off | number | step === 'discount' | Discount percentage |
duration | 'once' | 'repeating' | 'forever' | step === 'discount' | Coupon duration semantics |
duration_in_months | number | null | step === 'discount' | Months the coupon applies for; null for once/forever |
durationMonths | number | step === 'pause' | Pause duration in months |
targetPriceId | string | step === 'plan-switch' | Stripe price ID of the target 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'
| 'followup'
| 'capture-feedback'
| 'discount'
| 'pause'
| 'confirm'
| 'uncancel-prompt'
| 'resume-prompt'
| 'trial-extension'
| 'plan-switch'
| 'success';| Step | Description |
|---|---|
feedback | Initial reason-selection screen |
followup | Branching follow-up question for reasons with a follow-up pathway |
capture-feedback | Optional free-text capture before offer routing |
discount | Discount offer screen |
pause | Pause offer 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 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.
npm SDK
The npm package lazy-loads the CDN runtime on first createUnchurn().open() or .preload() call. Once the runtime is installed, window.unchurn.events is available. Pair it with the WIDGET_EVENTS constants exported from the package so you don’t depend on the global for event names:
import { WIDGET_EVENTS } from '@unchurn.dev/widget';
// After at least one `createUnchurn(...).preload()` or `.open()` call has run,
// `window.unchurn.events` is available.
const unsubscribe = window.unchurn!.events.on(
WIDGET_EVENTS.STEP_VIEWED,
(data) => {
myAnalytics.track('cancel_step_viewed', {
step: data.step,
sessionId: data.sessionId,
});
},
);CDN runtime
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 token. Any script on the page can subscribe — including the token would leak it to scripts that have no business seeing it.
emitis not exposed onwindow.unchurn.events. Other scripts on the page can read events but cannot fake them.- 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 — loader APIs and
onCompletecallback for terminal outcomes - Architecture — how sessions and telemetry flow