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
2xxanswer 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 comparecreated_atbefore changing state. - Rotating the secret (
POST /v1/webhooks/{id}/rotate) signs with both the new and the previous secret for 24 hours.