# Domain webhooks: events and HMAC signatures (https://dodomain.io/docs/webhooks)

DoDomain webhooks for custom domain connections: the five event types, how to verify the HMAC-SHA256 signature, retries, and free DNS drift monitoring.

<DocsIllustration name="webhook" />

Add webhook endpoints per app with the `add_webhook_endpoint` tool or `POST /api/v1/webhook-endpoints`; each one gets its own `whsec_...` signing secret. Every event fans out to every endpoint the app has registered (up to 10 endpoints per app), with retries per endpoint.

The same lifecycle is available over REST (register, list, repoint, rotate the signing secret, delete), so endpoints can be managed from CI or infrastructure-as-code: see [Managing webhook endpoints](https://dodomain.io/docs/api#managing-webhook-endpoints).

## Verifying signatures [#verifying-signatures]

Every delivery carries an `x-dodomain-signature` header in the format `t=<unix ms>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `t + "." + rawBody` keyed with your `whsec_...` secret. Verify against the **raw** request body: the signature covers the exact bytes sent.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyDoDomainSignature(secret, rawBody, header, toleranceMs = 5 * 60 * 1000) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() - t) > toleranceMs) return false;
  const expected = createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");
  const got = String(parts.v1 ?? "");
  return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
```

Or use a server SDK: `verifyWebhook(secret, rawBody, signatureHeader)` from [`@dodomain/node`](https://dodomain.io/docs/node-sdk) and `verify_webhook(secret, raw_body, header)` from [`dodomain-sdk`](https://dodomain.io/docs/python-sdk) both return a boolean. Acknowledge with a 2xx quickly and do slow work asynchronously.

## Events [#events]

The body is JSON with the shape:

```json
{
  "id": "whd_2f9c...",
  "type": "connection.verified",
  "occurredAt": "2026-08-06T12:34:56.000Z",
  "data": {
    "sessionId": "sess_...",
    "connectionId": "conn_...",
    "domain": "app.customer.com"
  },
  "event": "connection.verified"
}
```

| Field        | Meaning                                                                                                                                          |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`         | Stable delivery id: **dedupe on this**. Identical across every retry of the same delivery, and also sent as the `x-dodomain-delivery-id` header. |
| `type`       | The event type (table below).                                                                                                                    |
| `occurredAt` | ISO-8601 timestamp of when the event happened, not when this attempt was sent, so a retried delivery still reports the original time.            |
| `data`       | The per-event payload.                                                                                                                           |
| `event`      | **Deprecated** alias for `type`, always identical to it. Present only for receivers written before 2026-08-06. Read `type` in new code.          |

Treat the body as open for extension: new fields may be added, and new event types may appear. Ignore anything you don't recognise rather than erroring.

| Event                     | When it fires                                                                                                                                                                                            |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connection.verified`     | The connection ENTERED the healthy state: fires once when it is first verified, and once per recovery after a `connection.failed` (then with `recovered: true`). Never twice for one uneventful connect. |
| `connection.failed`       | Something went wrong with a connection: a verified connection drifted (monitoring found records no longer matching), or a session's connection attempt failed.                                           |
| `connection.disconnected` | You ended the connection via `DELETE /api/v1/connections/:connectionId`; monitoring stops for it.                                                                                                        |
| `session.completed`       | The connect flow reached terminal success; fires exactly once per session.                                                                                                                               |
| `session.abandoned`       | The session expired (24 hours) without completing. See [Session expiry](#session-expiry) for exactly when and what to do with it.                                                                        |

### Identifying what an event is about [#identifying-what-an-event-is-about]

Every payload carries `domain` and `sessionId`. Events about a **connection** also carry `connectionId`, the id `DELETE /api/v1/connections/:connectionId` and `POST /api/v1/connections/:connectionId/reverify` are keyed by, so you can act on the connection an event announces without looking it up first.

| Event                                  | `connectionId` | Also carries                                                                                          |
| -------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `connection.verified`                  | yes            | `recovered: true` when a broken connection healed; `tlsIssuanceAdvisories` when there are any (below) |
| `connection.failed` (`dns_drift`)      | yes            | `scope: "connection"`, `fqdn`, the failing `records`                                                  |
| `connection.failed` (`session_failed`) | no             | `scope: "session"`, `failedStep`                                                                      |
| `connection.disconnected`              | yes            | `fqdn`, `disconnectedAt`                                                                              |
| `session.completed`                    | yes            | `tlsIssuanceAdvisories` when there are any (below)                                                    |
| `session.abandoned`                    | no             | `lastStatus`, `expiredAt`                                                                             |

### TLS-issuance advisories [#tls-issuance-advisories]

A verified record proves the DNS is live, not that you can issue a certificate for the name. Two pre-existing records decide that: a **CAA** policy that doesn't name your CA, and a stale **`_acme-challenge`** record left by a previous vendor. Every verify pass reads both through the domain's own nameservers, at each host that will terminate TLS: the `A`, `AAAA` and `CNAME` records the session asked for; a session of only `TXT` records reads neither. When it finds something, `connection.verified` and `session.completed` carry an additional `tlsIssuanceAdvisories` array, omitted entirely when the list is empty, so a receiver that ignores it sees the same body as before. Advisories never change whether the connection verified.

Each entry has `code`, `severity` (`warning` or `info`), `fqdn` (the name the certificate is for), `evidenceFqdn` (where the record was read; for CAA, possibly a parent), `evidence` (the published values, verbatim) and a human-readable `note`:

| `code`                   | Severity  | Meaning                                                                                                                                                           |
| ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `caa_excludes_issuer`    | `warning` | A CAA policy governs the name and its `issue` tags don't include the CA configured on your app (**Apps → Branding → Certificate authority**). Issuance will fail. |
| `caa_restricts_issuance` | `info`    | A CAA policy governs the name, and your app has no CA configured to judge it against. Check that it allows yours.                                                 |
| `stale_acme_challenge`   | `warning` | `_acme-challenge.<fqdn>` already holds a TXT token or a CNAME delegation: a previous vendor's DNS-01 leftovers, which can fail a new DNS-01 validation.           |
| `tls_issuance_unchecked` | `info`    | The check itself couldn't complete (nameservers unreachable, SERVFAIL, timeout). Unknown is not clean.                                                            |

The same list is on `GET /api/v1/sessions/:id` as `tlsIssuanceAdvisories` (the last pass's snapshot) and on `POST /api/v1/sessions/:token/verify` as `advisories` (live).

The two without it are the two where no connection exists: a `session_failed` attempt never created one, and an abandoned session never completed. Use `sessionId` there, and if the user retries and succeeds, the resulting `connection.verified` carries the `connectionId` for that same session.

The connection id is stable for the life of a connection: a recovery, a re-verify, and a reconnect of the same session all report the id the connection already had.

## Session expiry [#session-expiry]

A connect session lives for **24 hours** from creation. `session.abandoned` is the event that tells you one ran out; it is the only expiry signal there is, and there is no separate `session.expired`.

**When it fires.** A background job sweeps every 5 minutes for sessions that are past `expiresAt` and never reached a terminal state, so the event lands within roughly five minutes of the 24-hour mark, not exactly on it. It is emitted once per session; the session's stored status becomes `expired` in the same transaction, so a repeated sweep cannot re-emit it.

**The payload:**

```json
{
  "id": "whd_...",
  "type": "session.abandoned",
  "occurredAt": "2026-08-17T09:00:12.000Z",
  "data": {
    "domain": "app.customer.com",
    "sessionId": "cmd...",
    "lastStatus": "verifying",
    "expiredAt": "2026-08-17T09:00:12.000Z"
  }
}
```

| Field        | Meaning                                                                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lastStatus` | The status the session was in when it expired: `pending`, `detected`, `authorizing`, `writing`, or `verifying`. It tells you how far the end user actually got.      |
| `expiredAt`  | When the sweep expired it (close to, but not identical to, the session's `expiresAt`).                                                                               |
| `sessionId`  | Use it with `GET /api/v1/sessions/:id`: an expired session is still readable there, which is the only way to see its final state (the token endpoint answers `410`). |

**Which sessions get it.** Only sessions that were still in progress. A session that succeeded ends with `session.completed`, and a session whose connect attempt failed outright ends with `connection.failed` (`reason: "session_failed"`); neither is abandoned, and neither will also produce a `session.abandoned`. So exactly one terminal event per session:

| How the session ended                 | Terminal event                         |
| ------------------------------------- | -------------------------------------- |
| Records verified                      | `session.completed`                    |
| Attempt failed mid-flow               | `connection.failed` (`session_failed`) |
| Ran out the 24 hours still unfinished | `session.abandoned`                    |

<Callout title="A failed attempt is not final: abandonment is">
  `connection.failed` (`session_failed`) is retryable: the end user can come back and finish the
  same session, which then also emits `connection.verified` and `session.completed`.
  `session.abandoned` is the one that closes the flow for good; after it, that session can never be
  verified.
</Callout>

**What to do with it.** Treat it as "this domain never got connected; ask the user to start again". Create a fresh session with `POST /api/v1/sessions`: an expired token cannot be revived.

<Callout type="warn" title="Don't infer expiry client-side">
  You do not need to compare `expiresAt` against the clock in your own code to know whether a
  session is over. `GET /api/v1/sessions/:id` returns a derived `expired` boolean, and it keeps
  working after expiry; `session.abandoned` is the push half of the same fact.
</Callout>

## Sessions that finish after the user walks away [#sessions-that-finish-after-the-user-walks-away]

DoDomain re-checks open sessions against authoritative DNS in the background while they are alive. If an end user adds the DNS record and then closes the connect window without clicking **Verify**, the session still completes on its own: you receive `connection.verified` and `session.completed` exactly as if they had clicked, with the same metering and the same `connectionId`.

Checks are frequent in a session's first minutes and space out as it ages, so a record added right after the user leaves is normally picked up within a couple of minutes. Nothing is checked after `expiresAt`. Your side needs no change for this: it is the same two events, arriving over the same webhook endpoints, and you can always force a check yourself with `POST /api/v1/sessions/:token/verify`.

## Automatic DNS drift monitoring [#automatic-dns-drift-monitoring]

Verified connections don't stay verified on trust: DoDomain keeps rechecking each connection's records against authoritative DNS, on every plan. The cadence widens with a connection's age; drift is most likely right after setup:

- **First 24 hours after verification**: re-checked every **10 minutes**.
- **First week**: re-checked **hourly**.
- **Steady state**: re-checked every **6 hours**.

If a customer deletes or edits a record later, the monitor confirms before it alerts: a suspected drift is re-checked every 10 minutes and must be confirmed on three consecutive checks (roughly 20–30 minutes after first observation) before the connection flips to **broken** and you get a `connection.failed` webhook; a transient resolver blip or a records edit in progress never pages you. Once broken, the connection is re-checked every 10 minutes so recovery is noticed fast; when the records return, `connection.verified` fires again with `recovered: true`.

`connection.verified` fires on **entry into the healthy state**: once when the connection is first verified, and once per recovery after a `connection.failed`. It never fires twice for one uneventful connect: a re-verify of a connection that is already healthy emits nothing.

You can also trigger an on-demand recheck of a broken connection, with the `reverify_connection` tool or `POST /api/v1/connections/:connectionId/reverify`. The result arrives asynchronously as a webhook and in `list_connections`.

### Domains behind a CDN proxy [#domains-behind-a-cdn-proxy]

A CDN proxy in front of the connected name (Cloudflare's orange cloud is the common one) replaces the record with the proxy's own edge addresses, and a proxied CNAME disappears from DNS altogether. The domain still works, so the monitor does not treat that as drift: when a missing CNAME, A or AAAA record is explained by the name answering only from Cloudflare's published edge ranges, the connection stays **active**, no `connection.failed` fires, and the dashboard shows "proxied by Cloudflare" under the status. A connection that was marked broken because of a proxy recovers on its next check, with `connection.verified` (`recovered: true`).

What DoDomain can't do is see behind the proxy: it confirms the name answers from Cloudflare, not where Cloudflare forwards the traffic. A missing TXT or MX record is never explained this way, since proxies don't touch those. The first verification of a new connection still needs the record itself to be visible, so turn the proxy on after the connection verifies.

### What monitoring costs [#what-monitoring-costs]

Nothing, on any plan. Every verified connection is monitored for as long as it exists (Free, Pro and Scale alike), and monitoring is not a per-domain meter, a tier limit, or an add-on. The only quota DoDomain bills is [verified connections per month](https://dodomain.io/docs/pricing), which counts a connection once, on its first verification; a domain breaking and recovering is never counted again. Monitoring stops only when you disconnect the connection (`DELETE /api/v1/connections/:connectionId`).

Why it is free: DoDomain is never in your request path, so a broken customer domain does not fail loudly on our side; the monitor is the only way you learn about it before the customer does. Charging for the signal would push integrators to switch it off on exactly the domains that need it.

## Delivery and retries [#delivery-and-retries]

Delivery is **at-least-once**: a delivery may arrive more than once (a retry after your endpoint accepted but timed out, for example). Key your side effects on `id`: it is the same value on every attempt of the same delivery.

Failed deliveries are retried on a retry ladder; deliveries that exhaust retries are listed by `list_webhook_deliveries` (and `get_webhook_delivery`, `redrive_webhook_delivery`). Disconnecting a connection stops monitoring for it.

When a delivery exhausts its retries, the team's owners and admins get an email about it (at most one a day per endpoint) that also says when the endpoint will be paused if nothing gets through.

### Paused endpoints [#paused-endpoints]

An endpoint that stays dead is paused automatically. DoDomain pauses an endpoint once **both** are true:

- it has had **no successful delivery for 7 days**, measured from the later of its last successful delivery and the day it was added (or the last time it was resumed), and
- **at least 5 deliveries** to it exhausted every retry in that time.

A burst of failures alone never pauses an endpoint, and neither does a quiet week with no events. One delivery that your endpoint accepts restarts the 7-day clock.

While an endpoint is paused:

- New events are still recorded: nothing is lost. Each one appears in the delivery log as **skipped** and is never sent.
- Redriving a delivery to it is refused until you resume it.
- `list_webhook_endpoints` shows it as paused, and `pausedAt` is set on it in `GET /api/v1/webhook-endpoints`.

Pausing sends one email to the team's owners and admins, with a link to resume.

To resume, fix your receiver, then call the `resume_webhook_endpoint` tool or `POST /api/v1/webhook-endpoints/:endpointId/resume` (see [Managing webhook endpoints](https://dodomain.io/docs/api#managing-webhook-endpoints)). Changing the endpoint's URL with `PATCH` resumes it too. Resuming restarts the 7-day clock. It does not resend the skipped deliveries on its own: redrive them from the delivery log, and each is sent with its original `id`, so your deduplication still holds.

## Common questions [#common-questions]

### What webhook events does DoDomain send? [#what-webhook-events-does-dodomain-send]

Five: `connection.verified`, `connection.failed`, `connection.disconnected`, `session.completed` and `session.abandoned`. The `connection.*` events follow a customer's domain through going live, drifting and being disconnected. The `session.*` events close out the connect flow. New event types may be added, so ignore a `type` you do not recognise instead of returning an error.

### Which webhook tells me a customer's domain is connected? [#which-webhook-tells-me-a-customers-domain-is-connected]

`connection.verified`. It fires when the records are first confirmed at the domain's authoritative nameservers, and again with `recovered: true` if the connection heals after a `connection.failed`. Its `data` carries the `domain`, `sessionId` and `connectionId`. `session.completed` fires alongside the first one for the same session. Activate the tenant on `connection.verified`, and make that handler idempotent so a recovery does not activate it twice.

### How do I verify a DoDomain webhook signature? [#how-do-i-verify-a-dodomain-webhook-signature]

Each delivery carries an `x-dodomain-signature` header shaped `t=<unix ms>,v1=<hex>`. `v1` is the HMAC-SHA256 of `t + "." + rawBody`, keyed with the endpoint's `whsec_...` secret. Compute it over the raw request body, reject a timestamp more than five minutes from your clock, and compare in constant time. `verifyWebhook` in `@dodomain/node` does all three and returns a boolean.

### How do I avoid handling the same webhook twice? [#how-do-i-avoid-handling-the-same-webhook-twice]

Delivery is at-least-once, so a retry can repeat an event you already handled. The envelope `id` is identical on every attempt and is also sent as the `x-dodomain-delivery-id` header, so dedupe on it before doing any work.

### Does DoDomain follow redirects when it delivers a webhook? [#does-dodomain-follow-redirects-when-it-delivers-a-webhook]

No. A `3xx` response counts as a failed delivery and goes onto the retry ladder, so register the final URL of your receiver instead of one that redirects.

### How do I find out when a customer changes their DNS record after go-live? [#how-do-i-find-out-when-a-customer-changes-their-dns-record-after-go-live]

Listen for `connection.failed`. The drift monitor re-checks every verified connection against authoritative DNS, and after three consecutive failed checks it sends `connection.failed` naming the failing records. When the records come back, `connection.verified` fires again with `recovered: true`. Monitoring is included on every plan, [Free](https://dodomain.io/docs/pricing) included.
