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:
- Read the raw request body, not re-serialised JSON.
- Recompute the HMAC and compare it with
v1in constant time. - Reject a
tmore than 5 minutes away from your clock (replayed requests). - Answer
400on a bad signature. On a good one, answer2xxfast 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);
// });# Use the raw body (Django: request.body; Flask: request.get_data()).
import hashlib
import hmac
import os
import time
def verify_nochurn_signature(raw_body, header, secret=None, tolerance=300):
secret = secret or os.environ["NOCHURN_WEBHOOK_SECRET"]
if isinstance(raw_body, bytes):
raw_body = raw_body.decode("utf-8")
parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > tolerance or "v1" not in parts:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])# Use the raw body (Rails: request.raw_post).
require "openssl"
def verify_nochurn_signature(raw_body, header, secret = ENV.fetch("NOCHURN_WEBHOOK_SECRET"), tolerance: 300)
parts = header.to_s.split(",").to_h { |part| part.split("=", 2) }
t = Integer(parts["t"].to_s, exception: false)
return false if t.nil? || (Time.now.to_i - t).abs > tolerance || parts["v1"].nil?
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts['t']}.#{raw_body}")
expected.bytesize == parts["v1"].bytesize && OpenSSL.fixed_length_secure_compare(expected, parts["v1"])
end
# Rails (skip CSRF on this API controller):
# def create
# return head(:bad_request) unless verify_nochurn_signature(request.raw_post, request.headers["NoChurn-Signature"])
# event = JSON.parse(request.raw_post)
# head :ok
# endThese 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-Deliverychanges with every delivery, so do not deduplicate on it. - For the
billing.*_requestedorders, also pass theiridempotency_keyto 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.