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): voidThrows 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'
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
subscriptionId | string | yes | — | Stripe subscription ID |
merchantId | string | yes | — | Merchant identifier from the dashboard |
mode | 'test' | yes | — | Test mode — no real Stripe mutations; TEST badge rendered |
authToken | string | no | — | Optional HMAC token; unsigned requests accepted in test mode |
baseUrl | string | no | VITE_UNCHURN_API_URL | Origin for the Unchurn backend (e.g. https://app.unchurn.dev) |
fetchImpl | typeof fetch | no | globalThis.fetch | Inject a custom fetch (tests, SSR bridges) |
retries | number | no | 3 | Max retry attempts after the initial request |
retryDelay | number | no | 500 | Delay in milliseconds between retries |
onComplete | (outcome: FlowOutcome) => void | no | — | Callback fired when the flow reaches a terminal outcome |
onError | (error: Error) => void | no | — | Error reporter fired on render-time exceptions caught by the error boundary |
pause | { allowedDurations: readonly number[] } | no | — | Override allowed pause durations (months) |
support | SupportConfig | no | — | Support link configuration |
effectiveDate | Date | string | no | 30 days from now | Displayed cancellation effective date |
theme | WidgetThemeConfig | no | — | Theme configuration |
copy | WidgetCopyInput | no | — | Custom copy overrides |
customerFirstName | string | no | — | First name for personalization copy; never forwarded to events or persistence |
customerAttributes | Record<string, string | number> | no | — | Merchant-defined attributes for segmentation; non-primitive values dropped with console.warn |
Variant: mode: 'live'
Same parameters as mode: 'test' except:
| Parameter | Type | Required | Description |
|---|---|---|---|
mode | 'live' | yes | Live mode — real Stripe mutations |
authToken | string | yes | HMAC-signed token. Required. Mint via createUnchurnHandler |
Variant: mode omitted
When mode is absent it defaults to 'live' on the backend. authToken is required.
| Parameter | Type | Required | Description |
|---|---|---|---|
authToken | string | yes | Required — 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(): voidNo 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): UseUnchurnReturnUseUnchurnOptions
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
tokenEndpoint | string | yes | — | URL of the merchant’s HMAC-signing endpoint |
lazy | boolean | no | false | When true, skip auto-fetch on mount; fetch on first show() call instead |
UseUnchurnReturn
| Field | Type | Description |
|---|---|---|
show | () => Promise<void> | Open the widget. Uses cached token if unexpired; otherwise fetches fresh |
close | () => void | Close the widget if open |
isReady | boolean | True once the initial token fetch has resolved (success or error) |
error | Error | null | Last 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
tokenEndpointchanges (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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
secret | string | yes | — | HMAC-SHA256 signing key. Store in UNCHURN_SECRET |
merchantId | string | yes | — | Merchant identifier from the dashboard |
resolveUser | (req: NextRequestLike) => Promise<UnchurnUserContext | null> | yes | — | Auth resolver — return null to emit 401 |
tokenTtlSeconds | number | no | 300 | Token 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:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
subscriptionId | string | yes | — | Stripe subscription ID for the current user |
mode | 'test' | 'live' | no | 'live' | Stripe environment |
UnchurnTokenResponse
The JSON body emitted by the handler on success (200):
| Field | Type | Description |
|---|---|---|
authToken | string | HMAC-signed opaque token |
expiresAt | string | ISO 8601 datetime when the token expires |
merchantId | string | Echoed merchant identifier |
subscriptionId | string | Echoed 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| Parameter | Type | Description |
|---|---|---|
secret | string | The merchant’s HMAC secret |
payloadB64 | string | base64url-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| Parameter | Type | Description |
|---|---|---|
params.merchantId | string | Merchant identifier |
params.subscriptionId | string | Stripe subscription ID |
params.mode | 'test' | 'live' | Stripe mode |
params.expMs | number | Expiry 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| Parameter | Type | Description |
|---|---|---|
token | string | The full token string (with or without mode prefix) |
secret | string | The 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 } | nullReturns null if the token has no recognized prefix (unprefixed legacy token).
Cross-links
- Token format — full HMAC scheme, claims, TTL
- Environment variables —
UNCHURN_SECRET,UNCHURN_MERCHANT_ID - Error codes — 401, 405, 500 from the token handler
- Architecture — how the four components fit together