GUIDES
Cancel modal
Open the NoChurn cancel flow from your cancel button, with a customer token signed by your server, your own UI or NoChurn's, and click to cancel at every step.
The cancel flow asks why the customer is leaving, shows the offer you attached to that reason (discount, pause, downgrade or talk to us), and cancels in one click if they still want to. Cancel now is visible at every step, and a requested cancellation is always carried out.
Configure the flow
In the dashboard, Cancel flow edits the reasons, offers, texts per language, feedback question and design, as a draft. Publish it to test mode first. The same is available through the API: GET /v1/flows/cancel returns the draft, PATCH /v1/flows/cancel edits it and POST /v1/flows/cancel/publish with { "to": "test" } publishes it. Offers live in a library (/v1/offers) and are attached to reasons.
The customer token
The browser never names a customer itself: your server signs a short token for the logged-in user, and the browser sends it with the publishable key.
- Format: JWT HS256, signed with the widget signing secret (
ncws_…, created in Developers or withPOST /v1/account/widget_secret, shown once; rotating it invalidates the tokens signed with the old one). - Claims:
kind: "external",value(your user id),exp(24 hours at most), andsubscription: the subscription the cancel button is about (sub_…,nsub_…or its id in your billing). Always sendsubscriptionwhen a customer can have several. - Where: an authenticated endpoint of your backend that your front end calls before opening the flow. Never ship the secret to the browser or the app.
// Node, server side. Env: NOCHURN_WIDGET_SECRET (ncws_…).
import { createHmac } from "node:crypto";
const base64url = (input) => Buffer.from(input).toString("base64url");
export function signCustomerToken(userId, secret = process.env.NOCHURN_WIDGET_SECRET, ttlSeconds = 3600, subscription = undefined) {
const claims = { kind: "external", value: String(userId), exp: Math.floor(Date.now() / 1000) + ttlSeconds };
if (subscription) claims.subscription = String(subscription);
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = base64url(JSON.stringify(claims));
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}# Python, server side. Env: NOCHURN_WIDGET_SECRET (ncws_...).
import base64
import hashlib
import hmac
import json
import os
import time
def _b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def sign_customer_token(user_id, secret=None, ttl=3600, subscription=None):
secret = secret or os.environ["NOCHURN_WIDGET_SECRET"]
claims = {"kind": "external", "value": str(user_id), "exp": int(time.time()) + ttl}
if subscription:
claims["subscription"] = str(subscription)
header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = _b64url(json.dumps(claims).encode())
signature = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"# Ruby, server side. Env: NOCHURN_WIDGET_SECRET (ncws_…).
require "openssl"
require "base64"
require "json"
def sign_customer_token(user_id, secret = ENV.fetch("NOCHURN_WIDGET_SECRET"), ttl: 3600, subscription: nil)
encode = ->(data) { Base64.urlsafe_encode64(data, padding: false) }
claims = { kind: "external", value: user_id.to_s, exp: Time.now.to_i + ttl }
claims[:subscription] = subscription.to_s if subscription
header = encode.call(JSON.generate({ alg: "HS256", typ: "JWT" }))
payload = encode.call(JSON.generate(claims))
"#{header}.#{payload}.#{encode.call(OpenSSL::HMAC.digest('SHA256', secret, "#{header}.#{payload}"))}"
endThe publishable key (pk_test_…) only works from the origins you allow on it (Developers, or Cancel flow › Install).
Headless: your UI, NoChurn’s logic
Build the screens yourself (web, desktop or a native app) and let NoChurn decide what to show and what to do.
- Open a session:
POST /v1/cancel_sessions { locale }withAuthorization: Bearer pk_test_…andX-NoChurn-Customer-Token: <token>. The session carries itsstep, the texts, the reasons and the offer. - Render the step and send each answer to
POST /v1/cancel_sessions/{id}/answer(table below). - Click to cancel. Show a visible Cancel now button at every step, wired to
POST /v1/cancel_sessions/{id}/cancel. Atchoose_subscription, ask which one first and send{ subscription }(one “Cancel this one” button per subscription): NoChurn never guesses and never cancels them all, and answers422subscription_requiredwithout it.
| Step | Answer |
|---|---|
choose_subscription |
{ subscription }: only when the customer has several live subscriptions and the token names none; list subscriptions and let them pick |
reason |
{ reason, comment? } |
offer |
{ accept_offer: true } or { accept_offer: false } |
feedback |
{ feedback } |
confirm |
POST /v1/cancel_sessions/{id}/cancel (step 3) |
// Browser. publishableKey: pk_test_… (your origin allowed on it); customerToken: signed by YOUR server.
const NOCHURN_API_URL = "https://api.nochurn.sh";
function nochurnHeaders(publishableKey, customerToken) {
return { Authorization: `Bearer ${publishableKey}`, "X-NoChurn-Customer-Token": customerToken, "Content-Type": "application/json" };
}
async function call(path, publishableKey, customerToken, body) {
const response = await fetch(`${NOCHURN_API_URL}${path}`, {
method: "POST", headers: nochurnHeaders(publishableKey, customerToken), body: JSON.stringify(body ?? {}),
});
const data = await response.json();
if (!response.ok) throw new Error(`NoChurn: ${data.error?.message} ${data.error?.fix ?? ""}`);
return data;
}
// Opens a session: its step (choose_subscription, reason, offer, feedback, confirm), subscriptions, reasons and offer.
export function startCancelSession(publishableKey, customerToken, locale = "en") {
return call("/v1/cancel_sessions", publishableKey, customerToken, { locale });
}
// answer: { subscription } | { reason, comment? } | { accept_offer: true | false } | { feedback }
export function answerCancelSession(sessionId, answer, publishableKey, customerToken) {
return call(`/v1/cancel_sessions/${sessionId}/answer`, publishableKey, customerToken, answer);
}
// subscription: required at choose_subscription only (the one the customer pressed "Cancel this one" on).
export function cancelNow(sessionId, publishableKey, customerToken, subscription) {
return call(`/v1/cancel_sessions/${sessionId}/cancel`, publishableKey, customerToken, subscription ? { subscription } : {});
}
From a server, the same session opens with the secret key and { customer, subscription }. GET /v1/cancel_sessions/{id} reads a session again after an error.
What happens on a cancellation: with Stripe or a billing tool that can cancel through its API, NoChurn cancels the subscription; with billing by API, your backend receives billing.cancel_requested and applies it. Either way the cancel_session.completed webhook tells you the outcome.
The NoChurn modal
NoChurn’s own modal is served by the browser script js.nochurn.sh/v1, with the publishable key and the same customer token. Its design comes from the flow (GET /v1/cancel_flows/{id}/theme: a preset, tokens such as colours, font, radius and spacing, the logo, a format modal, panel or page, and optional custom CSS). Custom CSS can never hide the cancel button: the script checks it at render and puts it back. The script’s reference (install tag, functions, options) will be on its own page; until then, use the headless flow above.
Fallback link
If the modal cannot open, the cancellation still goes through: a stable URL per publishable key, https://cancel.nochurn.sh/k/<pk_…>, receives the signed token by POST (an HTML form with a token field, locale optional), opens a hosted session and redirects to a page with one button that cancels. The token never appears in a URL. The URL is returned as cancel_fallback_url by GET /v1/cancel_flows/{id} with a publishable key.
Native apps
GET /v1/cancel_flows/default/theme gives native apps the same design tokens. Subscriptions sold through the App Store or Google Play are managed by Apple and Google: send those customers to the store’s subscription settings.