DoDomain

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-sdk

The 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 user

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

ArgumentDefaultNotes
secret_keyrequiredA dd_sk_... app key, or an OAuth 2.1 access token (team-scoped, which makes app_id required on sessions.create).
base_urlhttps://app.dodomain.ioThe one origin that actually serves /api/v1/*.
timeout30.0Per-request timeout in seconds.
max_retries2Replays of an idempotent request — see below.
http_clientBring 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:

ExceptionAPI code
AuthenticationErrorunauthorized
PermissionError_forbidden
NotFoundErrornot_found
ExpiredErrorexpired
InvalidRequestErrorinvalid_request
QuotaExceededErrorquota_exceeded
NotConfiguredErrornot_configured
ConflictErrorconflict
RateLimitErrorrate_limited
InternalServerErrorinternal

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.

On this page