Vanilla JS / CDN
This page shows you how to add the Unchurn widget to a non-React app — Rails, Laravel, plain HTML, or any server-rendered page — using the CDN script tag and window.unchurn.showCancelFlow.
Prerequisites
- A page that renders a cancel button
- For production: a backend endpoint (any language) that can sign an HMAC-SHA256 token
UNCHURN_SECRETandUNCHURN_MERCHANT_IDavailable on your server
Path A — Test / demo (no auth)
Test mode only — do not use in production. The unsigned CDN path below requires no server. Any browser script that guesses a subscription ID can trigger the flow for it. Use this only for UI experiments and demos. Switch to Path B before going live.
<!-- Any HTML page -->
<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.showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_TEST_SUB_ID',
mode: 'test',
})
})
</script>mode: 'test' targets Stripe test-mode subscriptions and renders a TEST badge in the dialog. The backend rejects any attempt to mix unch_test_ tokens with live-mode subscriptions. See Test & live modes.
Verify: Click the button. The cancel flow overlay opens. No network signing occurs.
Path B — Production (server-signed token)
Production requires a signed authToken. Your backend mints it and your page passes it to showCancelFlow. The token is HMAC-SHA256 over a canonical payload — any backend that can compute HMAC works.
Step 1 — Mint the token on your server
The token format is:
unch_<mode>_<base64url(merchantId:subscriptionId:mode:expMs)>.<hex-hmac-sha256>Ruby / Rails example:
# app/controllers/unchurn_controller.rb
require 'openssl'
require 'base64'
require 'json'
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 + 300) * 1000).to_i # 5-minute TTL
raw = "#{merchant_id}:#{subscription_id}:#{mode}:#{exp_ms}"
payload = Base64.urlsafe_encode64(raw, 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 example:
// routes/api.php → Route::post('/unchurn/token', [UnchurnController::class, 'token']);
// app/Http/Controllers/UnchurnController.php
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) + 300) * 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,
]);
}Verify: Call your endpoint with a valid session and confirm the response contains all five fields and the authToken starts with unch_live_ or unch_test_.
Step 2 — Fetch the token and open the widget
<!-- production-cancel.html -->
<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 function () {
const res = await fetch('/api/unchurn/token', {
method: 'POST',
credentials: 'same-origin',
})
if (!res.ok) {
console.error('Could not mint unchurn token', res.status)
return
}
const data = await res.json()
window.unchurn.showCancelFlow({
merchantId: data.merchantId,
subscriptionId: data.subscriptionId,
authToken: data.authToken,
mode: data.mode,
})
})
</script>Verify: Clicking the button fetches your endpoint, then opens the widget with an authenticated session. No TEST badge appears in live mode.
Common pitfalls
script fires before window.unchurn is ready. The CDN script tag has async — window.unchurn may not be defined when the page’s inline scripts run. Wrap calls in a click handler (as shown above) rather than in a top-level <script> block.
Token payload fields out of order. The canonical format is merchantId:subscriptionId:mode:expMs — colon-separated, in that order, base64url-encoded without padding. A reordered or padded payload produces a token that fails backend verification.
UNCHURN_SECRET exposed in HTML. The secret and the signing step must stay on the server. The browser only receives the finished authToken, never the raw secret.
Next steps
- Auth tokens reference — full token format, TTL, and verification details
- React integration — if you later add React to your stack
- Configuration — offer options, theming, and copy overrides