Configuration
showCancelFlow accepts a configuration object that controls which Stripe subscription is targeted, how the widget behaves, what offers are available, and how it looks and reads. All fields except subscriptionId and merchantId are optional.
showCancelFlow options
| Field | Type | Default | Description |
|---|---|---|---|
subscriptionId | string | — | Required. The Stripe subscription ID for the subscriber initiating the flow. |
merchantId | string | — | Required. Your Unchurn merchant ID (from your dashboard). |
authToken | string | — | A server-signed auth token. Required for production. See Auth Tokens. |
mode | 'test' | 'live' | 'live' | Which Stripe account to target. See Test & Live Modes. |
baseUrl | string | 'https://app.unchurn.dev' | Override the API base URL. Useful for self-hosted or proxy setups. |
retries | number | 3 | Number of automatic retries on network failure. |
retryDelay | number | 500 | Milliseconds to wait between retries. |
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. |
pause | PauseConfig | — | Configure available pause durations. See pause.allowedDurations. |
support | SupportConfig | — | Configure the support contact experience. See Support config. |
effectiveDate | Date | string | — | Date shown to the user as “access ends on X”. See effectiveDate. |
theme | WidgetThemeConfig | — | Brand colors and color scheme. See Theme. |
copy | WidgetCopyInput | — | Override any visible string. See Copy / i18n. |
onComplete callback
onComplete receives a FlowOutcome object when the widget closes after a completed action. It is not called when the user simply dismisses the widget; use { type: 'abandoned' } to detect that.
outcome.type | Extra fields | When it fires |
|---|---|---|
'canceled' | — | The subscription was canceled. |
'paused' | pauseMonths: number | The subscription was paused. pauseMonths is the duration the user selected. |
'discounted' | — | A discount coupon was applied and the subscription was retained. |
'abandoned' | — | The user closed the widget without completing any action. |
'uncanceled' | — | A previously scheduled cancellation was reversed. |
'resumed' | — | A paused subscription was resumed. |
'trial_extended' | daysExtended: number | The trial was extended. daysExtended is the number of days added. |
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
onComplete: (outcome) => {
switch (outcome.type) {
case 'canceled':
router.push('/account/canceled')
break
case 'paused':
console.log(`Paused for ${outcome.pauseMonths} month(s)`)
break
case 'trial_extended':
console.log(`Trial extended by ${outcome.daysExtended} day(s)`)
break
case 'discounted':
case 'uncanceled':
case 'resumed':
// subscription retained — refresh subscription state
void refetchSubscription()
break
case 'abandoned':
// user dismissed the widget
break
}
},
})pause.allowedDurations
Control which pause durations are offered to the user. Durations are in months.
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
pause: {
allowedDurations: [1, 3, 6], // offer 1 month, 3 months, 6 months
},
})When pause is omitted, available durations are determined by your dashboard configuration. Providing allowedDurations at call time overrides the dashboard defaults for that specific invocation.
If the pause offer is not enabled in your dashboard, this field has no effect — the pause screen will not appear regardless.
Support config
SupportConfig controls what happens when a user selects “Technical issues” as their cancellation reason.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | — | Link to your support page or help center. Opens in a new tab. |
label | string | 'Contact support' | Button label on the technical-issue screen. |
description | string | — | Subtitle shown on the technical-issue screen beneath the heading. |
onContact | () => void | — | Callback fired when the user clicks the support button. Fires in addition to opening url when both are provided. |
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
support: {
url: 'https://help.yourapp.com',
label: 'Open Help Center',
description: 'Our support team typically replies within one business day.',
onContact: () => {
analytics.track('support_clicked_from_cancel_flow')
},
},
})When url is provided, the widget shows a prominent contact button. When only onContact is provided without a url, the callback fires on button click but no tab is opened. When neither is configured, the technical-issue screen falls back to a describe-your-issue text input that submits feedback.
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.
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
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.
Theme
WidgetThemeConfig controls the color scheme and brand colors. All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
mode | 'auto' | 'light' | 'dark' | 'auto' | Color scheme. 'auto' follows the OS/browser preference. |
light.primary | string | Near-black | Primary accent color in light mode. Any CSS color format. |
light.secondary | string | — | Secondary accent color in light mode. |
dark.primary | string | Near-white | Primary accent color in dark mode. |
dark.secondary | string | — | Secondary accent color in dark mode. |
Accepted color formats: oklch(...), hsl(...), rgb(...), #hex.
When you provide only light.primary, a reasonable dark.primary is automatically derived via an OKLCH lightness shift. You do not need to specify both unless you want precise control over the dark-mode accent.
The default palette is monochrome (near-black on white in light mode, near-white on dark in dark mode).
Example: brand color
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
theme: {
mode: 'auto',
light: {
primary: '#6366f1', // indigo-500
},
dark: {
primary: '#818cf8', // indigo-400, slightly lighter for dark backgrounds
},
},
})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: 'Additional details (optional)',
textareaPlaceholder: 'Share any additional context.',
reasonLabels: {
// Override individual reason labels by ID
too_expensive: 'Too expensive',
not_using: 'Not using it enough',
missing_feature: 'Missing a feature I need',
technical_issues: 'Technical issues',
switching_product: 'Switching to another product',
other: 'Other',
},
},
}Default cancellation reason IDs: too_expensive, not_using, missing_feature, technical_issues, switching_product, other.
Discount offer screen
Variables: {percentOff}, {durationMonths}, {durationLabel}, {discountedAmount}, {currentAmount}.
copy: {
discount: {
title: '{percentOff}% off for {durationMonths} {durationLabel}.',
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}',
},
}Feedback detail screens
These screens appear for specific cancellation reasons and each accept title, subtitle, and placeholder.
copy: {
missingFeature: {
title: undefined,
subtitle: undefined,
placeholder: undefined,
},
switchingProduct: {
title: undefined,
subtitle: undefined,
placeholder: undefined,
},
technicalIssue: {
contactTitle: undefined,
contactSubtitle: undefined,
contactButton: undefined,
describeTitle: undefined,
describeSubtitle: undefined,
describePlaceholder: undefined,
},
}Cancellation confirmation screen
Variables: {date}.
copy: {
cancelConfirm: {
title: 'Confirm cancellation',
subtitle: 'Your subscription will end on {date}...',
cancelButton: 'Cancel subscription',
keepButton: 'Keep subscription',
},
}Success screens
Each outcome has a title and body. Available variables are listed per outcome.
copy: {
success: {
canceledTitle: undefined, // {date}
canceledBody: undefined, // {date}
keptTitle: undefined,
keptBody: undefined,
discountTitle: undefined, // {discountedAmount}, {durationMonths}, {durationLabel}
discountBody: undefined,
pauseTitle: undefined, // {durationMonths}, {durationLabel}
pauseBody: undefined,
supportTitle: undefined,
supportBody: undefined,
uncanceledTitle: undefined, // {date}
uncanceledBody: undefined, // {date}
resumedTitle: undefined, // {date}
resumedBody: undefined, // {date}
trialExtendedTitle: undefined, // {daysExtended}
trialExtendedBody: undefined, // {daysExtended}
},
}Trial extension offer screen
Variables: {days}.
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,
cancelConfirmSubtitle: undefined, // {date}
cancelButton: undefined,
canceledTitle: undefined,
canceledBody: 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,
},
}Complete French-language example
The example below overrides every user-visible string for a French-language product. Pass this object to the copy field of showCancelFlow.
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.',
reasonLabels: {
too_expensive: 'Trop cher',
not_using: 'Je ne l\'utilise pas assez',
missing_feature: 'Il manque une fonctionnalité',
technical_issues: 'Problèmes techniques',
switching_product: 'Je passe à un autre produit',
other: 'Autre',
},
},
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}',
},
missingFeature: {
title: 'Dites-nous ce qui manque',
subtitle: 'Nous prenons toutes les suggestions en compte.',
placeholder: 'Décrivez la fonctionnalité souhaitée.',
},
switchingProduct: {
title: 'Vers quel produit passez-vous ?',
subtitle: 'Votre retour nous aide à nous améliorer.',
placeholder: 'Nom du produit ou raison.',
},
technicalIssue: {
contactTitle: 'Rencontrez-vous un problème technique ?',
contactSubtitle: 'Notre équipe peut vous aider rapidement.',
contactButton: 'Contacter le support',
describeTitle: 'Décrivez le problème',
describeSubtitle: 'Nous allons enquêter.',
describePlaceholder: 'Décrivez le problème que vous rencontrez.',
},
cancelConfirm: {
title: 'Confirmer l\'annulation',
subtitle: 'Votre abonnement se terminera le {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.',
cancelConfirmSubtitle: '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',
},
}
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
copy: frenchCopy,
})