DoDomain

REST API

The /api/v1 surface — authentication, endpoints, and rate limits.

Base URL: https://app.dodomain.io. Every request and response body is JSON.

Everything on this page is also published machine-readably as an OpenAPI 3.1 document at https://dodomain.io/docs/openapi.json — generated from the same schemas these endpoints validate with, and regenerated by a CI gate that fails on any drift.

Authentication

Two credential kinds, for two different callers:

  • Secret key (dd_sk_...) — server-to-server. Create an app in the dashboard to get one; send it as Authorization: Bearer dd_sk_... from your server only. A missing, malformed, or unknown key returns 401 unauthorized. The public key (dd_pk_...) only identifies the app and grants nothing.
  • Session token (dd_sess_...) — a single-session capability returned by POST /api/v1/sessions. Session-scoped endpoints are public and authorize by the unguessable token itself, which is how the hosted flow and widget work from the browser. Sessions expire after 24 hours; expired sessions answer 410 expired.

Endpoints

MethodPathWhat it does
POST/api/v1/sessionsCreate a connect session for a domain (secret key). Returns token, expiresAt, connectUrl.
GET/api/v1/sessions/:tokenRead one session's state by its token: domain, requested records, status, detected provider, expiry.
GET/api/v1/sessions/:idRead one session by the id webhooks carry (secret key). Works after expiry, and reports a derived expired flag.
POST/api/v1/sessions/:token/detectDetect the domain's DNS provider and the connect path it will get.
POST/api/v1/sessions/:token/verifyTrigger a live DNS check of the session's records against authoritative nameservers. On full match the connection is finalized and webhooks fire.
GET/api/v1/sessions/:token/cloudflare/startStart the Cloudflare one-click OAuth flow for the session.
POST/api/v1/domains/checkPre-flight a domain before connecting: provider, the zone that owns the records, connect tier, nameservers.
GET/api/v1/appsList the apps in your team (never returns secret keys).
GET/api/v1/connectionsList verified connections and their live DNS health, filterable by app or domain.
GET/api/v1/connections/:connectionIdRead one connection and its live DNS health (disconnected ones included).
DELETE/api/v1/connections/:connectionIdDisconnect a connection (stops its monitoring).
POST/api/v1/connections/:connectionId/reverifyQueue an on-demand DNS recheck of a broken connection.
GET/api/v1/webhook-endpointsList your app's webhook endpoints (never returns signing secrets).
POST/api/v1/webhook-endpointsRegister an endpoint. Returns the signing secret once.
PATCH/api/v1/webhook-endpoints/:endpointIdRepoint an endpoint at a new URL. The signing secret is unchanged.
DELETE/api/v1/webhook-endpoints/:endpointIdStop delivering to an endpoint.
POST/api/v1/webhook-endpoints/:endpointId/rotate-secretMint a new signing secret for one endpoint. Returned once; effective immediately.
POST/api/v1/keys/rotateRotate the calling app's own secret key. Returned once; the old key dies immediately.

Creating a session

POST /api/v1/sessions takes the domain and the records the end user must create. The response tells you what the session will actually be verified against:

{
  "id": "cmd...",
  "token": "dd_sess_...",
  "expiresAt": "2026-08-13T09:00:00.000Z",
  "connectUrl": "https://app.dodomain.io/connect/dd_sess_...",
  "records": [{ "type": "CNAME", "host": "app", "fqdn": "app.customer.com" }]
}

records echoes each record you sent alongside fqdn — the fully-qualified name DoDomain will look up on authoritative DNS. Hosts are composed relative to the session's domain, and @ (or an empty host) means the domain itself.

host is relative to domain, not to the registrable apex

If your session domain is already a subdomain — links.acme.com — then host: "links" composes to links.links.acme.com. To attach a record to links.acme.com itself, send host: "@". Check records[].fqdn in the response: it is the name that will be verified.

When something about an accepted request is worth flagging, the response also carries warnings. Warnings never change the status code — the session is created either way — and the key is absent when there is nothing to say:

{
  "warnings": [
    {
      "code": "duplicate_host_label",
      "message": "host \"links\" repeats the leading label of domain \"links.acme.com\" — this session will be verified at \"links.links.acme.com\". Use host \"@\" if you meant \"links.acme.com\".",
      "host": "links",
      "fqdn": "links.links.acme.com"
    }
  ]
}

Branch on code, not on the message text. New codes may be added; treat an unrecognised one as informational.

Reading a session

GET /api/v1/sessions/:token is the debugging endpoint: it echoes back exactly what we stored for a session, so you can confirm the API received what you think you sent. It is authorized by the session token itself (no secret key), and returns 410 expired once the session's 24 hours are up.

{
  "id": "cmd...",
  "domain": "customer.com",
  "records": [{ "type": "CNAME", "host": "app", "value": "cname.sendly.io" }],
  "recipe": null,
  "status": "pending",
  "tier": null,
  "detectedProvider": null,
  "returnUrl": null,
  "expiresAt": "2026-08-13T09:00:00.000Z"
}
FieldMeaning
domainThe session's domain, exactly as you sent it. Record hosts are relative to this.
recordsThe records you requested, verbatim. (Their composed fqdns are in the create response, above.)
recipeWhatever you passed as recipe at creation, echoed back. Nothing server-side consumes it.
statuspendingdetectedverifyingverified, plus authorizing/writing (one-click in progress), failed, and expired.
tierThe connect path detection chose: 1 Cloudflare one-click, 2 Domain Connect one-click, 3 guided manual. null until detection runs.
detectedProviderThe DNS provider detection identified. null until detection runs.
returnUrlWhere the hosted flow offers to send the user back, or null.

null detection fields mean 'not detected yet', never 'detection failed'

tier and detectedProvider are written only by POST /api/v1/sessions/:token/detect, which the hosted flow and widget call when the end user opens the connect link. Until then both are null and status is pending — that pair is the "detection has not run" signal. A detection that fails answers the detect call with a non-2xx and persists nothing, so it never leaves a half-detected session behind. If you drive the API yourself and want these fields populated, call detect.

Reading a session by id (server-side)

GET /api/v1/sessions/:token needs the session token and stops answering once the session expires. When you are reconciling from a webhook — every payload carries sessionId, never the token — or you want to know how a session ended, call the same path with the session id and your secret key instead:

curl https://app.dodomain.io/api/v1/sessions/cmd... \
  -H "Authorization: Bearer dd_sk_..."

One path, two arms: a dd_sess_... segment is the public token read above, anything else is this authenticated by-id read. A key only sees its own app's sessions; a session that exists but belongs to another app answers the same 404 not_found as one that doesn't exist.

{
  "id": "cmd...",
  "appId": "cmd...",
  "domain": "customer.com",
  "records": [{ "type": "CNAME", "host": "app", "fqdn": "app.customer.com" }],
  "recipe": null,
  "status": "verifying",
  "tier": 3,
  "detectedProvider": "cloudflare",
  "connectionId": null,
  "createdAt": "2026-08-16T09:00:00.000Z",
  "expiresAt": "2026-08-17T09:00:00.000Z",
  "expired": false
}
FieldMeaning
recordsThe composed records — each with the fqdn we actually look up, unlike the token endpoint's verbatim echo of what you sent.
connectionIdThe connection this session produced, once it has one; null before that. The same id DELETE/reverify are keyed by.
expiredDerived from expiresAt at read time, so it is true the instant the session runs out — before the background sweep gets around to writing status: "expired".
statusThe persisted status, reported as-is. Read expired for whether the session is over; read status for how far the end user got.

Unlike the token endpoint, this one keeps answering 200 after expiry — reading a session's final state is most of the reason to call it.

Re-checking DNS yourself

POST /api/v1/sessions/:token/verify is a supported thing to call from your own code, not just something the hosted flow does. It runs a live authoritative DNS check of the session's records and, when every record matches, finalizes the connection and fires connection.verified + session.completed — the same path a user clicking Verify takes.

curl -X POST https://app.dodomain.io/api/v1/sessions/dd_sess_.../verify
{
  "verified": false,
  "records": [
    {
      "fqdn": "app.customer.com",
      "type": "CNAME",
      "present": false,
      "note": "no CNAME found at app.customer.com",
      "outcome": "absent"
    }
  ]
}

It is authorized by the session token (no secret key), it is safe to call repeatedly, and calling it on an already-verified session does not double-count your quota. outcome distinguishes absent (we looked, it isn't there) from indeterminate (the lookup itself failed) and domain_not_found (the domain doesn't resolve at all) — only a real authoritative match ever finalizes.

You usually don't need to poll it. DoDomain already re-checks open sessions in the background, so a record added after the user closes the connect window still completes on its own (see Webhooks). Call verify when you want an answer now — for example while a support agent is on the phone with the customer.

Managing webhook endpoints

Everything the dashboard's Webhook endpoints card does is also a REST call, so delivery targets can live in your CI or infrastructure-as-code instead of in someone's browser history. These endpoints take the app's secret key only: an OAuth token (the credential AI assistants connect with) is refused with 403 forbidden and details.code: "SECRET_KEY_REQUIRED", because no consent scope covers managing your credentials.

POST /api/v1/webhook-endpoints with {"url": "https://your-app.com/webhooks/dodomain"} answers 201:

{
  "id": "cme...",
  "appId": "cmd...",
  "url": "https://your-app.com/webhooks/dodomain",
  "createdAt": "2026-08-17T09:00:00.000Z",
  "secret": "whsec_..."
}

secret is shown once

secret is the signing secret you verify x-dodomain-signature with. It appears in exactly two responses — the create above and rotate-secret below — and never again: GET /api/v1/webhook-endpoints returns the same object without the secret field. Store it when you receive it. Lost it? Rotate, and update your handler.

PATCH /api/v1/webhook-endpoints/:endpointId takes the same {"url": ...} body and moves an endpoint without re-keying it — a receiver that changed hosts keeps verifying with the secret it already has. POST /api/v1/webhook-endpoints/:endpointId/rotate-secret is the explicit re-key: it returns the same object shape as create, with a new secret. Rotation is immediate and total — signatures switch to the new secret at once, including retries of deliveries that were queued before you rotated, so deploy the new secret to your handler promptly.

DELETE /api/v1/webhook-endpoints/:endpointId answers {"id": "...", "deleted": true}. Past deliveries keep their history, but a failed delivery to a deleted endpoint can no longer be redriven.

Failures use the same codes the rest of the API does:

StatusCodeWhen
400invalid_requestThe URL isn't https://, points at localhost/a private address, or your app already sends to it.
402quota_exceededThe app is at its endpoint cap (10 per app). Remove one before adding another.
404not_foundNo such endpoint under your app. Another app's endpoint id answers the same 404 — by design.

Rotating your secret key

POST /api/v1/keys/rotate rotates the secret key that authenticated the call and returns the replacement once:

{
  "appId": "cmd...",
  "publicKey": "dd_pk_...",
  "secretKey": "dd_sk_...",
  "rotatedAt": "2026-08-17T09:00:00.000Z",
  "previousKeyExpiresAt": null
}

The public key is echoed unchanged: it identifies the app in the widget and is never rotated.

By default there is no overlap window — with no body (or {"overlapHours": 0}) the key you called with stops authenticating the moment this returns, so this response is the only copy of the new key. Write it to your secret store before your next API call. That default is deliberate: if a key leaked, rotating it must be the revoke, never a grace period.

Opt-in zero-downtime rotation: send {"overlapHours": 1} or {"overlapHours": 24} and BOTH keys authenticate until previousKeyExpiresAt (returned as an ISO timestamp), so your servers can pick up the new key with no failed requests in between. Two things to know about the window:

  • Exactly one previous key is ever kept. Rotating again — with or without a window — replaces the previous slot, so key n-1 stops working instantly regardless of how much window it had left. Chain rotations no faster than your rollout.
  • A zero-overlap rotate ends any live window. Rotating with the default overlapHours: 0 (or no body) kills the previous key along with installing the new one — it is the escape hatch when a key you gave a window to turns out to be compromised.

The dashboard's API-keys card shows a live window's expiry and when the old key last authenticated a request, so you can see when your cutover is actually complete before the window ends.

A rotation from a scheduled job, using GitHub Actions and the gh CLI to write the new key back:

#!/usr/bin/env bash
set -euo pipefail

response="$(curl -fsS -X POST https://app.dodomain.io/api/v1/keys/rotate \
  -H "Authorization: Bearer ${DODOMAIN_SECRET_KEY}")"

new_key="$(printf '%s' "$response" | jq -er '.secretKey')"

# Store FIRST, verify SECOND: the old key is already dead at this point.
gh secret set DODOMAIN_SECRET_KEY --body "$new_key"

curl -fsS https://app.dodomain.io/api/v1/webhook-endpoints \
  -H "Authorization: Bearer ${new_key}" > /dev/null
echo "rotated $(printf '%s' "$response" | jq -r '.appId')"

Keys can rotate themselves — nothing more

A secret key may rotate itself, which is what makes scheduled rotation possible. There is deliberately no API to create, list, or delete keys: key inventory stays behind a signed-in dashboard session, so a leaked key can never mint a second, hidden credential that survives you rotating the one you know about. If a key is compromised, rotating it is the revoke.

Rate limits and quotas

Secret-key requests are rate-limited per app on a fixed one-minute window, by plan. Over the cap, requests fail with 429 rate_limited, a Retry-After header (seconds), and details.retryAfterSeconds in the body.

PlanAPI requests / minVerified connections / mo
Free6050
Pro300500
Scale1,2002,500

The monthly connections number is a billing quota, not a rate limit: on every plan it is enforced at session creation (402 quota_exceeded once your team is at its cap). Connections you already have keep working — only new sessions are refused.

What counts against it: exactly one unit, the first time a session's connection is finalized. Re-verifying, a connection breaking and recovering, and disconnecting and reconnecting the same session all add nothing — the quota counts connections made, not verifications run. A connection created in an earlier month that recovers today counts against neither month again.

Session-scoped endpoints (/api/v1/sessions/:token/...) are public — the token is the credential — so they carry their own abuse caps instead of the plan's: one per client address, and a higher ceiling per session across all addresses. Both answer with the same 429 rate_limited shape. Normal use, including the hosted flow's automatic re-check every 15 seconds, sits far below either.

Two different 429s

Rate-limit responses carry a details.reason field — branch on it rather than the message text to tell a per-minute window limit apart from other request-rate protections.

On this page