Docs / Webhooks

Webhooks

Get notified when a customer's domain is ready, needs attention, recovers or is deleted. Events, payloads, HMAC signatures, retries and replay.

Subscribe

On the Webhooks tab of your application, or with the API:

POST /v1/webhooks
{"url": "https://app.yourproduct.com/hooks/custom-domain",
 "events": ["domain.ready", "domain.attention_required", "domain.recovered", "domain.deleted"]}

The response includes the signing secret, shown once. The URL must be https on a public address. Up to ten subscriptions per application.

Events

Type Sent when
domain.ready A domain first passes all four checks and is served.
domain.attention_required A ready domain fails a check; serving has stopped.
domain.recovered It is ready again.
domain.deleted A domain was deleted.
{
  "id": "b7e2c1a0-9d8f-4e7a-b6c5-d4e3f2a1b0c9",
  "type": "domain.ready",
  "created_at": "2026-10-04T15:00:00+00:00",
  "data": {"domain": {"id": "6f1c…", "hostname": "forms.acme.com", "reference": "ws_8f3a1c",
                      "status": "ready", "checks": ["…"], "dns_records": ["…"]}}
}

data.domain is the full domain as it was when the event happened, so you need no follow-up request to know the hostname and workspace.

Verify the signature

Each delivery carries:

X-Custom-Domain-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
X-Custom-Domain-Event: domain.ready
X-Custom-Domain-Event-Id: <event id>

Each v1 is HMAC-SHA256 over <t>.<raw body> with a subscription secret. Recompute it with every secret you hold, compare in constant time against any listed v1, and reject the delivery if t is more than five minutes from now.

Python:

from custom_domain import verify_webhook, parse_event, SignatureInvalid

try:
    verify_webhook(request.headers.get("X-Custom-Domain-Signature"), raw_body, secrets=[SECRET])
except SignatureInvalid:
    return 400
event = parse_event(raw_body)  # event.id, event.type, event.created_at, event.domain

Node.js:

import crypto from "node:crypto";

export function verifyWebhook(header, rawBody, secrets, toleranceSeconds = 300) {
  let t = null;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [k, v] = item.split("=", 2);
    if (k === "t") t = Number(v);
    if (k === "v1") signatures.push(Buffer.from(v, "hex"));
  }
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
    return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(s, expected));
  });
}

Delivery

  • Any 2xx answer is success. Failures are retried after 1, 5 and 30 minutes, then 2, 12 and 24 hours, up to 8 attempts.
  • Delivery is at least once and may be out of order: deduplicate on the event id, and compare created_at before changing state.
  • Rotating the secret (POST /v1/webhooks/{id}/rotate) signs with both the new and the previous secret for 24 hours.