# dodomain-sdk (Python) (https://dodomain.io/docs/python-sdk)

The Python server SDK: sync and async twins over the same /api/v1 surface, typed models, an exception hierarchy, and the webhook verifier.

```bash
pip install dodomain-sdk
```

The Python SDK covers the same server surface as [`@dodomain/node`](https://dodomain.io/docs/node-sdk): sessions, connections, apps, domains, webhook endpoints, key rotation, and webhook verification. It requires Python 3.10+ and depends only on [httpx](https://www.python-httpx.org/).

```python
import os
from dodomain import DoDomain, DnsRecord

client = DoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"])

session = client.sessions.create(
    domain="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 [#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.

```python
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` | none                      | Bring your own `httpx.Client` / `httpx.AsyncClient` (custom transport, proxies, test doubles).                         |

## Naming [#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 [#the-resource-tree]

```python
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.

<Callout type="warn" title="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.
</Callout>

**`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 [#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 [#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 [#verifying-webhooks]

```python
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](https://dodomain.io/docs/webhooks) for the event catalogue and the payload each one carries.

## Source and issues [#source-and-issues]

The Python SDK is developed in the open at [DevinoSolutions/dodomain-python](https://github.com/DevinoSolutions/dodomain-python), MIT-licensed, and published to PyPI as [`dodomain-sdk`](https://pypi.org/project/dodomain-sdk/).
