Skip to Content
ReferenceEvents

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 eventWhen it fires for Unchurn outcomes
customer.subscription.updatedDiscount applied, pause set or cleared, plan switched, cancel-at-period-end set or unset, trial extended
customer.subscription.deletedSubscription 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);
ConstantEvent nameWhen it fires
WIDGET_EVENTS.STEP_VIEWEDunchurn:step_viewedA flow step becomes visible to the customer
WIDGET_EVENTS.STEP_ACCEPTEDunchurn:step_acceptedThe customer accepts the offer on the current step
WIDGET_EVENTS.STEP_DECLINEDunchurn:step_declinedThe 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; };
FieldTypeDescription
stepStepThe step that became visible (see Step union below)
sessionIdstringSession 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 };
FieldTypeWhen presentDescription
stepStepalwaysThe step that was accepted
sessionIdstringalwaysSession identifier
kind'percent'step === 'discount'Discount kind
percent_offnumberstep === 'discount'Discount percentage
duration'once' | 'repeating' | 'forever'step === 'discount'Coupon duration semantics
duration_in_monthsnumber | nullstep === 'discount'Months the coupon applies for; null for once/forever
durationMonthsnumberstep === 'pause'Pause duration in months
targetPriceIdstringstep === 'plan-switch'Stripe price ID of the target plan

unchurn:step_declined

// packages/widget/src/events.ts export type StepDeclinedPayload = { step: Step; sessionId: string; };
FieldTypeDescription
stepStepThe step that was declined
sessionIdstringSession 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';
StepDescription
feedbackInitial reason-selection screen
followupBranching follow-up question for reasons with a follow-up pathway
capture-feedbackOptional free-text capture before offer routing
discountDiscount offer screen
pausePause offer screen
confirmCancel confirmation screen
uncancel-promptPrompt for subscriptions already scheduled to cancel
resume-promptPrompt for paused subscriptions
trial-extensionTrial extension offer screen
plan-switchPlan switch offer screen
successFinal 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, ): () => void

Call 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.
  • emit is not exposed on window.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.

  • Widget API — loader APIs and onComplete callback for terminal outcomes
  • Architecture — how sessions and telemetry flow