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; tlsIssuanceAdvisories when there are any (below)
connection.failed (dns_drift)yesscope: "connection", fqdn, the failing records
connection.failed (session_failed)noscope: "session", failedStep
connection.disconnectedyesfqdn, disconnectedAt
session.completedyestlsIssuanceAdvisories when there are any (below)
session.abandonednolastStatus, expiredAt

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:

codeSeverityMeaning
caa_excludes_issuerwarningA 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_issuanceinfoA CAA policy governs the name, and your app has no CA configured to judge it against. Check that it allows yours.
stale_acme_challengewarning_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_uncheckedinfoThe 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

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.

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

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

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

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.
  • The endpoint shows a Paused badge in the dashboard, 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 press Resume on the endpoint in the dashboard, or call POST /api/v1/webhook-endpoints/:endpointId/resume (see 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.

On this page