Skip to main content
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 you can do here with the same request and response shapes.
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.

Install

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).
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.
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:
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>",
})
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.

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:
# 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:
# 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 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:
# 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.
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)
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.

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.
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 for the full type table.

Next steps

API reference

Every endpoint, parameter, and response shape the SDK wraps.

Other languages

No Python? Call the same REST API from any stack.

Authentication

Project-scoped vs tenant-wide keys, and the sender option.

Webhooks

Subscribe to delivery, bounce, complaint, open, and click events.