# Webhooks

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

Source: https://customdomainapi.com/docs/webhooks/

## 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. |

```json
{
  "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:

```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:

```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.
