Skip to Content
ReferenceWidget API

Widget API

Full signatures and behavior notes for the public @unchurn.dev/widget exports.


npm Loader API: @unchurn.dev/widget

The package root is a loader SDK. It does not bundle a second copy of the widget UI. It loads the hosted runtime from https://cdn.unchurn.dev/widget.js unless you pass scriptUrl.

DEFAULT_WIDGET_SCRIPT_URL

export const DEFAULT_WIDGET_SCRIPT_URL = 'https://cdn.unchurn.dev/widget.js'

createUnchurn

Creates a client that manages runtime loading and token lifecycle.

export function createUnchurn(options: CreateUnchurnOptions): UnchurnClient
OptionTypeRequiredDescription
tokenEndpointstringyesYour server endpoint that returns { authToken, expiresAt, merchantId, subscriptionId, mode }
baseUrlstringnoOverride the Unchurn API base URL
scriptUrlstringnoOverride the hosted runtime script URL
fetchImpltypeof fetchnoCustom fetch implementation
defaultFlowWidgetOpenOptionsnoDefault widget options merged into each open() call
export type UnchurnClient = { preload: () => Promise<void> open: (options?: WidgetOpenOptions) => Promise<void> close: () => Promise<void> abort: () => void }

Every open() re-fetches a fresh token — there is no client-side cache. This prevents a previous user’s token from opening the wrong subscription after a SPA auth switch. Concurrent open() calls within one tick share a single in-flight fetch via dedup.

Example:

import { createUnchurn } from '@unchurn.dev/widget' const unchurn = createUnchurn({ tokenEndpoint: '/api/unchurn/token' }) document.querySelector('#cancel-button')?.addEventListener('click', () => { void unchurn.open({ onComplete(outcome) { if (outcome.type === 'canceled') window.location.reload() }, }) })

WidgetOpenOptions

Options that can be passed to client.open(), React components, useUnchurn().show(), or window.unchurn.open().

FieldTypeDescription
onComplete(outcome: FlowOutcome) => voidCalled when the flow reaches a terminal outcome
onError(error: Error) => voidCalled when the widget reports a fatal render/runtime error
effectiveDateDate | stringDate shown as the access end date
appearanceWidgetAppearanceConfigScoped brand tokens and color-scheme settings
copyWidgetCopyInputCopy overrides
customerFirstNamestringPersonalization copy only
customerAttributesRecord<string, string | number>Merchant-defined segmentation attributes

WidgetAppearanceConfig

Controls widget styling through scoped CSS variables on #unchurn-widget-root.

export type WidgetAppearanceConfig = { theme?: 'auto' | 'light' | 'dark' variables?: WidgetAppearanceVariables light?: { variables?: WidgetAppearanceVariables } dark?: { variables?: WidgetAppearanceVariables } } export type WidgetAppearanceVariables = { colorCanvas?: string colorText?: string colorTextMuted?: string colorTextFaint?: string colorBorder?: string colorBorderStrong?: string colorWell?: string colorWellStrong?: string colorPrimary?: string colorPrimaryForeground?: string colorPrimaryHover?: string colorPrimaryActive?: string colorPrimaryMuted?: string colorSecondary?: string colorSecondaryForeground?: string colorRing?: string colorDanger?: string colorDangerForeground?: string colorSuccess?: string borderRadius?: string fontFamily?: string }

Use Appearance for copy-paste examples, Tailwind/shadcn tokens, dark mode, and supported color formats.


React: @unchurn.dev/widget/react

All React exports are client-side. The entry file includes 'use client'.

UnchurnTrigger

The fastest React integration.

import { UnchurnTrigger } from '@unchurn.dev/widget/react' <UnchurnTrigger tokenEndpoint="/api/unchurn/token"> Cancel subscription </UnchurnTrigger>

It renders a <button type="button"> and defers the token + runtime fetch until the user clicks. Pass lazy={false} to preload on mount for faster click response.

UnchurnTrigger with asChild

Use asChild to keep your own button component. The trigger preserves the child’s onClick and respects event.preventDefault().

import { UnchurnTrigger } from '@unchurn.dev/widget/react' <UnchurnTrigger tokenEndpoint="/api/unchurn/token" asChild> <button className="danger">Cancel subscription</button> </UnchurnTrigger>

UnchurnTrigger accepts normal button props, UseUnchurnOptions, and WidgetOpenOptions.

useUnchurn

Hook for custom UI.

export function useUnchurn(opts: UseUnchurnOptions): UseUnchurnReturn
OptionTypeRequiredDefaultDescription
tokenEndpointstringyesYour server token endpoint
baseUrlstringnohosted APIOverride the Unchurn API base URL
scriptUrlstringnohosted CDN runtimeOverride the runtime script URL
fetchImpltypeof fetchnoglobal fetchCustom fetch implementation
defaultFlowWidgetOpenOptionsnoDefault options merged into each show() call
lazybooleannotrueDefer token + runtime preload to the first show() call. Set false to fetch on mount for faster click response.
export type UseUnchurnReturn = { show: (options?: WidgetOpenOptions) => Promise<void> close: () => void isReady: boolean /** * True between a `show()` call and its resolution — token fetch + * runtime load + widget mount. Disable your trigger while true. * Concurrent show() calls share a single transition. */ isOpening: boolean error: Error | null }

Example:

'use client' import { useUnchurn } from '@unchurn.dev/widget/react' export function CancelButton() { const { show, isReady, error } = useUnchurn({ tokenEndpoint: '/api/unchurn/token', }) if (error) return <p>Unable to load cancellation options.</p> return ( <button disabled={!isReady} onClick={() => void show()}> Cancel subscription </button> ) }

Server: @unchurn.dev/widget/server

Framework-agnostic. Use createUnchurnHandler in any Fetch-API runtime (Next App Router, Remix, SvelteKit, Hono, Cloudflare Workers, Vercel Edge, Bun, Deno). Use mintUnchurnToken in any Node-http runtime (Next Pages Router, Express, Fastify, Koa, AWS Lambda).

createUnchurnHandler

Returns a (Request) => Promise<Response> that mints HMAC-signed tokens.

export function createUnchurnHandler( opts: CreateUnchurnHandlerOptions, ): (req: Request) => Promise<Response>
OptionTypeRequiredDefaultDescription
secretstringyesHMAC signing secret from the dashboard
merchantIdstringyesMerchant identifier from the dashboard
resolveUser(req: Request) => Promise<UnchurnUserContext | null>yesAuth resolver; return null to emit 401
tokenTtlSecondsnumberno600Token TTL in seconds, capped at 600 (10 minutes)
export type UnchurnUserContext = { subscriptionId: string mode?: 'test' | 'live' }

Successful response:

export type UnchurnTokenResponse = { authToken: string expiresAt: string merchantId: string subscriptionId: string mode: 'test' | 'live' }

Example:

import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { getCurrentUser } from '@/lib/auth' export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async (req) => { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId, mode: process.env.NODE_ENV === 'production' ? 'live' : 'test', } }, })

mintUnchurnToken

Pure function for runtimes that don’t hand you a Web Request. You write the HTTP plumbing; this function does the HMAC. Returns the same shape as createUnchurnHandler’s response body, so the two paths are interchangeable.

export function mintUnchurnToken(opts: MintUnchurnTokenOptions): UnchurnTokenResponse
OptionTypeRequiredDefaultDescription
secretstringyesHMAC signing secret
merchantIdstringyesMerchant identifier
subscriptionIdstringyesStripe subscription ID for the authorised user
mode'test' | 'live'no'live'Stripe environment
ttlSecondsnumberno600Token TTL in seconds, capped at 600

Throws on empty secret, illegal merchantId / subscriptionId characters, or out-of-range ttlSeconds.

// Express import { mintUnchurnToken } from '@unchurn.dev/widget/server' router.post('/api/unchurn/token', async (req, res) => { const user = req.session?.user if (!user?.stripeSubscriptionId) return res.status(401).end() const token = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, }) res.json(token) })

Token Helpers

Advanced exports for custom signing/debugging:

export function encodePayload(params: { merchantId: string subscriptionId: string mode: 'test' | 'live' expMs: number }): string export function signPayload(secret: string, payloadB64: string): string export function verifyAuthToken( token: string, secret: string, ): { merchantId: string; subscriptionId: string; mode: 'test' | 'live'; expMs: number } | null export function splitTokenPrefix( token: string, ): { prefixMode: 'test' | 'live'; body: string } | null

CDN Runtime: window.unchurn

When loaded directly from the CDN, the runtime installs:

window.unchurn = { // Accepts either { tokenEndpoint, ...flowOpts } (runtime fetches the token) // or the pre-minted-token shape { merchantId, subscriptionId, mode, authToken, ...flowOpts }. open: (options: UnchurnOpenOptions) => Promise<void>, close: () => void, events: { on: <K extends EventKey>(event: K, handler: (data: Payloads[K]) => void) => () => void, EVENTS: typeof WIDGET_EVENTS, }, }

The CDN runtime injects the widget stylesheet and owns the actual React UI. The npm loader APIs call into this same runtime.

window.unchurn.open({ tokenEndpoint }) performs no caching — each call re-fetches a fresh token. Ambiguous combinations throw synchronously: passing tokenEndpoint alongside authToken, mode, merchantId, or subscriptionId rejects with an explicit error before any network request fires.


FlowOutcome

onComplete receives:

export type FlowOutcome = | { type: 'canceled'; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'kept'; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'discount_accepted'; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'pause_accepted'; durationMonths: number; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'uncanceled' } | { type: 'resumed' } | { type: 'trial_extended'; daysExtended: number; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'plan_switched'; targetPriceId: string; targetPlanName: string; reasonId: ReasonId | null; feedbackText: string; followUpAnswerId?: string } | { type: 'manual_cancellation_requested' } | { type: 'cancel_already_scheduled'; cancelAt: Date | null } | { type: 'dismissed' }