Skip to Content
WidgetConfiguration

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

FieldTypeDefaultDescription
onComplete(outcome: FlowOutcome) => voidCalled when the flow ends with any outcome.
onError(error: Error) => voidCalled if the widget encounters a fatal error it cannot recover from.
effectiveDateDate | stringDate shown to the user as “access ends on X”. See effectiveDate.
appearanceWidgetAppearanceConfigBrand tokens and color scheme. See Appearance.
copyWidgetCopyInputOverride any visible string. See Copy / i18n.
customerFirstNamestringFirst name for personalization copy.
customerAttributesRecord<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.typeExtra fieldsWhen it fires
'canceled'reasonId, feedbackTextThe subscription was canceled.
'kept'reasonId, feedbackTextThe customer kept the subscription without an offer.
'discount_accepted'reasonId, feedbackTextA discount coupon was applied and the subscription was retained.
'pause_accepted'durationMonths, reasonId, feedbackTextThe subscription was paused.
'uncanceled'A previously scheduled cancellation was reversed.
'resumed'A paused subscription was resumed.
'trial_extended'daysExtended, reasonId, feedbackTextThe trial was extended.
'plan_switched'targetPriceId, targetPlanName, reasonId, feedbackTextThe customer switched to another plan.
'manual_cancellation_requested'The customer requested cancellation but automation was not safe.
'cancel_already_scheduled'cancelAtThe 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.

FieldTypeDefaultDescription
theme'auto' | 'light' | 'dark''auto'Color scheme. 'auto' follows the OS/browser preference.
variables.colorPrimarystringNear-black / near-whitePrimary action and selected-state color.
variables.colorPrimaryForegroundstringAuto for static colorsText/icon color on primary surfaces.
variables.colorPrimaryHoverstringDerivedPrimary hover state.
variables.colorPrimaryActivestringDerivedPrimary pressed/loading state.
variables.colorPrimaryMutedstringDerivedSubtle selected row/radio halo.
variables.colorSecondarystringNeutral wellSecondary accent/well color.
variables.colorSecondaryForegroundstringDerivedText on secondary surfaces.
variables.colorRingstringNeutral focus ringFocus ring color.
variables.colorCanvasstringModal surfaceWidget modal surface.
variables.colorTextstringPrimary textPrimary text color.
variables.colorTextMutedstringMuted textSecondary text color.
variables.colorTextFaintstringFaint textFaint helper text.
variables.colorBorderstringSubtle borderSubtle borders.
variables.colorBorderStrongstringStrong borderStronger borders and dividers.
variables.colorWellstringNeutral wellNeutral wells and cards.
variables.colorWellStrongstringStrong wellHover/strong well surfaces.
variables.colorDangerstringDestructive accentDestructive/cancel accents.
variables.colorDangerForegroundstringDestructive foregroundText on destructive surfaces.
variables.colorSuccessstringSuccess accentSuccess accents.
variables.borderRadiusstring12pxBase control radius.
variables.fontFamilystringSystem sansScoped widget font family. Use inherit to match the host.
light.variablesWidgetAppearanceVariablesLight-mode overrides for any variable.
dark.variablesWidgetAppearanceVariablesDark-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.

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, })