> ## Documentation Index
> Fetch the complete documentation index at: https://docs.drin.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Python SDK

> The official drin Python client — typed errors, cursor pagination, retries, and local webhook signature verification, with zero third-party dependencies.

The official `drin` Python client is a thin wrapper over the REST API — with built-in retries, cursor pagination, typed errors, and local webhook signature verification, and zero third-party dependencies (standard library only).

The SDK is published to PyPI as `drin`. It mirrors the wire contract one-to-one, so anything you can do over [REST](/api-reference/overview) you can do here with the same request and response shapes.

<Info>
  **Requirements.** Python 3.8+. The SDK has zero third-party dependencies — the default HTTP backend uses the standard-library `urllib`. You can inject your own backend (`requests` / `httpx`) via the `http_handler` option.
</Info>

## Install

```bash theme={null}
pip install drin
```

## Create a client

Construct one client and reuse it. The only required argument is `api_key`; pass `sender` only when you authenticate with a tenant-wide key (see [Authentication](/authentication)).

```python theme={null}
import os
from drin import DrinClient

drin = DrinClient(
    api_key=os.environ["DRIN_API_KEY"],  # required
    sender="my-project",                 # only for tenant-wide keys
    # base_url="https://api.drin.run",   # default
    # timeout=30.0,                      # per-request timeout in seconds
    # max_retries=2,                     # transient-failure retries
)
```

The client exposes one resource per area of the API. Each has the `create` / `get` / `list` / `delete` methods as appropriate:

* `emails` · `domains` · `inboxes` · `threads` · `inbound` · `webhooks`
* `suppressions` · `contacts` · `templates` · `api_keys` · `metrics` · `forms`

Request bodies are plain dicts whose keys match the REST API wire format (camelCase), e.g. `{"from": {...}, "to": [...], "templateId": "..."}`. Responses are returned as parsed JSON (`dict` / `list`).

## Send

`emails.send()` resolves to a dict carrying the message `id` you can use to retrieve status or reply later.

```python theme={null}
res = drin.emails.send({
    "from": {"email": "hello@acme.com", "name": "Acme"},
    "to": [{"email": "customer@example.com"}],
    "subject": "Welcome aboard",
    "html": "<h1>You're in</h1>",
    "text": "You're in",
})
print(res["id"], res["status"])
```

To pick a verified sending domain in code rather than hard-coding it, use the `domains.list_verified()` convenience — it auto-pages and returns only the domains a `from` address may use:

```python theme={null}
domains = drin.domains.list_verified()
domain = domains[0]

drin.emails.send({
    "from": {"email": f"hello@{domain['domain']}"},
    "to": [{"email": "customer@example.com"}],
    "subject": "Hi",
    "html": "<p>…</p>",
})
```

<Note>
  **Idempotent sends.** Pass `idempotency_key` to make a retry safe — `drin.emails.send(body, idempotency_key="order-42")`. It is honored for 24h, per sender. See [Idempotency & retries](/idempotency).
</Note>

## Paginate

Every list endpoint returns `{"data": [...], "nextCursor": ...}`. The easiest way to walk all of it is `.paginate()`, a lazy iterator that fetches each page and stops when `nextCursor` is empty — re-using whatever filter you passed:

```python theme={null}
# Walk every bounced message across all pages — no cursor bookkeeping.
for message in drin.emails.paginate({"status": "bounced"}):
    print(message["to"], message["subject"])
```

When you want to control paging yourself — for cursor-based UIs, say — call `.list()` and thread the cursor:

```python theme={null}
# Or take one page at a time and thread the cursor yourself.
page = drin.emails.list({"limit": 50})
print(len(page["data"]), "messages")
if page.get("nextCursor"):
    nxt = drin.emails.list({"cursor": page["nextCursor"]})
```

`paginate()` is available on `emails`, `domains`, `threads`, `contacts`, `suppressions`, `templates`, `webhooks`, `api_keys`, and `forms`.

## Receive & reply

Inbound mail lands on an [inbox](/receiving) and is joined into a thread alongside your outbound messages. Reply in one call — Drin handles the addressing and the threading headers, so the recipient's mail client threads the conversation correctly:

```python theme={null}
# One-call threaded reply. From, To, Subject ("Re: …"), and the
# In-Reply-To / References headers are all set for you.
drin.emails.reply(inbound_message_id, {
    "html": "<p>Thanks for reaching out — we're on it.</p>",
})
```

## Verify a webhook

Use `verify_webhook()` (also exposed as `drin.webhooks.verify()`) — a pure, local check with no network call. Pass the **raw** request body (the exact bytes you received, before any JSON parsing) and the `Drin-Signature` header. It returns the verified result on success and raises `DrinWebhookVerificationError` on any mismatch.

```python theme={null}
from drin import verify_webhook, DrinWebhookVerificationError

# e.g. in a Flask/Django/FastAPI handler:
raw_body = request.get_data(as_text=True)     # the exact bytes received
signature = request.headers["Drin-Signature"]

try:
    result = verify_webhook(
        raw_body,
        signature,
        signing_secret,        # the endpoint's signing secret
        tolerance_seconds=300, # optional freshness window; 0 disables it
    )
    event = result.payload     # trusted only AFTER verification
    print(event["type"], event["data"])
except DrinWebhookVerificationError:
    abort(400)
```

<Warning>
  **Use the raw body.** Most frameworks parse JSON before your handler runs, which re-serializes the bytes and breaks the signature. Capture the raw body (e.g. `request.get_data(as_text=True)` in Flask) and verify *that*, not the parsed object.
</Warning>

## Typed errors

Every failure is a subclass of `DrinError`, so you can branch with `except` or on `err.type`. The base carries `type`, `status`, `code`, `param`, `request_id`, and the raw `body`.

```python theme={null}
import time
from drin import (
    DrinError,
    DrinValidationError,
    DrinRateLimitError,
    DrinSuppressedError,
)

try:
    drin.emails.send({ ... })
except DrinSuppressedError:
    # every recipient is suppressed — nothing was sent
    ...
except DrinRateLimitError as err:
    time.sleep((err.retry_after or 1))
except DrinValidationError as err:
    print("Bad field:", err.param, err)
except DrinError as err:
    print(err.type, err.status, err.request_id)
```

The full set of typed errors: `DrinValidationError`, `DrinAuthenticationError`, `DrinPermissionError`, `DrinNotFoundError`, `DrinConflictError`, `DrinSuppressedError`, `DrinRateLimitError`, `DrinInternalError`, and `DrinConnectionError` (raised on a network failure, timeout, or abort before any HTTP response was received).

The SDK auto-retries `429` and `5xx` with exponential backoff and full jitter (default 2 retries). A `POST` is retried only when you supplied an `idempotency_key`, so a send is never duplicated. See [Errors](/errors) for the full type table.

## Next steps

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/api-reference/overview">
    Every endpoint, parameter, and response shape the SDK wraps.
  </Card>

  <Card title="Other languages" icon="code" href="/sdk/rest">
    No Python? Call the same REST API from any stack.
  </Card>

  <Card title="Authentication" icon="key" href="/authentication">
    Project-scoped vs tenant-wide keys, and the `sender` option.
  </Card>

  <Card title="Webhooks" icon="bell" href="/webhooks">
    Subscribe to delivery, bounce, complaint, open, and click events.
  </Card>
</CardGroup>
