Developer docs
Everything ships from one script tag or plain HTTPS. No SDK to install, no data pipeline — Churntide sits on top of the billing you already have.
Quickstart — the embed
Load the script, then open a flow from your cancel button. Get your flow id (ct_…) from the dashboard.
<script src="https://churntide.com/churntide.js" defer></script>
<script>
document.querySelector("#cancel-btn").addEventListener("click", () => {
Churntide.open({
flowId: "ct_xxxxxxxx",
customer: {
id: "cus_123", // your internal or billing customer id
email: "jane@acme.com",
name: "Jane",
plan: "Pro", // used for offer targeting
mrr: 49, // used for MRR-range targeting + "MRR saved" stats
subscriptionId: "sub_123", // required for billing auto-apply
},
onSaved: (s) => { /* s.offerAccepted? s.offer is set only when accepted */ },
onCanceled: (s) => { /* they cancelled — do the actual cancellation (or let auto-apply) */ },
onClose: () => { /* modal closed */ },
});
});
</script>Declarative buttons
No JavaScript required — annotate the button instead:
<button
data-churntide="ct_xxxxxxxx"
data-churntide-id="cus_123"
data-churntide-email="jane@acme.com"
data-churntide-plan="Pro"
data-churntide-mrr="49"
data-churntide-subscription="sub_123">
Cancel subscription
</button>
<!-- outcomes bubble up as DOM events -->
<script>
document.addEventListener("churntide:saved", (e) => console.log(e.detail));
document.addEventListener("churntide:canceled", (e) => console.log(e.detail));
</script>Hosted cancel page
Zero-code option: link customers straight to your hosted flow and pass customer context in the query string.
https://churntide.com/cancel/ct_xxxxxxxx
?id=cus_123&email=jane@acme.com&name=Jane
&plan=Pro&mrr=49&subscriptionId=sub_123Try it live: demo flow ↗
Events & callbacks
The onSaved / onCanceled payload:
{
"sessionId": "665f…",
"reason": "too_expensive",
"offer": {
"id": "…", "type": "discount",
"title": "30% off for 3 months",
"percentOff": 30, "durationMonths": 3
},
"customer": { "id": "cus_123", "subscriptionId": "sub_123" }
}If you connected a billing key, Churntide has already applied the outcome by the time the callback fires — treat the callback as confirmation, not a to-do. Without a key, this payload is your to-do: apply the discount / pause / cancellation on your side.
In-app dunning banner
Shows a dismissible "payment failed" bar with a tracked update-payment link — only when the signed-in customer actually has an open failed payment. Your account id (acct_…) is in the dashboard under Recovery → Set up ingestion.
<script src="https://churntide.com/churntide.js" defer></script>
<script>
window.addEventListener("load", () => {
Churntide.dunning({
account: "acct_xxxxxxxxxx",
customer: { id: "cus_123", email: "jane@acme.com" },
// optional: message, ctaText, background, onShow(data)
});
});
</script>REST API
Server-to-server endpoints authenticate with an API key (ct_live_…, created in Settings) via Authorization: Bearer or x-api-key. Widget endpoints are public and keyed by unguessable ids. All endpoints are rate-limited per client; a 429 comes with a Retry-After header.
| Endpoint | Auth | Purpose |
|---|---|---|
| GET /api/v1/flows/:publicId | public | Flow config for the widget |
| POST /api/v1/sessions | public | Start a cancel session (returns fatigue-filtered offer exclusions) |
| PATCH /api/v1/sessions/:id | public | Record reason / offer / outcome (finalise is one-shot) |
| GET /api/v1/dunning/:accountId | public | Does this customer have an open failed payment? |
| POST /api/v1/recovery | API key | Register a failed payment (dedupes by invoice) |
| PATCH /api/v1/recovery/:id | API key | Mark recovered / lost |
| POST /api/v1/stripe-events | API key | Forward raw Stripe events; we open/close recoveries |
# register a failed payment
curl -X POST https://churntide.com/api/v1/recovery \
-H "Authorization: Bearer ct_live_..." \
-H "Content-Type: application/json" \
-d '{
"customer": { "id": "cus_123", "email": "jane@acme.com", "subscriptionId": "sub_123" },
"invoiceId": "in_123", "amount": 49, "currency": "usd"
}'Webhooks
Set a webhook URL per flow (Builder → Behaviour) and every finalised session POSTs to it:
POST <your webhook url>
{
"event": "session.saved" | "session.canceled" | "session.abandoned",
"flow": { "id": "…", "publicId": "ct_xxxxxxxx" },
"session": {
"id": "…", "outcome": "saved",
"reason": "too_expensive", "feedback": "…",
"offerShown": { "type": "discount", "title": "30% off for 3 months" },
"offerAccepted": true,
"customer": { "id": "cus_123", "email": "…", "plan": "Pro", "mrr": 49, "subscriptionId": "sub_123" },
"stripe": { "applied": true, "action": "coupon:…" }
},
"sentAt": "2026-08-24T12:00:00.000Z"
}Set a signing secret on the flow and every post carries an X-Churntide-Signature header (t=<unix>,v1=<hmac>). Verify it:
const [t, v1] = sig.split(",").map((p) => p.split("=")[1]);
const expected = crypto.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`).digest("hex");
const valid = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - t) < 300; // 5-min replay windowBilling auto-apply
Connect a restricted Stripe key (or a Paddle API key — beta) in Settings and Churntide applies outcomes itself: discounts as coupons, pauses via pause_collection, downgrades as price swaps, cancellations as cancel-at-period-end. Keys are AES-256-GCM encrypted at rest and can be revoked any time. Pass subscriptionId when opening the flow or nothing can be applied.
Something missing? Email us — we answer fast.