Reference › Webhook payloads
Webhooks
Domain state changes, delivered over signed HTTP. Manage endpoints under /v1/webhooks — see the API reference.
Events
Four events. An endpoint with no events array receives all of them.
domain.degradedSomething is wrong, but not yet confirmed. Show it; do not page anyone on it.
Fires on the first definite failure, once per episode — not once per check while it stays degraded.
domain.failedThe failure persisted across consecutive checks. This is the one worth acting on.
Fires when consecutive failures reach the configured threshold, which is three by default.
domain.recoveredA domain that was degraded or failed is verified again. Distinct from domain.verified so a first-time welcome is not sent on every recovery.
Fires on the first passing check after degraded or failed.
domain.verifiedSetup is complete. Sent once, not on later recoveries.
Fires on the first passing check for a domain that has never verified.
Do not build a pager on domain.degraded. It means one check failed and we have not confirmed it yet — which is often a resolver blip or a zone mid-edit. It fires once per episode rather than on every check, so it is safe to display, but domain.failed is the event that means something is really wrong.
Payload
Fields are snake_case, unlike the rest of the API, because this is the shape most webhook tooling expects. previous_state is what lets you tell a first-time setup from a recovery without keeping your own state.
{
"type": "domain.failed",
"created_at": "2026-08-03T12:00:00.000Z",
"data": {
"id": "019fc8ee-234b-7103-907e-3a9ae3b74d3b",
"domain": "mail.customer.example",
"external_id": "cust_1",
"previous_state": "degraded",
"state": "failed",
"reason": "3 consecutive failures, reaching the failed threshold"
}
}Verifying a request
Three headers: webhook-id, webhook-timestamp (unix seconds) and webhook-signature. The signature is v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}, keyed with your secret. This is the Svix format, so an existing Svix verification library works unchanged.
Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
// Reject anything too old to be a live delivery. Without this the signature
// stays valid forever and a captured request can be replayed.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return false;
}
// The whsec_ prefix is a label, not key material. Strip it, then base64-decode.
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected =
"v1," +
createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
// The header may carry more than one signature during a secret rotation.
// Any match is a pass.
return headers["webhook-signature"]
.split(" ")
.some((candidate) => {
const a = Buffer.from(candidate.trim());
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}Rotating a secret
POST /v1/webhooks/:id/secret returns a new secret and previousSecretExpiresAt. Until that moment every request is signed with both secrets, space-separated in the one header — so you can deploy the new secret on your own schedule without dropping a delivery. Verify by accepting any match, as the snippet above does.
Rotating because a secret leaked? Pass { "windowHours": 0 } and the old one stops being accepted immediately.
Retries and failures
Delivery is at-least-once. Return any 2xx to acknowledge; we do not read the body. Respond quickly and do your work afterwards — an attempt that holds the connection open counts against the timeout.
5xx,408,429, a timeout or a connection error is retried with exponential backoff from one second.- Any other
4xxis not retried. A404means the URL is wrong, and forty more attempts will not fix that. - Redirects are never followed. A signed request only ever goes to the URL you configured.
Every attempt is recorded. GET /v1/webhooks/:id/deliveries shows the status, the attempt count and the last error for each one — which is where to look when something did not arrive.
Ordering and duplicates
Events are not ordered, and a retry can arrive after a later event. Treat data.state as the state at created_at rather than as the current one, and make your handler idempotent on webhook-id — a delivery that succeeded on your side but whose response we never saw will be sent again.