Skip to Content
ReferenceWidget API

Widget API

Full signatures, parameter tables, and return shapes for every public export of the @unchurn.dev/widget package.


Imperative API — @unchurn.dev/widget

showCancelFlow

Mounts the cancel-flow dialog and opens it. Safe to call multiple times — subsequent calls reuse the same React root and re-open the dialog with the new config.

// packages/widget/src/embed-api.tsx export function showCancelFlow(config: ShowCancelFlowConfig): void

Throws if baseUrl cannot be resolved from the call argument or the build-time VITE_UNCHURN_API_URL environment variable.

ShowCancelFlowConfig

A discriminated union on mode that enforces the HMAC token requirement at the type level.

Variant: mode: 'test'

ParameterTypeRequiredDefaultDescription
subscriptionIdstringyesStripe subscription ID
merchantIdstringyesMerchant identifier from the dashboard
mode'test'yesTest mode — no real Stripe mutations; TEST badge rendered
authTokenstringnoOptional HMAC token; unsigned requests accepted in test mode
baseUrlstringnoVITE_UNCHURN_API_URLOrigin for the Unchurn backend (e.g. https://app.unchurn.dev)
fetchImpltypeof fetchnoglobalThis.fetchInject a custom fetch (tests, SSR bridges)
retriesnumberno3Max retry attempts after the initial request
retryDelaynumberno500Delay in milliseconds between retries
onComplete(outcome: FlowOutcome) => voidnoCallback fired when the flow reaches a terminal outcome
onError(error: Error) => voidnoError reporter fired on render-time exceptions caught by the error boundary
pause{ allowedDurations: readonly number[] }noOverride allowed pause durations (months)
supportSupportConfignoSupport link configuration
effectiveDateDate | stringno30 days from nowDisplayed cancellation effective date
themeWidgetThemeConfignoTheme configuration
copyWidgetCopyInputnoCustom copy overrides
customerFirstNamestringnoFirst name for personalization copy; never forwarded to events or persistence
customerAttributesRecord<string, string | number>noMerchant-defined attributes for segmentation; non-primitive values dropped with console.warn

Variant: mode: 'live'

Same parameters as mode: 'test' except:

ParameterTypeRequiredDescription
mode'live'yesLive mode — real Stripe mutations
authTokenstringyesHMAC-signed token. Required. Mint via createUnchurnHandler

Variant: mode omitted

When mode is absent it defaults to 'live' on the backend. authToken is required.

ParameterTypeRequiredDescription
authTokenstringyesRequired — same enforcement as mode: 'live'

FlowOutcome

The value passed to onComplete:

// packages/widget/src/flow-routing.ts export type FlowOutcome = | { type: 'canceled'; reasonId: ReasonId | null; feedbackText: string } | { type: 'kept'; reasonId: ReasonId | null; feedbackText: string } | { type: 'discount_accepted'; reasonId: ReasonId; feedbackText: string } | { type: 'pause_accepted'; durationMonths: number; reasonId: ReasonId; feedbackText: string } | { type: 'support_requested'; reasonId: ReasonId; feedbackText: string } | { type: 'uncanceled' } | { type: 'resumed' } | { type: 'trial_extended'; daysExtended: number; reasonId: ReasonId | null; feedbackText: string } | { type: 'plan_switched'; targetPriceId: string; reasonId: ReasonId | null; feedbackText: string } | { type: 'manual_cancellation_requested' } | { type: 'cancel_already_scheduled'; cancelAt: Date | null } | { type: 'dismissed' }

Example:

// app/settings/billing/cancel-button.ts import { showCancelFlow } from '@unchurn.dev/widget'; showCancelFlow({ merchantId: process.env.NEXT_PUBLIC_UNCHURN_MERCHANT_ID!, subscriptionId: currentUser.stripeSubscriptionId, mode: 'test', onComplete: (outcome) => { if (outcome.type === 'canceled') { router.push('/canceled'); } }, });

closeCancelFlow

Unmounts the widget, removes its container, and detaches any theme media-query listeners. Idempotent.

// packages/widget/src/embed-api.tsx export function closeCancelFlow(): void

No parameters. No return value.

Example:

// app/settings/billing/cancel-button.ts import { closeCancelFlow } from '@unchurn.dev/widget'; closeCancelFlow();

CDN global — window.unchurn

When the widget loads via the CDN script tag, the following is installed as a non-configurable, non-writable property:

// packages/widget/src/embed.ts window.unchurn = { showCancelFlow: (config: ShowCancelFlowConfig) => void, closeCancelFlow: () => void, events: { on: <K extends EventKey>(event: K, handler: (data: Payloads[K]) => void) => () => void, EVENTS: typeof WIDGET_EVENTS, }, }

Example:

<!-- your-page.html --> <script src="https://cdn.unchurn.dev/widget.js" async></script> <script> window.unchurn.showCancelFlow({ merchantId: 'mch_your_id', subscriptionId: 'sub_xxx', mode: 'test', }); </script>

React — @unchurn.dev/widget/react

useUnchurn

React hook that manages the HMAC token lifecycle and opens the widget.

// packages/widget/src/react.tsx export function useUnchurn(opts: UseUnchurnOptions): UseUnchurnReturn

UseUnchurnOptions

ParameterTypeRequiredDefaultDescription
tokenEndpointstringyesURL of the merchant’s HMAC-signing endpoint
lazybooleannofalseWhen true, skip auto-fetch on mount; fetch on first show() call instead

UseUnchurnReturn

FieldTypeDescription
show() => Promise<void>Open the widget. Uses cached token if unexpired; otherwise fetches fresh
close() => voidClose the widget if open
isReadybooleanTrue once the initial token fetch has resolved (success or error)
errorError | nullLast fetch error, or null. Cleared when refresh() succeeds
refresh() => Promise<void>Force-refetch the token, bypassing the cache

Behavior notes:

  • Prefetches the token on mount unless lazy: true
  • Deduplicates concurrent in-flight requests
  • Aborts in-flight requests on unmount
  • Invalidates the cache when tokenEndpoint changes (sign-out / sign-in safety)
  • Uses a 30-second safety buffer before token expiry to avoid opening the widget with a near-expired token

Example:

// app/settings/billing/cancel-button.tsx '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 cancel flow</p>; return ( <button disabled={!isReady} onClick={() => show()}> Cancel subscription </button> ); }

Next.js — @unchurn.dev/widget/nextjs

createUnchurnHandler

Creates a Next.js App Router route handler that mints HMAC-signed tokens.

// packages/widget/src/nextjs.ts export function createUnchurnHandler( opts: CreateUnchurnHandlerOptions, ): (req: NextRequestLike) => Promise<Response>

CreateUnchurnHandlerOptions

ParameterTypeRequiredDefaultDescription
secretstringyesHMAC-SHA256 signing key. Store in UNCHURN_SECRET
merchantIdstringyesMerchant identifier from the dashboard
resolveUser(req: NextRequestLike) => Promise<UnchurnUserContext | null>yesAuth resolver — return null to emit 401
tokenTtlSecondsnumberno300Token TTL in seconds. Clamped to [1, 600]; values outside range throw at construction time

Validates merchantId format ([A-Za-z0-9_-]+) at construction time — throws immediately on invalid config rather than at first request.

UnchurnUserContext

Return value from resolveUser:

FieldTypeRequiredDefaultDescription
subscriptionIdstringyesStripe subscription ID for the current user
mode'test' | 'live'no'live'Stripe environment

UnchurnTokenResponse

The JSON body emitted by the handler on success (200):

FieldTypeDescription
authTokenstringHMAC-signed opaque token
expiresAtstringISO 8601 datetime when the token expires
merchantIdstringEchoed merchant identifier
subscriptionIdstringEchoed Stripe subscription ID
mode'test' | 'live'Echoed Stripe mode

Example:

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/nextjs'; import { auth } from '@clerk/nextjs/server'; import { db } from '@/lib/db'; export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async (req) => { const { userId } = await auth(); if (!userId) return null; const user = await db.user.findUnique({ where: { clerkId: userId }, select: { stripeSubscriptionId: true, stripeMode: true }, }); if (!user?.stripeSubscriptionId) return null; return { subscriptionId: user.stripeSubscriptionId, mode: user.stripeMode ?? 'live', }; }, });

signPayload

Signs a base64url-encoded payload string with HMAC-SHA256. Exported for testing and custom integrations.

// packages/widget/src/nextjs.ts export function signPayload(secret: string, payloadB64: string): string
ParameterTypeDescription
secretstringThe merchant’s HMAC secret
payloadB64stringbase64url-encoded payload string

Returns a 64-character lowercase hex HMAC-SHA256 digest.


encodePayload

Encodes the canonical four-field payload into a base64url string. Exported for testing and custom integrations.

// packages/widget/src/nextjs.ts export function encodePayload(params: { merchantId: string; subscriptionId: string; mode: 'test' | 'live'; expMs: number; }): string
ParameterTypeDescription
params.merchantIdstringMerchant identifier
params.subscriptionIdstringStripe subscription ID
params.mode'test' | 'live'Stripe mode
params.expMsnumberExpiry in Unix milliseconds

Returns a base64url string (no padding, URL-safe alphabet).


verifyAuthToken

Decodes and verifies an auth token against a secret. Uses crypto.timingSafeEqual for the signature comparison.

// packages/widget/src/nextjs.ts export function verifyAuthToken( token: string, secret: string, ): { merchantId: string; subscriptionId: string; mode: 'test' | 'live'; expMs: number } | null
ParameterTypeDescription
tokenstringThe full token string (with or without mode prefix)
secretstringThe merchant’s HMAC secret

Returns structured claims on success, null on any failure (bad format, bad signature, past expiry). Deliberately opaque — callers cannot distinguish bad-signature from malformed-payload.

Example:

// scripts/debug-token.ts import { verifyAuthToken } from '@unchurn.dev/widget/nextjs'; const claims = verifyAuthToken(token, process.env.UNCHURN_SECRET!); if (!claims) { console.log('Token invalid or expired'); } else { console.log(claims.merchantId, claims.subscriptionId, claims.mode, new Date(claims.expMs)); }

splitTokenPrefix

Strips the mode prefix from a token and returns the body and declared mode. Exported for advanced use cases.

// packages/widget/src/nextjs.ts export function splitTokenPrefix( token: string, ): { prefixMode: 'test' | 'live'; body: string } | null

Returns null if the token has no recognized prefix (unprefixed legacy token).


Last updated on