propgate docs

ReferenceWebhook 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.degraded

    Something 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.failed

    The 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.recovered

    A 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.verified

    Setup 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 4xx is not retried. A 404 means 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.