Vanilla JS and CDN
For non-React apps: plain JavaScript with a bundler, server-rendered pages (Rails, Laravel, Django), and direct CDN usage.
Production calls need a server-signed token. Unsigned window.unchurn.open calls work only in test mode — never ship them to production.
Path A: Bundled JavaScript
For Vue, Svelte, Solid, or any app with a build step:
npm install @unchurn.dev/widget@latestCreate a client once and call it from your existing cancel button:
import { createUnchurn } from '@unchurn.dev/widget'
const unchurn = createUnchurn({
tokenEndpoint: '/api/unchurn/token',
defaultFlow: {
appearance: {
variables: {
colorPrimary: '#1942F5',
colorPrimaryForeground: '#ffffff',
fontFamily: 'inherit',
},
},
},
})
document.querySelector('#cancel-btn')?.addEventListener('click', () => {
void unchurn.open()
})createUnchurn fetches the signed token, loads the hosted runtime, and opens the flow. Each open fetches a fresh token — no caching across calls.
Path B: Direct CDN runtime
For no-bundler pages:
<script src="https://cdn.unchurn.dev/widget.js" async></script>
<button id="cancel-btn">Cancel subscription</button>In production, fetch a signed token from your server before opening the flow:
<script>
document.getElementById('cancel-btn').addEventListener('click', async function () {
const res = await fetch('/api/unchurn/token', {
method: 'POST',
credentials: 'same-origin',
})
if (!res.ok) return
const token = await res.json()
window.unchurn.open({
merchantId: token.merchantId,
subscriptionId: token.subscriptionId,
authToken: token.authToken,
mode: token.mode,
})
})
</script>Theming with appearance works the same as Path A — see Appearance for the full token list.
Token endpoint response shape
Your server endpoint should return this exact JSON shape. Field names are case-sensitive.
{
"authToken": "unch_live_<base64url-payload>.<hex-signature>",
"expiresAt": "2026-04-22T12:05:00.000Z",
"merchantId": "mch_abc123",
"subscriptionId": "sub_1PqXyz",
"mode": "live"
}Ruby / Rails
require 'openssl'
require 'base64'
class UnchurnController < ApplicationController
before_action :authenticate_user!
def token
subscription_id = current_user.stripe_subscription_id
return head :unauthorized unless subscription_id
merchant_id = ENV.fetch('UNCHURN_MERCHANT_ID')
secret = ENV.fetch('UNCHURN_SECRET')
mode = Rails.env.production? ? 'live' : 'test'
exp_ms = ((Time.now.to_f + 600) * 1000).to_i
payload = Base64.urlsafe_encode64(
"#{merchant_id}:#{subscription_id}:#{mode}:#{exp_ms}",
padding: false
)
sig = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
prefix = mode == 'live' ? 'unch_live_' : 'unch_test_'
render json: {
authToken: "#{prefix}#{payload}.#{sig}",
expiresAt: Time.at(exp_ms / 1000.0).iso8601,
merchantId: merchant_id,
subscriptionId: subscription_id,
mode: mode,
}
end
endPHP / Laravel
public function token(Request $request): JsonResponse
{
$user = $request->user();
if (!$user?->stripe_subscription_id) {
return response()->json(null, 401);
}
$merchantId = config('services.unchurn.merchant_id');
$secret = config('services.unchurn.secret');
$mode = app()->isProduction() ? 'live' : 'test';
$expMs = (int) ((microtime(true) + 600) * 1000);
$raw = implode(':', [$merchantId, $user->stripe_subscription_id, $mode, $expMs]);
$payload = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$sig = hash_hmac('sha256', $payload, $secret);
$prefix = $mode === 'live' ? 'unch_live_' : 'unch_test_';
return response()->json([
'authToken' => "{$prefix}{$payload}.{$sig}",
'expiresAt' => (new \DateTime())->setTimestamp((int) ($expMs / 1000))->format('c'),
'merchantId' => $merchantId,
'subscriptionId' => $user->stripe_subscription_id,
'mode' => $mode,
]);
}For Node, Python, Go, and other stacks, the recipe is the same: base64url-encode merchantId:subscriptionId:mode:expMs, HMAC-SHA256 it with your secret, and return the five fields above. The signing format is documented in Token format.
Test-only: unsigned CDN call
For UI previews against test-mode subscriptions you can skip the server entirely:
<script src="https://cdn.unchurn.dev/widget.js" async></script>
<button id="cancel-btn">Cancel subscription</button>
<script>
document.getElementById('cancel-btn').addEventListener('click', function () {
window.unchurn.open({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_TEST_SUB_ID',
mode: 'test',
})
})
</script>Never use this in production. The backend rejects unsigned calls in live mode.
Common pitfalls
UNCHURN_SECRETin HTML. The secret and the signing step must stay on the server. If you can see the secret in your page source, fix it before anything else.- CSP blocks the runtime. Allow
script-src https://cdn.unchurn.dev,connect-src https://app.unchurn.dev, and inline styles for the widget’s scoped stylesheet. - Script not loaded yet. Keep
window.unchurn.openinside a click handler — the CDN script is loadedasync, so it isn’t ready at page-load. - Token field order. The canonical signing payload is
merchantId:subscriptionId:mode:expMs. Reordering breaks the signature. - Tailwind HSL triple in
colorPrimary. A rawvar(--primary)of the form255 82% 63%isn’t a CSS color. Wrap it:hsl(var(--primary)).
Where to go next
- Appearance — full theming reference.
- Configuration — open options, callbacks, copy.
- Token format — signing payload spec for any language.
- React guide — if you’re considering a React rewrite.