Add churn prevention to your subscription app.
Connect Stripe, add the cancel flow widget, recover failed payments, and send the customer signals Churnless needs to protect MRR.
Create keys
Use a browser-safe publishable key in your app and a secret key only from your server.
Connect Stripe
Paste a restricted Stripe key so Churnless can read products, customers, subscriptions, invoices, and payment failures.
Add the cancel flow
Mint a customer token on your server, then open the script-tag widget where cancellation starts.
Send signals
Track usage, feedback, support, and lifecycle events so the save path can adapt to the customer.
Create the right keys.
Churnless uses two key types. The publishable key identifies your installation in the browser; customer billing actions also require a signed customer token. The secret key is only for server-side API calls, MCP, jobs, and customer imports.
Publishable key
Use `fk_pub_...` with the tracking pixel and session recorder. On its own it cannot start a cancel flow or read payment state: those also need a short-lived customer token minted by your server.
Secret key
Use `fk_live_...` only on your server, including to mint customer tokens. Never expose it to client bundles, mobile apps, or public config. Test-mode Churnless keys are not available yet; use a Stripe test-mode key while you integrate.
Stripe restricted key
In Churnless settings, paste a restricted Stripe key with the prefilled least-privilege permissions. Churnless uses read scopes to import customers, products, prices, subscriptions, invoices, and charges, plus limited write scopes for save actions such as pauses, discounts, and cancellation at period end. Start in test mode, run the import, then switch to your live Stripe key when the mapping looks right.
Mint customer tokens on your server.
Cancel flows and payment status are authorized by a short-lived customer token, not by a key in the browser. Your backend decides who the customer is; Churnless signs a token bound to that customer and subscription.
- Your backend authenticates the signed-in user and resolves their Stripe customer ID and the exact subscription they are managing. Never accept those IDs from the browser.
- It calls
POST /api/v1/customer-sessionwith your secret key and a purpose:cancel_flow(requiresexternalSubscriptionId) orpayment_status. - Churnless returns a signed
customerTokenthat expires after 15 minutes. Your frontend passes it to the widget throughgetCustomerToken, which is called each time a flow opens. - A started cancel session stays valid for 60 minutes of inactivity, up to 4 hours in total.
Enforce your app’s CSRF protection and billing-management permissions on this endpoint. For accounts with several subscriptions, resolve the selected subscription against the signed-in account. Keep a direct cancellation path available in case Churnless cannot start.
// Your app: app/api/churnless/customer-session/route.ts
// Adapt this import to your existing authenticated billing-session resolver.
import { requireBillingSession } from "@/lib/billing-session";
export async function POST(request: Request) {
// Enforce your app's same-origin/CSRF policy and billing-management role.
const billing = await requireBillingSession(request);
const { purpose } = await request.json();
if (!["cancel_flow", "payment_status"].includes(purpose)) {
return Response.json({ error: "Invalid purpose" }, { status: 400 });
}
const result = await fetch("https://www.getchurnless.com/api/v1/customer-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CHURNLESS_API_KEY!,
},
body: JSON.stringify({
purpose,
customerId: billing.stripeCustomerId,
externalSubscriptionId: billing.stripeSubscriptionId,
productId: process.env.CHURNLESS_PRODUCT_ID,
}),
cache: "no-store",
});
return new Response(await result.text(), {
status: result.status,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}Add the cancel flow widget.
A single script tag works with any stack. The widget opens as an accessible modal, always shows a direct cancel option, and hands off to your fallback URL if it cannot finish.
<script src="https://www.getchurnless.com/widget/v1.js"></script>
<script>
const cancelFlow = window.Churnless?.init({
customerId: "cus_123", // the signed-in customer's Stripe ID
// Returns the customerToken string from your backend endpoint below.
getCustomerToken: async (purpose) => {
const response = await fetch("/api/churnless/customer-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose }),
});
if (!response.ok) throw new Error("Could not authorize billing session");
return (await response.json()).customerToken;
},
// Where customers can still cancel if the flow cannot start.
fallbackCancelUrl: "/billing/cancel",
// Called when anything blocks cancelling; send them to your own path.
onUnavailable: () => window.location.assign("/billing/cancel"),
onSaved: () => window.location.reload(),
onCanceled: () => window.location.assign("/goodbye"),
});
document.getElementById("cancel-subscription").onclick = () => {
// Ad blockers or a slow network can stop the script from loading:
// never leave the customer without a way to cancel.
if (!window.Churnless) {
window.location.assign("/billing/cancel");
return;
}
cancelFlow?.show();
};
</script>Migrating an existing widget embed.
Older embeds that only passed a publishable key and customerId keep working, but they skip the retention flow until you add a customer token.
A signed customer token is required
Mint a short-lived token on your backend (POST /api/v1/customer-session with your secret key) and pass getCustomerToken. With a callback the widget restarts an expired session once, transparently; a static customerToken cannot be refreshed. Without a token, init() logs one console error and hands off to your fallback.
fallbackCancelUrl and onUnavailable
onUnavailable fires once per flow for anything that blocks cancelling: the flow could not start, a request timed out (10 seconds), the session expired and could not restart, or the server could not confirm a cancellation. Without a handler the widget shows a Cancel subscription link to fallbackCancelUrl. Guard for a blocked script (if (!window.Churnless)) the same way.
Analytics: eventId and flowInstanceId
onTrackEvent payloads no longer include sessionId, because it authorizes billing actions. Join events on flowInstanceId (stable for one open flow) and deduplicate on eventId. Keep my subscription and Never mind now record outcome deflected instead of abandoned.
theme.mode
theme.mode is 'light', 'dark' or 'auto' (the default, which follows the customer's system setting). Set 'light' if your product has no dark theme.
React SDK.
The React SDK wraps the same token flow in components: ChurnlessProvider, CancelFlow, and PaymentRecoveryWall. It is not yet published to npm. Email support@getchurnless.com to join the beta; until then, use the script-tag widget above.
Coming soon
The install command below will work once the package is published. It fails today.
# Coming soon (not yet published to npm)
npm install @churnless/react"use client";
import { ChurnlessProvider } from "@churnless/react";
export function AppProviders({ children, customer }) {
return (
<ChurnlessProvider
publishableKey={process.env.NEXT_PUBLIC_CHURNLESS_KEY}
customerId={customer.stripeCustomerId}
productId="prod_adscreator"
trackingConsent={customer.analyticsConsent}
getCustomerToken={async (purpose) => {
const response = await fetch("/api/churnless/customer-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ purpose }),
});
if (!response.ok) throw new Error("Could not authorize billing session");
return (await response.json()).customerToken;
}}
>
{children}
</ChurnlessProvider>
);
}Replace the raw cancel button in React.
Pass the same customer ID you use in Stripe. Churnless starts a session, asks for the reason, chooses the next best step, and reports the outcome.
"use client";
import { CancelFlow } from "@churnless/react";
export function BillingSettings({ customer }) {
return (
<CancelFlow
customerId={customer.stripeCustomerId}
onSaved={() => window.location.reload()}
onCanceled={() => window.location.assign("/goodbye")}
onError={(error) => console.error(error)}
>
{({ show, isLoading }) => (
<button onClick={show} disabled={isLoading}>
{isLoading ? "Loading..." : "Cancel subscription"}
</button>
)}
</CancelFlow>
);
}Block involuntary churn without blocking recovery.
Dunning emails include a secure link to a hosted card-update page, with no code required. In React apps, the beta PaymentRecoveryWall shows customers with unresolved failures a clear path to update payment details; everyone else sees the app.
import { PaymentRecoveryWall } from "@churnless/react";
export function ProtectedApp({ customer, children }) {
return (
<PaymentRecoveryWall
customerId={customer.stripeCustomerId}
fallback={children}
pollInterval={60000}
/>
);
}Send the product signals that predict churn.
The better the signal history, the better the cancellation path. Track meaningful usage, activation, support friction, billing friction, and feedback.
<script>
window.fk = window.fk || function(){(window.fk.q = window.fk.q || []).push(arguments)};
fk("init", "fk_pub_...", {
customerId: "cus_123",
productId: "prod_adscreator",
consent: true
});
fk("track", "feature_used", {
feature: "campaign_export",
value: 1
});
</script>
<script async src="https://www.getchurnless.com/pixel/v1.js"></script>await fetch("https://www.getchurnless.com/api/v1/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CHURNLESS_API_KEY
},
body: JSON.stringify({
customerId: "cus_123",
productId: "prod_adscreator",
eventType: "feature_used",
eventName: "campaign_exported",
category: "usage",
payload: {
exportCount: 3,
campaignId: "camp_789"
}
})
});Consent-first sites
Pass requireConsent: true to fk("init") and the pixel captures nothing until fk("consent", true). fk("consent", false) stops capture again. In the React SDK, set requireConsent on the provider and pass trackingConsent once your consent manager says yes.
<script>
window.fk = window.fk || function(){(window.fk.q = window.fk.q || []).push(arguments)};
// Consent-first: nothing is captured until consent is granted.
fk("init", "fk_pub_...", { customerId: "cus_123", requireConsent: true });
// Call this from your consent manager. A call made before init finishes
// loading is still honored.
onAnalyticsConsent((granted) => fk("consent", granted));
</script>Usage
feature_used, project_created, export_completed
Lifecycle
trial_started, activation_completed, team_invited
Friction
support_ticket_opened, error_seen, downgrade_viewed
Named product actions for session proof
For Friction Radar product issues, emit allowlisted action names so the AI PM can cite product intent (not only rage clicks). Prefer churnless_action tags in recording, or track with stable names like billing.portal_open, checkout.submit, onboarding.invite_sent, and error.payment_failed. Names must match ^[a-zA-Z][a-zA-Z0-9_.:-]{0,99}$. Settings → Products shows your instrumentation score.
Replay consented journeys without exposing sensitive data.
Session recording is available on every plan, but it is disabled for every product until you enable it in Settings → Products. The existing trackingConsent value remains the only end-user consent control.
Privacy modes
Balanced masks every form value while keeping ordinary page text for journey context. With the separate prompt-capture opt-in enabled in recording settings, recognized prompt and composer values are recorded verbatim; sensitive fields stay masked regardless.
Strict masks all form values and ordinary page text.
Both modes exclude canvas pixels, inline images, fonts, console output, and cross-origin iframe contents.
Mark sensitive regions
Add data-churnless-prompt to a prompt composer that should be readable in Balanced replay when prompt capture is opted in. Use rr-mask, rr-block, and rr-ignore, or the equivalent data-churnless-mask, data-churnless-block, and data-churnless-ignore attributes, to override capture for sensitive areas.
Sampling is deterministic per session. The recorder downloads only after product enablement, analytics consent, Do Not Track, and sampling checks pass. Replays stay private and expire after 30 days.
Use the API and MCP server for automation.
Server-side integrations can import customers, push events, inspect health scores, list cancel sessions, read payment-recovery performance (each recovered payment credited to one method, plus what Churnless added against a no-treatment holdout), pause or resume recovery campaigns, and let internal AI tools query churn context.
OpenAPI reference
Browse every REST endpoint, schema, and request example in the generated API reference.
The endpoints this guide installs (customer sessions, cancel flows, payment status, identify, events, signals, recovery links, billing contacts, GDPR) work on every plan. The rest of the data API needs API access, which the Launch plan doesn’t include; those calls return 402. Connecting an AI agent over MCP with OAuth works on every plan.
Connect an AI agent over MCP
Add the hosted MCP server to your AI client and sign in with your Churnless account. You choose the workspace and whether the agent may work your Today inbox. Once connected, it can read customers, churn risk, reasons, cancel flows, payment recovery and alerts, then review what needs you today and approve, reject, snooze or clear items. Every change asks for your confirmation first, runs as you, and shows up in the agent ledger, where it can be undone. Works on every plan while it’s active. Disconnect apps under Settings → Developers → Connected AI apps.
# Claude Code (Claude, ChatGPT and Cursor: add the same URL as a remote MCP server)
claude mcp add --transport http churnless https://www.getchurnless.com/api/mcpServer-to-server automation can send Authorization: Bearer fk_live_… to the same URL instead (needs API access).