Skip to Content
WidgetVanilla JS / CDN

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@latest

Create 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 end

PHP / 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_SECRET in 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.open inside a click handler — the CDN script is loaded async, 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 raw var(--primary) of the form 255 82% 63% isn’t a CSS color. Wrap it: hsl(var(--primary)).

Where to go next