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.
30-second install
Paste this just before </body> on the page where customers cancel. Your flow id (ct_…) is on the dashboard.
<script src="https://churntide.com/churntide.js"
data-flow="ct_xxxxxxxx"
data-auto
defer></script>Done. Churntide finds your "cancel subscription" button — even when your app renders it later — and opens your flow when it's clicked. The install is detected automatically the first time the page loads (no verify step), and once your domain is registered the snippet even works without data-flow.
Tell it who's cancelling (recommended — powers offer targeting, offer memory and MRR-saved stats):
<script>
window.ChurntideCustomer = {
id: "cus_123", // your billing customer id
email: "jane@acme.com",
plan: "Pro",
mrr: 49,
subscriptionId: "sub_123" // needed for billing auto-apply
};
</script>Want to see it before installing? The console preview on /dashboard/install opens your real flow on your own page and records nothing. Rather not touch code at all? Email support@churntide.com — we install it for you. Free.
Need full control instead? Wire it up yourself:
<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", email: "jane@acme.com", plan: "Pro", mrr: 49, subscriptionId: "sub_123" },
onSaved: (s) => { /* s.offerAccepted → s.offer is set; apply it (or let auto-apply) */ },
onCanceled: (s) => { /* cancel on your side (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_123Also accepted: sub (alias for subscriptionId), sig (billing auto-apply works from hosted links too), and preview=1 (render the flow, record nothing). Try it live: demo flow ↗
Events & callbacks
The onSaved / onCanceled payload:
{
"sessionId": "665f…",
"flowId": "ct_xxxxxxxx",
"outcome": "saved", // "saved" | "canceled"
"offerAccepted": true, // false = they just changed their mind
"reason": "too_expensive",
"feedback": "…",
"offerShown": { "id": "…", "type": "discount", "title": "30% off for 3 months" },
"offer": { // only set when accepted — otherwise null
"id": "…", "type": "discount",
"title": "30% off for 3 months",
"percentOff": 30, "durationMonths": 3
// pause offers: pauseMonths · downgrades: planName · extends: extendDays
},
"customer": { "id": "cus_123", "email": "…", "plan": "Pro", "mrr": 49, "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.
Offer fatigue & test mode
Every offer has three memory settings, evaluated server-side against the customer's previous sessions (matched by customer.id or email) — the widget can't be tricked into re-showing them. Pass at least one of those when opening the flow, or nothing can be remembered.
- Once per customer — never show the offer again to a customer who already accepted it (stops discount farming).
- Cooldown (days) — hide it from customers who accepted it within the last N days.
- Hide after decline — never show the offer again to a customer who explicitly declined it ("No thanks, continue cancelling"). Default on for discounts, off for pause / downgrade / extend / contact. Abandoned sessions don't count as declines.
POST /api/v1/sessions returns the resulting excludedOfferIds; the accepted offer is re-validated against the same rules before anything touches billing.
Test mode (Builder → Behaviour) lets you click through a live install safely: sessions are recorded with isTest: true (a "test" badge in Sessions), excluded from stats, save rate and your monthly quota, and nothing is applied to Stripe/Paddle or sent to webhooks, Slack or email. The public flow payload carries settings.testMode and the widget shows a "Test mode" ribbon. The dashboard's ?preview=1 link is different: it records nothing at all.
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_…) and signing secret are in Settings → Account id & signing secret.
The request must carry a signature minted on your server — unsigned requests always answer "nothing due", so nobody can use the public account id to probe who's in dunning: sig = HMAC_SHA256(applySecret, `$${customerId}.$${email.toLowerCase()}`).
<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" },
sig: "…", // HMAC_SHA256(applySecret, `${customerId}.${email.toLowerCase()}`), from your server
// 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 excludedOfferIds and a token |
| PATCH /api/v1/sessions/:id | token | Record reason / offer / outcome — body must carry the token from POST (404 without it) |
| 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) |
| GET /api/v1/recovery/:id | API key | Read one recovery |
| PATCH /api/v1/recovery/:id | API key | Mark recovered / lost (or re-open) |
| 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.
One thing is required so we can trust whose subscription to touch: sign the customer context on your server with your apply-signing secret (Settings → Account id & signing secret) and pass the result as sig. subscriptionId is optional — leave it out (sign `${customer.id}.`, note the trailing dot) and Churntide resolves the subscription from your connected Stripe key, as long as the customer id is the Stripe one (cus_…) and they have exactly one live subscription. Unsigned flows still record everything — they just won't mutate billing; each such session gets an Apply in Stripe button on the customer's timeline so you're one click from fixing it.
// server-side, with your APPLY_SECRET (cts_…)
const sig = crypto.createHmac("sha256", APPLY_SECRET)
.update(`${customer.id}.${subscriptionId || ""}`) // subscriptionId optional
.digest("hex");
// then hand `sig` to the widget:
Churntide.open({
flowId: "ct_xxxxxxxx",
customer: { id: "cus_123", subscriptionId: "sub_123", email, plan, mrr },
sig,
});
// or declaratively: <button data-churntide="ct_…" data-churntide-sig="${sig}">Not sure your signing code is right? Settings → Account id & signing secret has ready-made endpoints for Next.js and Express, and a Test your signature box that checks your server's output against what auto-apply will expect.
Something missing? Email us — we answer fast.