Configuration
Widget options control callbacks, branding, and copy. Pass them to UnchurnTrigger, useUnchurn().show(), createUnchurn().open(), or window.unchurn.open(). Offers (discount, pause, plan switch, trial extension) and cancellation reasons are configured in the dashboard.
Widget open options
| Field | Type | Default | Description |
|---|---|---|---|
onComplete | (outcome: FlowOutcome) => void | — | Called when the flow ends with any outcome. |
onError | (error: Error) => void | — | Called if the widget encounters a fatal error it cannot recover from. |
effectiveDate | Date | string | — | Date shown to the user as “access ends on X”. See effectiveDate. |
appearance | WidgetAppearanceConfig | — | Brand tokens and color scheme. See Appearance. |
copy | WidgetCopyInput | — | Override any visible string. See Copy / i18n. |
customerFirstName | string | — | First name for personalization copy. |
customerAttributes | Record<string, string | number> | — | Merchant-defined segmentation attributes. |
When you call the CDN runtime directly via window.unchurn.open(), the call additionally accepts merchantId, subscriptionId, mode, authToken, baseUrl, retries, and retryDelay. The SDK paths pass these for you from the token-endpoint response.
onComplete callback
onComplete receives a FlowOutcome object when the widget reaches a terminal outcome, including dismissal.
outcome.type | Extra fields | When it fires |
|---|---|---|
'canceled' | reasonId, feedbackText | The subscription was canceled. |
'kept' | reasonId, feedbackText | The customer kept the subscription without an offer. |
'discount_accepted' | reasonId, feedbackText | A discount coupon was applied and the subscription was retained. |
'pause_accepted' | durationMonths, reasonId, feedbackText | The subscription was paused. |
'uncanceled' | — | A previously scheduled cancellation was reversed. |
'resumed' | — | A paused subscription was resumed. |
'trial_extended' | daysExtended, reasonId, feedbackText | The trial was extended. |
'plan_switched' | targetPriceId, targetPlanName, reasonId, feedbackText | The customer switched to another plan. |
'manual_cancellation_requested' | — | The customer requested cancellation but automation was not safe. |
'cancel_already_scheduled' | cancelAt | The subscription was already scheduled to cancel. |
'dismissed' | — | The customer closed the widget. |
import { createUnchurn } from '@unchurn.dev/widget'
const unchurn = createUnchurn({ tokenEndpoint: '/api/unchurn/token' })
unchurn.open({
onComplete: (outcome) => {
switch (outcome.type) {
case 'canceled':
router.push('/account/canceled')
break
case 'pause_accepted':
console.log(`Paused for ${outcome.durationMonths} month(s)`)
break
case 'trial_extended':
console.log(`Trial extended by ${outcome.daysExtended} day(s)`)
break
case 'discount_accepted':
case 'uncanceled':
case 'resumed':
void refetchSubscription()
break
case 'dismissed':
break
}
},
})effectiveDate
effectiveDate sets the date displayed to the user as “access ends on X” — the date their subscription access actually stops if they proceed with cancellation. Pass a Date object or an ISO 8601 string.
unchurn.open({
effectiveDate: user.subscriptionCurrentPeriodEnd, // Date object or ISO string
})When omitted, the widget derives the date from the subscription’s current_period_end returned by Stripe. Pass effectiveDate explicitly when your app uses a custom billing model or you want to show a grace period end date instead.
Appearance
WidgetAppearanceConfig controls the widget’s scoped brand tokens. All fields are optional. See Make the widget on-brand for framework recipes, Tailwind/shadcn examples, and QA guidance.
| Field | Type | Default | Description |
|---|---|---|---|
theme | 'auto' | 'light' | 'dark' | 'auto' | Color scheme. 'auto' follows the OS/browser preference. |
variables.colorPrimary | string | Near-black / near-white | Primary action and selected-state color. |
variables.colorPrimaryForeground | string | Auto for static colors | Text/icon color on primary surfaces. |
variables.colorPrimaryHover | string | Derived | Primary hover state. |
variables.colorPrimaryActive | string | Derived | Primary pressed/loading state. |
variables.colorPrimaryMuted | string | Derived | Subtle selected row/radio halo. |
variables.colorSecondary | string | Neutral well | Secondary accent/well color. |
variables.colorSecondaryForeground | string | Derived | Text on secondary surfaces. |
variables.colorRing | string | Neutral focus ring | Focus ring color. |
variables.colorCanvas | string | Modal surface | Widget modal surface. |
variables.colorText | string | Primary text | Primary text color. |
variables.colorTextMuted | string | Muted text | Secondary text color. |
variables.colorTextFaint | string | Faint text | Faint helper text. |
variables.colorBorder | string | Subtle border | Subtle borders. |
variables.colorBorderStrong | string | Strong border | Stronger borders and dividers. |
variables.colorWell | string | Neutral well | Neutral wells and cards. |
variables.colorWellStrong | string | Strong well | Hover/strong well surfaces. |
variables.colorDanger | string | Destructive accent | Destructive/cancel accents. |
variables.colorDangerForeground | string | Destructive foreground | Text on destructive surfaces. |
variables.colorSuccess | string | Success accent | Success accents. |
variables.borderRadius | string | 12px | Base control radius. |
variables.fontFamily | string | System sans | Scoped widget font family. Use inherit to match the host. |
light.variables | WidgetAppearanceVariables | — | Light-mode overrides for any variable. |
dark.variables | WidgetAppearanceVariables | — | Dark-mode overrides for any variable. |
Accepted static color formats include #hex, rgb(...), rgba(...), hsl(...), hsla(...), oklch(...), and named CSS colors.
Native CSS values are also supported and passed through to the browser, including var(...), hsl(var(--token)), color-mix(...), currentColor, and inherit. For dynamic primary colors, pass colorPrimaryForeground when your brand color may be very light or very dark.
The default palette is monochrome and all tokens are scoped to #unchurn-widget-root; Unchurn does not write host body, html, or :root styles.
Example: brand color
unchurn.open({
appearance: {
theme: 'auto',
variables: {
colorPrimary: '#6366f1',
},
},
})Example: Tailwind / shadcn tokens
unchurn.open({
appearance: {
theme: 'auto',
variables: {
colorPrimary: 'hsl(var(--primary))',
colorPrimaryForeground: 'hsl(var(--primary-foreground))',
fontFamily: 'inherit',
borderRadius: 'var(--radius)',
},
},
})Copy / i18n
Every visible string in the widget can be overridden through the copy field. All fields are optional — provide only the strings you want to change. Template variables use {variableName} syntax. Unknown variables pass through unchanged.
Global navigation labels
copy: {
back: 'Back', // default
continue: 'Continue', // default
cancelAnyway: 'Cancel anyway',
noThanks: 'No thanks',
done: 'Done',
}Feedback screen
Variables: none.
copy: {
feedback: {
title: 'Reason for canceling',
subtitle: 'Your feedback helps us improve.',
textareaLabel: 'Your response',
textareaPlaceholder: 'Share any additional context.',
textareaRequiredHint: 'Required', // shown next to label when captureFeedback.required = true
},
}Cancellation reasons (id + label) are fully merchant-defined in the dashboard. The widget renders each reason’s dashboard label directly — there are no built-in reason IDs and no widget-side label override.
Discount offer screen
Variables: {percentOff}, {durationMonths}, {durationLabel}, {discountedAmount}, {currentAmount}.
copy: {
discount: {
title: '{percentOff}% off for {durationMonths} {durationLabel}.',
titleFirstPayment: 'Save {percentOff}% off your next payment', // used when discount applies only to the first invoice
subtitle: '{discountedAmount}/mo instead of {currentAmount}...',
monthLabel: 'month',
wasLabel: 'was',
ctaButton: 'Claim discount',
},
}Pause offer screen
Variables: {durationMonths}, {durationLabel}.
copy: {
pause: {
title: 'Pause your subscription.',
subtitle: undefined,
ctaButton: 'Pause for {durationMonths} {durationLabel}',
},
}Cancellation confirmation screen
Variables: {date}.
copy: {
cancelConfirm: {
title: 'Confirm cancellation',
subtitleWithPendingCharges: 'If you cancel, your subscription will remain active until {date}. A final invoice for outstanding charges or usage may still be issued.',
subtitleNeutral: 'If you cancel, your subscription will remain active until {date}.',
cancelButton: 'Cancel subscription',
keepButton: 'Keep subscription',
},
}Success screens
Each outcome has a title and body. Available variables are listed per outcome.
copy: {
success: {
// Cancel — body variants are picked automatically based on the final invoice state.
canceledTitle: undefined, // {date}
canceledBody: undefined, // {date}
canceledBodyWithPendingCharges: undefined, // {date} — outstanding charges remain
canceledBodyWithCredit: undefined, // {date}, {amount} — customer ends with credit
canceledBodyWithOwed: undefined, // {date}, {amount} — customer owes a balance
canceledBodyNeutral: undefined, // {date} — no pending charges, no credit, no owed
pendingChargesCaveatAppendix: undefined, // sentence appended when pending charges exist
// Keep / save outcomes.
keptTitle: undefined,
keptBody: undefined,
discountTitle: undefined, // {discountedAmount}, {durationMonths}, {durationLabel}
discountBody: undefined,
discountBodyFirstPayment: undefined, // shown when discount applies only to the next invoice
pauseTitle: undefined, // {durationMonths}, {durationLabel}
pauseBody: undefined,
uncanceledTitle: undefined, // {date}
uncanceledBody: undefined, // {date}
resumedTitle: undefined, // {date}
resumedBody: undefined, // {date}
trialExtendedTitle: undefined, // {daysExtended}
trialExtendedBody: undefined, // {daysExtended}
planSwitchedTitle: undefined, // {planName}
planSwitchedTitleFallback: undefined, // used when targetPlanName is unavailable
planSwitchedBody: undefined, // {date}
planSwitchedBodyNoDate: undefined, // used when the effective date is unknown
// Manual cancellation request — when the widget can't auto-cancel and a human request is filed.
supportTitle: undefined,
supportBody: undefined,
manualRequestedTitle: undefined,
manualRequestedBody: undefined,
// Already-scheduled — customer reopens after the cancel was already booked.
cancelAlreadyScheduledTitle: undefined,
cancelAlreadyScheduledBodyWithPendingCharges: undefined, // {date}
cancelAlreadyScheduledBodyNeutral: undefined, // {date}
cancelAlreadyScheduledNoDateBodyWithPendingCharges: undefined, // no end-date known
cancelAlreadyScheduledNoDateBodyNeutral: undefined, // no end-date known
},
}Trial extension offer screen
Variables: {date} and {days} in title. {days} in subtitle. ctaButton and declineButton are not interpolated.
copy: {
trialExtension: {
title: undefined,
subtitle: undefined,
ctaButton: undefined,
declineButton: undefined,
},
}Trial-subscriber screens
These strings are used only when the subscription has an active trial (i.e. trial_end is in the future).
Variables: {date} where noted.
copy: {
trial: {
feedbackSubtitle: undefined,
cancelConfirmSubtitleWithPendingCharges: undefined, // {date}
cancelConfirmSubtitleNeutral: undefined, // {date}
cancelButton: undefined,
canceledTitle: undefined,
canceledBody: undefined, // {date}
canceledBodyWithPendingCharges: undefined, // {date}
canceledBodyWithCredit: undefined, // {date}, {amount}
canceledBodyWithOwed: undefined, // {date}, {amount}
canceledBodyNeutral: undefined, // {date}
offerUnavailableTitle: undefined,
offerUnavailableBody: undefined,
offerUnavailableClose: undefined,
},
}Uncancel prompt
Shown when the subscription is already scheduled to cancel at period end.
copy: {
uncancelPrompt: {
title: undefined,
subtitle: undefined,
ctaButton: undefined,
declineButton: undefined,
},
}Resume prompt
Shown when the subscription is currently paused.
copy: {
resumePrompt: {
title: undefined,
titleIndefinite: undefined, // used when the pause has no set end date
subtitle: undefined,
ctaButton: undefined,
declineButton: undefined,
},
}French-language example
A representative override covering each section. Refer to the per-section blocks above for the full set of keys (body variants, fallback titles, manual-request and already-scheduled states).
const frenchCopy = {
back: 'Retour',
continue: 'Continuer',
cancelAnyway: 'Annuler quand même',
noThanks: 'Non merci',
done: 'Terminé',
feedback: {
title: 'Raison de l\'annulation',
subtitle: 'Vos commentaires nous aident à améliorer notre service.',
textareaLabel: 'Détails supplémentaires (facultatif)',
textareaPlaceholder: 'Partagez tout contexte additionnel.',
},
discount: {
title: '{percentOff} % de réduction pendant {durationMonths} {durationLabel}.',
subtitle: '{discountedAmount}/mois au lieu de {currentAmount}...',
monthLabel: 'mois',
wasLabel: 'était',
ctaButton: 'Profiter de la réduction',
},
pause: {
title: 'Mettre en pause votre abonnement.',
subtitle: 'Reprenez quand vous êtes prêt.',
ctaButton: 'Mettre en pause {durationMonths} {durationLabel}',
},
cancelConfirm: {
title: 'Confirmer l\'annulation',
subtitleWithPendingCharges: 'Votre abonnement reste actif jusqu\'au {date}. Une dernière facture peut être émise.',
subtitleNeutral: 'Votre abonnement reste actif jusqu\'au {date}.',
cancelButton: 'Annuler l\'abonnement',
keepButton: 'Conserver l\'abonnement',
},
success: {
canceledTitle: 'Abonnement annulé',
canceledBody: 'Votre accès se termine le {date}.',
keptTitle: 'Abonnement conservé',
keptBody: 'Nous sommes ravis de vous garder.',
discountTitle: 'Réduction appliquée',
discountBody: '{discountedAmount}/mois pendant {durationMonths} {durationLabel}.',
pauseTitle: 'Abonnement suspendu',
pauseBody: 'Suspendu pendant {durationMonths} {durationLabel}.',
supportTitle: 'Demande envoyée',
supportBody: 'Notre équipe reviendra vers vous prochainement.',
uncanceledTitle: 'Annulation annulée',
uncanceledBody: 'Votre abonnement continue jusqu\'au {date}.',
resumedTitle: 'Abonnement repris',
resumedBody: 'Votre prochain renouvellement est le {date}.',
trialExtendedTitle: 'Essai prolongé',
trialExtendedBody: 'Votre essai a été prolongé de {daysExtended} jours.',
},
trialExtension: {
title: 'Prolongez votre essai',
subtitle: 'Bénéficiez de {days} jours supplémentaires, gratuitement.',
ctaButton: 'Prolonger l\'essai',
declineButton: 'Non merci',
},
trial: {
feedbackSubtitle: 'Dites-nous comment améliorer votre expérience.',
cancelConfirmSubtitleWithPendingCharges: 'Votre essai se terminera le {date}. Une dernière facture peut être émise.',
cancelConfirmSubtitleNeutral: 'Votre essai se terminera le {date}.',
cancelButton: 'Terminer l\'essai',
canceledTitle: 'Essai terminé',
canceledBody: 'Votre accès se termine le {date}.',
offerUnavailableTitle: 'Offre non disponible',
offerUnavailableBody: 'Aucune offre de rétention n\'est disponible pour votre compte.',
offerUnavailableClose: 'Fermer',
},
uncancelPrompt: {
title: 'Annuler la résiliation programmée ?',
subtitle: 'Votre abonnement est actuellement programmé pour se terminer.',
ctaButton: 'Conserver l\'abonnement',
declineButton: 'Continuer la résiliation',
},
resumePrompt: {
title: 'Reprendre votre abonnement ?',
titleIndefinite: 'Votre abonnement est en pause.',
subtitle: 'Votre facturation reprendra normalement.',
ctaButton: 'Reprendre l\'abonnement',
declineButton: 'Rester en pause',
},
}
unchurn.open({
copy: frenchCopy,
})