Official Python SDK for doDomain — connect your customers' custom domains without becoming a DNS support desk.
You mint a connect session, send the customer to a hosted flow that detects their DNS provider and walks them through (or one-clicks) the records, and you get a signed webhook when the domain goes live.
This SDK covers all 16 callable REST operations, sync and async, plus webhook
signature verification. (The API's other four routes are browser navigations that
answer 302 on every path; the SDK exposes them as URL builders and never fetches
them.)
pip install dodomain-sdkRequires Python 3.10+. The only runtime dependency is httpx.
The distribution is published as
dodomain-sdk, but the import package isdodomain—pip install dodomain-sdk, thenfrom dodomain import DoDomain.
Every authenticated call takes your app's secret key — a dd_sk_… value from
the doDomain dashboard (your app → API keys). Keep it server-side and read it from
the environment:
export DODOMAIN_SECRET_KEY="dd_sk_live_…"importosfromdodomainimportDoDomainclient=DoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"])A secret key is app-scoped — the app is implicit in the key. If you are
integrating through OAuth instead (the MCP/agent path), pass the access token as
secret_key; the SDK detects it, and app_id then becomes required on
sessions.create because an OAuth token is team-scoped.
importosfromdodomainimportDoDomain, DnsRecordclient=DoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"])
session=client.sessions.create(
domain="app.customer.com",
records=[DnsRecord(type="CNAME", host="app", value="cname.dodomain.io")],
return_url="https://customer.com/settings/domains",
)
print(session.connect_url) # send your customer hereprint(session.expires_at) # 24 hours from now, timezone-awareforrecordinsession.records:
print(record.fqdn) # the name we will actually verify — check thisRender session.connect_url as a link or redirect. When the customer finishes,
doDomain fires a connection.verified webhook carrying session.id as
sessionId, so you can correlate it back to the row you just wrote.
Read session.records before you show the customer anything. The API composes
each host under your domain, so domain="links.acme.com" with host="links"
is verified at links.links.acme.com — a doubled label that used to surface only
as a mysteriously failing verify. session.warnings carries advisories about a
request that was accepted anyway (duplicate_host_label is the one that exists
today); a warning never changes the status code.
Two different reads, and the difference matters. From your server, use
sessions.get(session_id) — it takes your credential, it is addressable by the
sessionId every webhook carries, and it still answers after the session expired:
state=client.sessions.get("cs_01HZX")
state.status# "verified"state.expired# True once the 24h TTL passed — even before the reaper catches upstate.connection_id# the DomainConnection id, or None if it never finalizedstate.records# composed names (type/host/fqdn) — no `value` on this armThe token-public routes are the other read: they take the session token in the path as the capability, send no credential at all, and are what a browser or a customer-side process can call:
public=client.sessions.retrieve(session.token) # status, records, detected tierdetected=client.sessions.detect(session.token) # provider, tier, manual guideresult=client.sessions.verify(session.token) # check live DNS nowforrecordinresult.records:
print(record.fqdn, record.type, record.outcome)retrieve raises ExpiredError forever once the TTL passes — correct for a
capability URL, useless for support, which is exactly why get exists.
Two endpoints are browser navigations, not API calls — they answer 302 on
every path. The SDK exposes them as URL builders so you can render your own CTA,
and never fetches them:
session.cloudflare_start_url# tier-1 Cloudflare OAuth flowsession.domain_connect_start_url# tier-2 Domain Connect one-clickAsyncDoDomain has the identical resource tree, arguments and return types —
every method is awaitable and list_all is an async iterator.
importasyncioimportosfromdodomainimportAsyncDoDomain, DnsRecordasyncdefmain() ->None:
asyncwithAsyncDoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"]) asclient:
session=awaitclient.sessions.create(
domain="app.customer.com",
records=[DnsRecord(type="CNAME", host="app", value="cname.dodomain.io")],
)
print(session.connect_url)
asyncforconnectioninclient.connections.list_all():
print(connection.id, connection.status)
asyncio.run(main())A connection is a customer domain that went live. doDomain keeps checking its DNS and tells you when it drifts.
# Hold an id from a webhook? Read that one connection directly.conn=client.connections.get("conn_123")
conn.status# "active" | "broken"conn.record_fqdns# ("status.customer.com",) — the names we actually monitorconn.disconnected_at# not None => monitoring stoppedpage=client.connections.list(limit=100)
page.connections# tuple[Connection, ...]page.next_cursor# str | None — opaque, pass it straight backpage.has_more# bool# Auto-paginating iterator — the ergonomic default.forconnectioninclient.connections.list_all(domain="app.customer.com"):
print(connection.id, connection.status, connection.verified_at)
# Include the ones you disconnected, to reconcile against the archive.forconnectioninclient.connections.list_all(include_disconnected=True):
print(connection.id, connection.disconnected_at)The cursor is opaque by contract. Pass next_cursor back unchanged; never
construct, parse or persist its internals.
# Ask for an on-demand DNS re-check. Answers 202 — the outcome arrives as a# connection.verified / connection.failed webhook, not in this response.client.connections.reverify("conn_123")
# Stop monitoring a domain the customer removed from your product.result=client.connections.disconnect("conn_123")
result.disconnected_at# datetimeresult.already_disconnected# False on the call that did it, True on a repeatdisconnect is idempotent by construction: a repeat returns the originaldisconnected_at and emits no second webhook.
A connection owned by another app or team answers 404, never 403 — the API
refuses to confirm that someone else's id exists, and this SDK does not
reinterpret that as a permission problem. get is the exception to the list's
default in one way: it does return a disconnected connection, because a caller
naming an id already knows the row exists.
Read
record_fqdns, notfqdn.Connection.fqdnhas always been written as the session's domain, so a connection verified forstatus.acme.comreportsfqdn="acme.com". It cannot be fixed in place — a session may carry several records, so there is no single honest "the" fqdn — and it keeps its value for the integrators already reading it.record_fqdnscarries the real answer: every name doDomain monitors for that connection.
Manage delivery targets from CI or IaC instead of the dashboard. Secret-key
only — an OAuth token is refused with a 403 whose
exc.secret_key_required is True.
endpoint=client.webhook_endpoints.create(url="https://acme.example/webhooks/dodomain")
endpoint.secret# "whsec_…" — SHOWN ONCE. Store it now.foreinclient.webhook_endpoints.list():
print(e.id, e.url) # never carries a secretclient.webhook_endpoints.update("whe_123", url="https://acme.example/v2") # secret unchangedrotated=client.webhook_endpoints.rotate_secret("whe_123")
rotated.secret# the new one, also shown onceclient.webhook_endpoints.delete("whe_123")Three things that will bite if assumed away:
- The signing secret is show-once.
createandrotate_secretreturn it and nothing else ever does. It is kept out of the object'sreprso a traceback cannot spill it into your logs; read it off.secret. - Rotation is an immediate cutover. There is no dual-secret window: signatures switch the moment the call returns, including retries of deliveries created before it. Deploy the new secret to your receiver first.
get(endpoint_id)is a client-side lookup overlist()— the API has no read-one route — so it costs one list request, and theNotFoundErrorit raises carriesstatus_code == 0because no 404 came back from the server.
rotated=client.keys.rotate()
rotated.secret_key# the NEW dd_sk_… — the only copy that will ever existrotated.public_key# unchanged, so CI can assert it rewrote the right approtated.previous_key_expires_at# None: the old key is already deadThe default is an immediate cutover. The key you authenticated the call with
stops working the instant the response is produced. Write rotated.secret_key to
your secret store before doing anything else — drop it and you are locked out
until you rotate again from the dashboard. The client you called it on still
holds the old key; build a new one from the result.
If you cannot deploy the new key in the same breath, ask for an overlap window and both keys authenticate until it closes:
rotated=client.keys.rotate(overlap_hours=24) # 0 (default), 1 or 24rotated.previous_key_expires_at# when the OLD key stops workingRotate, ship rotated.secret_key everywhere, and let the old one lapse on its
own. Three things to hold on to:
- Exactly one previous key is ever kept. Rotating again overwrites that slot and kills key n-1 immediately, whatever was left of its window — so the safe rhythm is rotate, deploy, then rotate again, never two rotations in a row.
- A zero-overlap rotation is the kill switch.
keys.rotate()with the default also terminates a window still running from an earlier rotation, which is how you revoke a previous key early. - The client you called it on keeps using the key it was built with, so within a
window it keeps working; past the window its next call is a
401.
There is deliberately no keys.create, keys.list or keys.revoke: key
inventory stays behind a human dashboard session, so a stolen key can never mint a
second hidden credential that survives you rotating the one you know about.
Rotating is the revoke.
Find out how a domain would connect before you create anything. Stateless, persists nothing, available on every plan:
check=client.domains.check(domain="app.customer.com")
check.zone# "customer.com" — the registrable apex DNS is managed atcheck.provider# "cloudflare"check.tier# 1 | 2 | 3check.method# "oauth" | "domain-connect" | "guided"check.confidence# "high" | "medium" | "low"check.guide.steps# copy-ready manual instructionsforappinclient.apps.list():
print(app.id, app.name, app.public_key, app.sandbox)A secret key sees exactly its own app — listing siblings would widen a single leaked key into team-wide reconnaissance. The model carries no secret material.
doDomain signs every delivery Stripe-style:
x-dodomain-signature: t=<unix millis>,v1=<hex sha256 hmac>
x-dodomain-event: connection.verified
x-dodomain-delivery-id: whd_…
Verify against the raw request body, before any JSON parsing — re-serializing a parsed body changes the bytes and the signature will not match.
importjsonimportosfromdodomainimportverify_webhook@app.post("/webhooks/dodomain")asyncdefhandle(request):
raw=awaitrequest.body()
signature=request.headers.get("x-dodomain-signature", "")
ifnotverify_webhook(os.environ["DODOMAIN_WEBHOOK_SECRET"], raw, signature):
returnResponse(status_code=400)
event=json.loads(raw)
...verify_webhook(secret, body, header, tolerance_ms=300_000, now_ms=None) -> bool
bodymay bestrorbytes;secrettoo.- The replay window is five minutes by default.
- It never raises. A wrong secret, a tampered body, a stale timestamp, a
malformed or missing
v1, and outright garbage are all justFalse— an attacker cannot turn a crafted header into a 500 inside your handler. - Comparison is constant-time.
{
"id": "whd_…",
"type": "connection.verified",
"occurredAt": "2026-08-17T10:00:00.000Z",
"data": { "sessionId": "cs_…", "connectionId": "conn_…" },
"event": "connection.verified"
}Read type. Dedupe on id — it is stable across retries and is the same
value as the x-dodomain-delivery-id header, so you can dedupe before parsing the
body at all. event is a deprecated alias of type, byte-identical to it,
kept only so receivers written before the 2026-08-06 envelope cutover keep
parsing; do not write new code against it.
data always carries sessionId as your correlation handle, and every payload
that announces a connection also carries connectionId — the id
connections.get / reverify / disconnect are keyed by.
Event types: connection.verified, connection.failed,
connection.disconnected, session.completed, session.abandoned. A receiver
that does not recognise a type must ignore it — the vocabulary grows additively.
No typed event parser ships, on purpose. Both the event vocabulary and the payload fields grow additively, so a strict parser would reject a delivery the day the API adds a type — exactly the failure a webhook receiver must not have.
verify_webhookchecks the HMAC and nothing else, which is what makes it safe across every additive change.
Every failure is a subclass of DoDomainError. Nothing raw — not a
JSONDecodeError from a proxy's HTML 502, not an httpx timeout — escapes.
fromdodomainimportDoDomainAPIError, NotFoundError, RateLimitErrortry:
client.connections.reverify("conn_123")
exceptNotFoundError:
... # unknown id, or one you do not ownexceptRateLimitErrorasexc:
print(exc.reason, exc.retry_after)
exceptDoDomainAPIErrorasexc:
print(exc.code, exc.status_code, exc.message, exc.details)API error code | Exception | Status |
|---|---|---|
unauthorized | AuthenticationError | 401 |
forbidden | PermissionError_ | 403 |
not_found | NotFoundError | 404 |
expired | ExpiredError | 410 |
invalid_request | InvalidRequestError | 400 |
quota_exceeded | QuotaExceededError | 402 |
not_configured | NotConfiguredError | 503 |
conflict | ConflictError | 409 |
rate_limited | RateLimitError | 429 |
internal | InternalServerError | 500 |
| (anything else) | DoDomainAPIError | as sent |
Plus, outside the HTTP layer: DoDomainConfigError (bad constructor options),
DoDomainConnectionError (no response was ever received), and
InvalidResponseError (a 2xx body that does not match the contract).
exc.message is frequently None. The API drops the message field on most
thrown errors, so do not build your user-facing string from it. str(exc)
synthesizes a useful one for you:
doDomain API error: POST /api/v1/sessions returned HTTP 402 (quota_exceeded)
An InvalidRequestError raised by the SDK's own input validation carries
status_code == 0: no request was sent.
The plan cap starts at 60 requests/minute, in fixed 60-second windows. A 429
carries Retry-After, and — importantly — two different meanings:
exceptRateLimitErrorasexc:
ifexc.reason=="recently_checked":
# This ONE connection was DNS-checked inside its 10-minute cooldown.# Nothing global is throttled. Do NOT retry — show "checked recently,# try again in a few minutes". A backoff loop here only burns your plan# quota against the other limiter.show_cooldown_notice(exc.retry_after)
else: # reason == "request_rate"# You are calling too fast. exc.limit is the per-minute cap.back_off(exc.retry_after)The SDK already does the right thing for you: it retries request_rate
(honouring Retry-After) and raises recently_checked immediately.
client.last_rate_limit holds a snapshot of the most recent response's rate-limit
headers. The API emits only Retry-After today, so the IETF draft-11
RateLimit-* fields are None; they are read opportunistically so the SDK
already reports them the day the API adopts them.
client=DoDomain(
secret_key="dd_sk_…",
base_url="https://app.dodomain.io", # the only origin that serves /api/v1timeout=30.0,
max_retries=2,
http_client=None, # bring your own httpx.Client
)- Retries apply to
GETandDELETEonly, on429/5xx/transport errors, with exponential backoff and full jitter (base 0.5s, cap 8s). ARetry-Afterabove 60 seconds is raised rather than slept through. POSTis never retried. The API has no server-side idempotency, so a replayedPOST /v1/sessionswould mint a second session and burn a second unit of plan quota.sessions.verifyandconnections.disconnectare idempotent server-side, so retrying those yourself is safe.idempotency_keyis accepted on every write method and forwarded asIdempotency-Key. The API does not currently honour it; the plumbing is there so the eventual server-side landing is a non-event.client.new_idempotency_key()mints one.- Both clients are context managers (
with/async with) and expose.close(). An injectedhttp_clientis never closed by the SDK.
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"
ruff check .&& ruff format --check .&& ty check
pytest tests --ignore=tests/e2e --cov=dodomain --cov-fail-under=90The unit suite mocks the wire with respx and asserts on the actual request —
URL, method, headers, JSON body — as well as the parsed return value, so it tests
the protocol rather than the mock.
tests/fixtures/webhook_vectors.json is generated by the TypeScriptsignWebhook in the doDomain monorepo (packages/core/src/webhook.ts), executed
directly with node --experimental-strip-types. It is the guard against the
Python and TypeScript verifiers drifting apart.
tests/e2e/ runs against production and skips loudly without
DODOMAIN_SECRET_KEY:
DODOMAIN_SECRET_KEY="dd_sk_…" pytest tests/e2e -vThis SDK is hand-written, not generated. doDomain does publish an OpenAPI 3.1 document — https://dodomain.io/docs/openapi.json, generated from the same zod schemas the route handlers validate with — and it is the right thing to check this SDK's shapes against, but the hand-written surface is deliberate: the naming, the sync/async twins, the local validation and the docstrings that explain why an endpoint behaves the way it does are the product here, and none of them survive a generator.
MIT © 2026 Devino Solutions Inc.