propgate docs

SDKwebhooks

Webhooks

Where domain state changes are sent, and what happened to them.

const { data, error, meta } = await propgate.webhooks.create({
  url: "https://example.com/hooks/propgate",
  events: ["domain.failed", "domain.recovered"],
});

// Readable exactly once, and only when this call created the endpoint.
if (meta?.created) {
  await storeSigningSecret(data.secret);
}

propgate.webhooks.create() is idempotent on the URL, and the secret is in the response exactly once. Creating the same endpoint twice returns the existing one with meta.created false and no secret: the stored secret is kept to sign with and is not ours to hand back, so a retry cannot become a way to read a secret somebody else set up. Lost it? Rotate.

An omitted or empty events array means every event. The four are domain.verified, domain.degraded, domain.failed and domain.recovered — see webhook payloads for what each carries and when it fires.

const { data } = await propgate.webhooks.list();

await propgate.webhooks.get("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b");

// Stop delivering without losing the endpoint or its history.
await propgate.webhooks.update("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b", { disabled: true });

await propgate.webhooks.remove("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b");

propgate.webhooks.update() with disabled: true stops delivery while keeping the endpoint and its delivery history, which is what you want during an incident on your side. propgate.webhooks.remove() is the one that loses the history. propgate.webhooks.list() and propgate.webhooks.get() read them back.

Rotating the signing secret

const { data, meta } = await propgate.webhooks.rotateSecret("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b", {
  windowHours: 24,
});

// Both secrets verify until this moment, so your deploy can take its time.
meta?.previousSecretExpiresAt;

// Unless you are rotating because something leaked.
await propgate.webhooks.rotateSecret("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b", { windowHours: 0 });

Both secrets are accepted for the window, defaulting to 24 hours — long enough that redeploying your receiver on your own schedule is never a broken endpoint. windowHours: 0 expires the old one immediately, which is the right answer when you are rotating because something leaked.

Deliveries

const { data, meta } = await propgate.webhooks.listDeliveries("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b", {
  status: "failed",
  limit: 200,
});

for (const delivery of data ?? []) {
  console.log(delivery.event, delivery.attempts, delivery.lastError);
}

// Every page of them.
const all = await propgate.webhooks.listAllDeliveries("019fcf9a-3c4d-7e5f-a06b-7c8d9e0f1a2b", { status: "failed" });

The ledger is per endpoint, because a delivery belongs to exactly one and "did this endpoint receive it" is the question that gets asked. lastError is what makes a dead-lettered delivery answerable; it is null while pending and after an eventual success.

propgate.webhooks.listAllDeliveries() walks the cursor to the end. Deliveries sort newest first, so anything created mid-walk is missed rather than duplicated — for an audit that must not miss one, walk again from the top rather than resuming a stale cursor.

Verifying on the receiving end

import { TOLERANCE_SECONDS, verifyPayload } from "@propgate/webhooks";
import type { WebhookPayload } from "@propgate/sdk";

export async function handler(request: Request): Promise<Response> {
  const body = await request.text();
  const timestamp = Number(request.headers.get("webhook-timestamp"));

  // The signature covers the timestamp but says nothing about how old it is.
  // Without this, a captured request replays forever. 300 seconds is the
  // window every stock Svix-compatible library already enforces.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return new Response("stale", { status: 400 });
  }

  const verified = verifyPayload({
    body,
    header: request.headers.get("webhook-signature") ?? "",
    id: request.headers.get("webhook-id") ?? "",
    secret: process.env.PROPGATE_WEBHOOK_SECRET ?? "",
    timestamp,
  });

  if (!verified) {
    return new Response("bad signature", { status: 400 });
  }

  const payload = JSON.parse(body) as WebhookPayload;

  if (payload.type === "domain.failed") {
    await notify(payload.data.external_id, payload.data.reason);
  }

  return new Response(null, { status: 200 });
}

The payload type is exported here as WebhookPayload, so your handler and your client describe one shape. It is snake_case on the wire, unlike everything else in this SDK: it is a contract with other people's code, it has to match what the docs show, and snake_case is what the Svix ecosystem expects.