@unchurn.dev/widget
v0.4.1
Published
Embeddable cancel-flow widget for Stripe-native subscription apps. React + Next.js helpers included.
Readme
@unchurn.dev/widget
Drop-in cancel-flow widget for Stripe-native SaaS. Replace your "Cancel subscription" button with a retention engine that runs eligibility, reason-routed offers (pause / discount / plan switch / trial extension), Stripe mutations, verification, and outcome recording — all behind one component.
Docs: docs.unchurn.dev — full reference, configuration, appearance. Dashboard: app.unchurn.dev — get your
UNCHURN_MERCHANT_IDandUNCHURN_SECRET.
How it works
The widget is two halves you wire together:
- A server endpoint in your app that mints a short-lived HMAC-signed token for the signed-in user's Stripe subscription.
- A client surface (React component, vanilla JS, or CDN script) that opens the flow.
The signing secret never leaves your server. Tokens are scoped to one subscription, expire in 5 minutes, and can't be forged from the browser.
Pick your path
| Stack | Server side | Client side |
|---|---|---|
| Next.js / React | @unchurn.dev/widget/server | @unchurn.dev/widget/react |
| Node.js / any framework | @unchurn.dev/widget/server | @unchurn.dev/widget |
| No build step / plain HTML | @unchurn.dev/widget/server (or any backend that returns the same token JSON) | https://cdn.unchurn.dev/widget.js |
All three paths run the same hosted widget runtime. Pick by what your app already uses.
1. Next.js / React (App Router shown — Pages Router works the same)
Install
npm install @unchurn.dev/widget
# or: pnpm add @unchurn.dev/widgetServer: token route
// app/api/unchurn/token/route.ts
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',
};
},
});resolveUser is the only app-specific part: authenticate the request, look up the signed-in user's Stripe subscription, return it. Returning null rejects with 401.
Client: cancel button
// app/components/CancelButton.tsx
'use client';
import { UnchurnTrigger } from '@unchurn.dev/widget/react';
export function CancelButton() {
return (
<UnchurnTrigger tokenEndpoint="/api/unchurn/token">
Cancel subscription
</UnchurnTrigger>
);
}That's the whole integration. Open it in test mode, hit Cancel, watch the offers fire.
Style your own button instead:
<UnchurnTrigger tokenEndpoint="/api/unchurn/token" asChild>
<button className="your-cancel-btn">Cancel subscription</button>
</UnchurnTrigger>Full control with the hook:
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.</p>;
return (
<button disabled={!isReady} onClick={() => void show()}>
Cancel subscription
</button>
);
}Hook returns { show, close, isReady, error }.
2. Node.js (Express / Fastify / Hono / Lambda / Workers / any)
Install
npm install @unchurn.dev/widgetServer: any Fetch-API runtime
// Hono / Cloudflare Workers / Bun / Deno / Vercel Edge
import { createUnchurnHandler } from '@unchurn.dev/widget/server';
app.post('/api/unchurn/token', createUnchurnHandler({
secret: process.env.UNCHURN_SECRET!,
merchantId: process.env.UNCHURN_MERCHANT_ID!,
resolveUser: async (req) => {
const user = await authenticate(req);
return user ? { subscriptionId: user.stripeSubscriptionId, mode: 'live' } : null;
},
}));Server: pure function (Express, Fastify, Koa, anything without Fetch API)
import { mintUnchurnToken } from '@unchurn.dev/widget/server';
app.post('/api/unchurn/token', async (req, res) => {
const user = await authenticate(req);
if (!user) return res.status(401).end();
const token = mintUnchurnToken({
secret: process.env.UNCHURN_SECRET!,
merchantId: process.env.UNCHURN_MERCHANT_ID!,
subscriptionId: user.stripeSubscriptionId,
mode: 'live',
});
res.json(token);
});Client: vanilla JS
import { createUnchurn } from '@unchurn.dev/widget';
const unchurn = createUnchurn({ tokenEndpoint: '/api/unchurn/token' });
document.querySelector('#cancel-btn')?.addEventListener('click', () => {
void unchurn.open();
});createUnchurn returns { preload, open, close, abort }. Each open() re-fetches the token (no cross-user cache).
3. CDN — no build step
For plain HTML, Rails / Django / Laravel views, marketing pages, or anywhere you can't add a bundler:
<script src="https://cdn.unchurn.dev/widget.js" async></script>
<button id="cancel-btn">Cancel subscription</button>
<script>
document.getElementById('cancel-btn').addEventListener('click', async () => {
const res = await fetch('/api/unchurn/token', {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) return;
const token = await res.json();
window.unchurn.open(token);
});
</script>Same token shape as paths 1 and 2 — your server endpoint is identical. The CDN bundle is minified, Brotli-served, and cached at the edge.
Unsigned test-mode demo (no backend, demo only — never ship):
<script src="https://cdn.unchurn.dev/widget.js" async></script>
<script>
window.unchurn.open({
merchantId: 'mch_demo',
subscriptionId: 'sub_demo',
mode: 'test',
});
</script>Environment variables
| Name | Where it lives | Purpose |
|---|---|---|
| UNCHURN_SECRET | Server only — never prefix with NEXT_PUBLIC_ or expose to the client | HMAC-SHA256 signing key for tokens. Rotate from the dashboard if leaked. |
| UNCHURN_MERCHANT_ID | Server only (also safe client-side, but no reason to expose it) | Your merchant identifier from the dashboard. |
Get both from app.unchurn.dev → Settings → Keys.
Content Security Policy
If your app sets CSP headers, allow the widget runtime and API:
Content-Security-Policy:
script-src 'self' https://cdn.unchurn.dev;
connect-src 'self' https://app.unchurn.dev;
style-src 'self' 'unsafe-inline';The widget injects its stylesheet into the page (CSS-in-JS pattern). 'unsafe-inline' for style-src is required unless you use a nonce-based CSP.
Token format
unch_<mode>_<base64url(merchantId:subscriptionId:mode:expMs)>.<hex-hmac-sha256>Default TTL: 300 seconds (capped at 600). createUnchurnHandler and mintUnchurnToken produce this for you — you never construct it by hand.
Next steps
- docs.unchurn.dev/widget/configuration — callbacks, pause durations, copy overrides
- docs.unchurn.dev/widget/appearance — match the widget to your brand
- docs.unchurn.dev/concepts/test-and-live-modes — going from test to live
- docs.unchurn.dev/troubleshooting — when things go wrong
License
Proprietary. Contact [email protected] for commercial licensing.
