GUIDES

Webhooks

Receive NoChurn's events on your backend, verify the NoChurn-Signature header, and handle retries, replays and duplicates.

NoChurn sends an HTTPS POST to your endpoints each time something happens that your systems may care about: a payment recovered, a customer who cancelled, access to restrict, an order for your billing. The list of types and their payloads: Webhook events.

Create an endpoint

From the dashboard (Developers › Webhooks) or the API. The URL must be https://. events lists the types to receive, or ["*"] for all of them. An endpoint belongs to the mode of the key: a test endpoint only receives test events.

curl -X POST https://api.nochurn.sh/v1/webhook_endpoints \
  -H "Authorization: Bearer $NOCHURN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://app.example.com/webhooks/nochurn","events":["*"],"description":"Backend"}'

The response carries the signing secret, whsec_…, once: store it in your backend as NOCHURN_WEBHOOK_SECRET. You can rotate it later (PATCH /v1/webhook_endpoints/{id} with rotate_secret: true, which returns the new one once), change the URL or the types, or disable the endpoint (status: "disabled").

POST /v1/webhook_endpoints/{id}/test sends a signed test event right now (type test, an id starting with nevt_test_) and returns the delivery with your endpoint’s answer.

Verify the signature

Each delivery carries these headers:

Header Value
NoChurn-Signature t=<unix time>,v1=<signature>
NoChurn-Event The event type, e.g. recovery.recovered
NoChurn-Delivery The delivery id (whd_…), different for each delivery
User-Agent NoChurn-Webhooks/1.0

v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret. To verify:

  1. Read the raw request body, not re-serialised JSON.
  2. Recompute the HMAC and compare it with v1 in constant time.
  3. Reject a t more than 5 minutes away from your clock (replayed requests).
  4. Answer 400 on a bad signature. On a good one, answer 2xx fast and handle the event after answering.
// Node 18+. Express: express.raw({ type: "application/json" }); Next.js route handler: await request.text().
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyNoChurnSignature(rawBody, header, secret = process.env.NOCHURN_WEBHOOK_SECRET, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header || "").split(",").map((part) => part.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !parts.v1) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(String(parts.v1), "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

// app.post("/webhooks/nochurn", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyNoChurnSignature(req.body.toString("utf8"), req.get("NoChurn-Signature"))) return res.sendStatus(400);
//   const event = JSON.parse(req.body.toString("utf8"));
//   res.sendStatus(200);
// });

These functions are the ones NoChurn tests against its own signer. The MCP server hands the same code to coding agents.

Retries

A delivery succeeds when your endpoint answers a 2xx. Any other status, a timeout (NoChurn waits 10 seconds for the answer) or a connection error is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours: 7 attempts in all. After the last one the delivery is marked failed. Redirects are not followed. A disabled endpoint receives nothing.

GET /v1/webhook_deliveries lists deliveries (filter by endpoint, status: pending, retrying, delivered, failed, skipped, and type) with their attempts, the status your endpoint answered and how long it took. GET /v1/webhook_deliveries/{id} adds the payload. Each attempt is also a line of the dashboard’s Logs.

Replay a delivery

POST /v1/webhook_deliveries/{id}/replay sends the same payload, with the same event id, as a new delivery right now, and returns it with your endpoint’s answer. Use it once your endpoint is fixed. Replaying needs the admin role in the dashboard (or a key with write access to webhooks), and it is written to the account’s events.

Handle duplicates

A retry or a replay delivers an event you may have handled already, and events can arrive out of order. Make your handler idempotent:

  • Store the event id (nevt_…) when you handle it, and skip an id you have seen. NoChurn-Delivery changes with every delivery, so do not deduplicate on it.
  • For the billing.*_requested orders, also pass their idempotency_key to your billing, so that a payment is never retried twice or a subscription cancelled twice.
  • When the order matters, read the current state from the API (for example GET /v1/customers/{id}/access) rather than trusting the last event received.

Slack and Intercom

The flows can also post a message to a Slack channel or add an internal note on the customer’s Intercom contact, for the events a person wants to hear about. Those are set in the dashboard (flow settings) or with /v1/flow_notifications, not with webhook endpoints.