DoDomain

Webhooks and monitoring

The HMAC signature scheme, the four webhook events, and the automatic DNS drift monitor built into every plan.

Add webhook endpoints per app in the dashboard — 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.

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.

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 and verify_webhook(secret, raw_body, header) from dodomain-sdk both return a boolean. Acknowledge with a 2xx quickly and do slow work asynchronously.

Events

The body is JSON with the shape:

{
  "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"
}
FieldMeaning
idStable delivery id — dedupe on this. Identical across every retry of the same delivery, and also sent as the x-dodomain-delivery-id header.
typeThe event type (table below).
occurredAtISO-8601 timestamp of when the event happened — not when this attempt was sent, so a retried delivery still reports the original time.
dataThe per-event payload.
eventDeprecated 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.

EventWhen it fires
connection.verifiedThe 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.failedSomething went wrong with a connection — a verified connection drifted (monitoring found records no longer matching), or a session's connection attempt failed.
connection.disconnectedYou ended the connection via DELETE /api/v1/connections/:connectionId; monitoring stops for it.
session.completedThe connect flow reached terminal success — fires exactly once per session.
session.abandonedThe session expired (24 hours) without completing. See Session expiry for exactly when and what to do with it.

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.

EventconnectionIdAlso carries
connection.verifiedyesrecovered: true when a broken connection healed
connection.failed (dns_drift)yesscope: "connection", fqdn, the failing records
connection.failed (session_failed)noscope: "session", failedStep
connection.disconnectedyesfqdn, disconnectedAt
session.completedyes
session.abandonednolastStatus, expiredAt

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

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:

{
  "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"
  }
}
FieldMeaning
lastStatusThe status the session was in when it expired — pending, detected, authorizing, writing, or verifying. It tells you how far the end user actually got.
expiredAtWhen the sweep expired it (close to, but not identical to, the session's expiresAt).
sessionIdUse 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 endedTerminal event
Records verifiedsession.completed
Attempt failed mid-flowconnection.failed (session_failed)
Ran out the 24 hours still unfinishedsession.abandoned

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.

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.

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.

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

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 — from the dashboard's Re-verify button or via POST /api/v1/connections/:connectionId/reverify. The result arrives asynchronously as a webhook and on the dashboard.

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 visible in the app's dashboard event log. Disconnecting a connection stops monitoring for it.

On this page