dodomain-sdk (Python)
The Python server SDK — sync and async twins over the same /api/v1 surface, typed models, an exception hierarchy, and the webhook verifier.
pip install dodomain-sdkThe Python SDK covers the same server surface as @dodomain/node: sessions, connections, apps, domains, webhook endpoints, key rotation, and webhook verification. It requires Python 3.10+ and depends only on httpx.
import os
from dodomain import DoDomain, DnsRecord
client = DoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"])
session = client.sessions.create(
domain="app.customer.com",
records=[DnsRecord(type="CNAME", host="app", value="cname.yourproduct.com")],
return_url="https://yourproduct.com/settings/domains",
)
print(session.connect_url) # hand this to your userKeep the secret key server-side only — it must never reach a browser.
Sync and async twins
DoDomain and AsyncDoDomain expose the identical resource tree, take the identical arguments, and return the identical parsed objects. The only difference is that the async methods are awaitable (and connections.list_all is an async iterator). Both share every piece of request-building and response-interpretation logic, so the two can never disagree about what a response means.
from dodomain import AsyncDoDomain
async with AsyncDoDomain(secret_key=key) as client:
async for connection in client.connections.list_all():
print(connection.id, connection.status)Both clients are context managers and both have a close(). A transport you inject yourself is never closed by the SDK.
| Argument | Default | Notes |
|---|---|---|
secret_key | — required | A dd_sk_... app key, or an OAuth 2.1 access token (team-scoped, which makes app_id required on sessions.create). |
base_url | https://app.dodomain.io | The one origin that actually serves /api/v1/*. |
timeout | 30.0 | Per-request timeout in seconds. |
max_retries | 2 | Replays of an idempotent request — see below. |
http_client | — | Bring your own httpx.Client / httpx.AsyncClient (custom transport, proxies, test doubles). |
Naming
Everything is snake_case, on both the call and the result: client.webhook_endpoints.rotate_secret(...), session.connect_url, connection.disconnected_at, page.next_cursor. Models are plain typed objects — the JSON keys are converted for you, so you never index into a raw dict.
The resource tree
client.sessions.create(domain=..., records=[...], app_id=None, recipe=None, return_url=None)
client.sessions.get(session_id) # authed read by id — works after expiry
client.sessions.retrieve(token) # token-public read
client.sessions.detect(token) # token-public
client.sessions.verify(token) # token-public
client.connections.list(app_id=None, domain=None, limit=50, cursor=None, include_disconnected=False)
client.connections.list_all(...) # iterator; follows the cursor for you
client.connections.get(connection_id)
client.connections.reverify(connection_id)
client.connections.disconnect(connection_id)
client.domains.check(domain=...)
client.apps.list()
client.webhook_endpoints.list()
client.webhook_endpoints.get(endpoint_id) # client-side lookup — see below
client.webhook_endpoints.create(url=...)
client.webhook_endpoints.update(endpoint_id, url=...)
client.webhook_endpoints.delete(endpoint_id)
client.webhook_endpoints.rotate_secret(endpoint_id)
client.keys.rotate()Three things in that tree are worth reading twice:
sessions.get vs sessions.retrieve. get takes the session id — the one every webhook payload carries — and authenticates with your credential. It is the arm to use from your server: it keeps answering after the session's 24 hours are up, which is the only way to see how a session ended, and it returns an IntegratorSession whose records are the composed names DoDomain looks up (with no value), plus app_id, connection_id and a derived expired flag. retrieve takes the dd_sess_... token, sends no Authorization header at all (the token is the capability), and raises ExpiredError forever once the TTL passes. Handing get a token raises InvalidRequestError locally, before any request.
A garbled token answers 401, not 404
One server path serves both arms and picks between them structurally, off the dd_sess_
prefix. A malformed token is therefore routed to the authed arm — which retrieve sends no
credential to — so it comes back as AuthenticationError, not NotFoundError. Worth knowing
before you debug a typo'd token as an auth problem.
webhook_endpoints.get is a client-side lookup. The API has no GET /api/v1/webhook-endpoints/{id} route, so this method lists and filters for you. It is offered because it is the obvious thing to reach for — but it costs one list request, so in a loop, call list() once yourself.
connections.list_all follows the cursor. Pages are fetched lazily as you consume them and iteration stops when the API returns no next cursor. Use list() when you want to hold the page and its next_cursor yourself.
Errors
Failures raise a typed exception, so you can catch the case you actually handle rather than inspecting a status code:
| Exception | API code |
|---|---|
AuthenticationError | unauthorized |
PermissionError_ | forbidden |
NotFoundError | not_found |
ExpiredError | expired |
InvalidRequestError | invalid_request |
QuotaExceededError | quota_exceeded |
NotConfiguredError | not_configured |
ConflictError | conflict |
RateLimitError | rate_limited |
InternalServerError | internal |
All of them subclass DoDomainAPIError, which carries code, status_code, message, details, request_id, method, path and body — so one except DoDomainAPIError still catches an eleventh code the API adds later. Alongside them: DoDomainConfigError (bad client configuration), DoDomainConnectionError (the request never got a response), and InvalidResponseError (the body didn't match the contract). All descend from DoDomainError.
status_code is 0 when the SDK refused the call locally — arguments that cannot satisfy the API's schema fail before a request is sent rather than round-tripping for the identical 400.
Retries and rate limits
max_retries replays only GET and DELETE, on a transport error or a 429/500/502/503/504, with exponential backoff and jitter. POST is never retried: the API has no server-side idempotency today, so a replayed POST /api/v1/sessions would mint a second session and burn a second unit of your monthly quota. Retry-After is honoured up to 60 seconds; anything longer is raised to you instead of silently blocking the thread.
Every method accepts an optional idempotency_key, forwarded as an Idempotency-Key header. The API does not honour it today — it is accepted for forward-compatibility, and it does not make sessions.create idempotent.
After each call, client.last_rate_limit holds what the response said. Only retry_after is populated today; the IETF draft-11 RateLimit-* fields are read opportunistically so they light up the day the API emits them.
Verifying webhooks
from dodomain import verify_webhook
ok = verify_webhook(
secret, # the endpoint's whsec_... signing secret
raw_body, # the RAW request bytes, exactly as received
request.headers["x-dodomain-signature"],
)verify_webhook(secret, body, header, tolerance_ms=..., now_ms=None) returns a boolean. Pass the raw body — the signature covers the exact bytes sent, so a re-serialized dict will not verify. The replay window defaults to five minutes, and now_ms lets you verify an archived delivery in a test. The package also exports SIGNATURE_HEADER and DEFAULT_TOLERANCE_MS so those constants never have to be retyped.
See Webhooks for the event catalogue and the payload each one carries.
Source and issues
The Python SDK is developed in the open at DevinoSolutions/dodomain-python, MIT-licensed, and published to PyPI as dodomain-sdk.