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_123

Try 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.

EndpointAuthPurpose
GET /api/v1/flows/:publicIdpublicFlow config for the widget
POST /api/v1/sessionspublicStart a cancel session (returns fatigue-filtered offer exclusions)
PATCH /api/v1/sessions/:idpublicRecord reason / offer / outcome (finalise is one-shot)
GET /api/v1/dunning/:accountIdpublicDoes this customer have an open failed payment?
POST /api/v1/recoveryAPI keyRegister a failed payment (dedupes by invoice)
PATCH /api/v1/recovery/:idAPI keyMark recovered / lost
POST /api/v1/stripe-eventsAPI keyForward 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 window

Billing 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.