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| Option | Type | Required | Description |
|---|---|---|---|
tokenEndpoint | string | yes | Your server endpoint that returns { authToken, expiresAt, merchantId, subscriptionId, mode } |
baseUrl | string | no | Override the Unchurn API base URL |
scriptUrl | string | no | Override the hosted runtime script URL |
fetchImpl | typeof fetch | no | Custom fetch implementation |
defaultFlow | WidgetOpenOptions | no | Default 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().
| Field | Type | Description |
|---|---|---|
onComplete | (outcome: FlowOutcome) => void | Called when the flow reaches a terminal outcome |
onError | (error: Error) => void | Called when the widget reports a fatal render/runtime error |
effectiveDate | Date | string | Date shown as the access end date |
appearance | WidgetAppearanceConfig | Scoped brand tokens and color-scheme settings |
copy | WidgetCopyInput | Copy overrides |
customerFirstName | string | Personalization copy only |
customerAttributes | Record<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| Option | Type | Required | Default | Description |
|---|---|---|---|---|
tokenEndpoint | string | yes | — | Your server token endpoint |
baseUrl | string | no | hosted API | Override the Unchurn API base URL |
scriptUrl | string | no | hosted CDN runtime | Override the runtime script URL |
fetchImpl | typeof fetch | no | global fetch | Custom fetch implementation |
defaultFlow | WidgetOpenOptions | no | — | Default options merged into each show() call |
lazy | boolean | no | true | Defer 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>| Option | Type | Required | Default | Description |
|---|---|---|---|---|
secret | string | yes | — | HMAC signing secret from the dashboard |
merchantId | string | yes | — | Merchant identifier from the dashboard |
resolveUser | (req: Request) => Promise<UnchurnUserContext | null> | yes | — | Auth resolver; return null to emit 401 |
tokenTtlSeconds | number | no | 600 | Token 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| Option | Type | Required | Default | Description |
|---|---|---|---|---|
secret | string | yes | — | HMAC signing secret |
merchantId | string | yes | — | Merchant identifier |
subscriptionId | string | yes | — | Stripe subscription ID for the authorised user |
mode | 'test' | 'live' | no | 'live' | Stripe environment |
ttlSeconds | number | no | 600 | Token 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 } | nullCDN 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' }