Metadata-Version: 2.5
Name: sendora
Version: 0.6.0
Summary: The Python client for Sendora's email API.
Project-URL: Homepage, https://sendora.se/docs
License-Expression: MIT
License-File: LICENSE
Keywords: api,email,sdk,sendora,transactional email
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx2<3,>=2.3
Description-Content-Type: text/markdown

# sendora

The Python client for [Sendora](https://sendora.se), transactional email
delivery. Python 3.11 or newer, one dependency (httpx2), typed throughout:
every request is a `TypedDict` your editor completes and every answer a
frozen dataclass.

```sh
pip install sendora
```

Two kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and
does everything inside one server: sending, broadcasts, the log, received
mail, streams, suppressions, tokens, webhooks and the server's own limits.
`SendoraAccount` takes an account key (`ak_…`) and manages the account:
its servers, their keys and its sending domains. `AsyncSendora` and
`AsyncSendoraAccount` are the same clients for asyncio. Both keys are
secrets: keep them on the server and never ship them to a browser.

```python
from pathlib import Path

from sendora import Sendora

sendora = Sendora()  # the key from SENDORA_API_TOKEN

accepted = sendora.email.send(
    from_={"email": "no-reply@example.se", "name": "Example AB"},
    to=["anna@example.com"],
    subject="Din faktura för september",
    text="Hej Anna, fakturan finns bifogad.",
    attachments=[
        {
            "name": "faktura.pdf",
            "content": Path("faktura.pdf").read_bytes(),
            "content_type": "application/pdf",
        }
    ],
    tag="invoice",
    metadata={"invoice_id": "2026-0912"},
)
print(accepted.message_id)
```

A client without a key raises `ValueError` at construction, so build it
at startup; `Sendora("sk_…")` takes the key directly and
`SendoraAccount()` reads `SENDORA_ACCOUNT_TOKEN`. Make one client per
process and share it: it is safe across threads and reuses its
connections. `with Sendora() as sendora:` closes them at the end, as
`sendora.close()` does.

`from_` must be on a verified sending domain; it ends in an underscore
because `from` is a Python keyword. `to`, `cc` and `bcc` take plain
addresses or `{"email", "name"}` dictionaries, at most 50 in all; at least
one of `text` and `html` is required. `stream_id` names the stream the
message goes on; without it the default transactional stream. Attachments
take bytes or a base64 string. Every method takes what its route takes,
with snake_case names, and answers what the route answers; your own keys,
in `metadata`, `headers` and `substitutions`, are sent as you wrote them.

## Sending

| Call                                 | Answers                                                                |
| ------------------------------------ | ---------------------------------------------------------------------- |
| `sendora.email.send(**message)`      | `AcceptedEmail` with `message_id`, `status`, `submitted_at` and `test` |
| `sendora.email.send_batch(messages)` | `BatchResult` with `results`, one per message in order                 |

`accepted` means the message is stored and on its way to the mail server;
delivery, deferral, bounce and complaint arrive later as events on the
message and as webhooks.

`idempotency_key=` names the request so a retry cannot send twice. The
SDK makes a random UUID when you give none; give your own when a retry
may come from another process. The API remembers a key for 24 hours and
refuses it with a different body (`idempotency_key_mismatch`). An error
from a send carries the key it went under as `error.idempotency_key`.

A batch never raises for one refused message. Each result is an
`AcceptedBatchItem` with the id, or a `RefusedBatchItem` with the same
`code` a single send would raise; a retried batch answers the items sent
before with `replayed=True`.

```python
from sendora.types import RefusedBatchItem

batch = sendora.email.send_batch(messages, idempotency_key="invoices-2026-09")
for result in batch.results:
    if isinstance(result, RefusedBatchItem):
        print(result.index, result.code, result.message)
```

## Messages

| Call                                                            | Answers                                                                           |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `sendora.messages.get(message_id)`                              | The message with its recipients, its attachments described and its events         |
| `sendora.messages.search(**filters)`                            | `MessagePage` with `messages` and `next`, newest first                            |
| `sendora.messages.search_all(**filters)`                        | Every match, page by page, as an iterator                                         |
| `sendora.inbound.get(inbound_message_id)`                       | A received message: envelope, parties, headers, text, HTML, attachments described |
| `sendora.inbound.search(**filters)`                             | `InboundPage` of received mail, newest first                                      |
| `sendora.inbound.search_all(**filters)`                         | Every received match, page by page, as an iterator                                |
| `sendora.inbound.raw(inbound_message_id)`                       | The raw message as `bytes`, `message/rfc822`                                      |
| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes`                                                           |

A stream can also receive on a domain of yours: every address on it,
whatever the local part, lands on the stream. Claim it with the stream
id, add the two records the answer gives, an MX that names Sendora and a
TXT record that proves the claim, and mail is accepted once both are
seen. A stream holds one domain, and the first account to verify a name
holds it.

| Call                                                  | Answers                                                                      |
| ----------------------------------------------------- | ---------------------------------------------------------------------------- |
| `sendora.inbound_domains.create(stream_id=, domain=)` | The domain with `mx` and `txt` to add                                        |
| `sendora.inbound_domains.list()`                      | `InboundDomainList` with `domains`                                           |
| `sendora.inbound_domains.get(inbound_domain_id)`      | One domain with the state of its records                                     |
| `sendora.inbound_domains.verify(inbound_domain_id)`   | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |
| `sendora.inbound_domains.delete(inbound_domain_id)`   | `None`; mail to it is refused from then on                                   |

The filters of `messages.search` are `recipient`, `stream_id`,
`broadcast_id`, `tag`, `status` (`queued`, `delivered`, `deferred`,
`bounced`, `expired`), `from_` and `to` as an aware `datetime` or ISO
8601, `limit` (1 to 100, 50 by default) and `after`, the previous page's
`next`. Bodies are never returned; the log keeps messages for 13 months.

```python
from datetime import UTC, datetime

message = sendora.messages.get(message_id)
delivered = all(recipient.status == "delivered" for recipient in message.recipients)

for bounced in sendora.messages.search_all(
    status="bounced", from_=datetime(2026, 9, 1, tzinfo=UTC)
):
    print(bounced.message_id, bounced.subject)
```

## Streams

Every message goes on a stream of its server. A server starts with a
default transactional stream, and the account adds more: transactional
and broadcast ones, and one inbound stream, which receives mail at its
`inbound_address` instead of sending. Each sending stream has a
suppression list of its own.

| Call                                                                | Answers                                                                                                               |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `sendora.streams.create(kind=, name=)`                              | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existing_id` |
| `sendora.streams.list()`                                            | `StreamList` with `streams`, archived ones included                                                                   |
| `sendora.streams.get(stream_id)`                                    | One stream                                                                                                            |
| `sendora.streams.update(stream_id, name=, content_retention_days=)` | The updated stream; the days apply to an inbound stream                                                               |
| `sendora.streams.archive(stream_id)`                                | The stream with `archived_at`; it takes no new messages from then on                                                  |

```python
stream = sendora.streams.create(kind="transactional", name="Aviseringar")
sendora.email.send(
    from_="no-reply@example.se",
    to=["anna@example.com"],
    subject="Leveransen är på väg",
    text="Hej Anna, paketet lämnade lagret i dag.",
    stream_id=stream.stream_id,
)
```

## Broadcasts

One message to many on a broadcast stream, sent as a whole: the content
once and one entry per message, up to 50,000 and 50 MB per broadcast.
Every message is a message in the log with its own events and webhooks,
carrying the `broadcast_id`, and every recipient gets their own
unsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the
stream's suppression list are dropped and counted in `suppressed`. The
SDK sets an idempotency key, so a retry answers the same broadcast. A
message's `substitutions`, up to 20 strings, replace `{{ key }}` in the
subject, the text and the HTML, escaped in the HTML; a key the content
names that a message lacks raises `substitution_missing`.

| Call                                      | Answers                                                                   |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| `sendora.broadcasts.send(**broadcast)`    | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test` |
| `sendora.broadcasts.get(broadcast_id)`    | The broadcast with `status`, `released` and `failed`                      |
| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first                |
| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed               |

```python
accepted = sendora.broadcasts.send(
    stream_id="7c9e6679-7425-40de-944b-e07fc1f90ae7",
    from_={"email": "nyheter@example.se", "name": "Example AB"},
    subject="Nyheter i oktober, {{ first_name }}",
    text="Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}",
    messages=[
        {"to": ["anna@example.com"], "substitutions": {"first_name": "Anna"}},
        {"to": ["bo@example.com"], "substitutions": {"first_name": "Bo"}},
    ],
)
progress = sendora.broadcasts.get(accepted.broadcast_id)
```

## Suppressions

Addresses the server no longer sends to: hard bounces, spam complaints,
unsubscribes and entries added by hand. A send to one of them raises
`recipient_suppressed`.

| Call                                                    | Answers                                          |
| ------------------------------------------------------- | ------------------------------------------------ |
| `sendora.suppressions.list(stream_id=, limit=, after=)` | `SuppressionPage` with `suppressions` and `next` |
| `sendora.suppressions.list_all(stream_id=)`             | Every entry, as an iterator                      |
| `sendora.suppressions.delete(address=, stream_id=)`     | `None`; the address may be sent to again         |

Every stream has a list of its own; without `stream_id` the calls mean
the default transactional stream. A spam complaint or a recipient's own
unsubscribe cannot be lifted this way (`spam_complaint_locked`,
`unsubscribe_locked`); only Sendora support can, at the recipient's own
request. A refused send carries `stream_id` beside `suppressed`.

```python
sendora.suppressions.delete(address="anna@example.com")
```

## Tokens

The server's own keys, from its own key. Every key of a server has the
same rights; the value is shown once, when it is created; a server holds
up to two live keys, and a third is `token_limit`. The account key
manages the same keys under `account.servers.tokens`.

| Call                              | Answers                                             |
| --------------------------------- | --------------------------------------------------- |
| `sendora.tokens.create(name=)`    | `CreatedToken` with its `token` value, once         |
| `sendora.tokens.list()`           | `TokenList` with `tokens`, revoked ones included    |
| `sendora.tokens.get(token_id)`    | One token, without its value                        |
| `sendora.tokens.revoke(token_id)` | `None`; the last live key is refused (`last_token`) |

A key's value is kept out of the answer's `repr`, so a logged answer does
not print it; read it from `.token`.

## The server

The key's own server: its name and id, whether it is live or a test
server, and every limit a send on it is held to with how much of it this
minute and this month have used.

| Call                   | Answers                                                                |
| ---------------------- | ---------------------------------------------------------------------- |
| `sendora.server.get()` | `OwnServer` with `mode` and `limits`, each with `used` and `resets_at` |

```python
own = sendora.server.get()
for limit in own.limits:
    print(limit.scope, limit.period, limit.used, "of", limit.limit)
```

## Webhooks

An https URL of yours that Sendora posts events to as they happen, signed
with a secret shown once at creation. A webhook holds up to two live
secrets, so a new one can be rolled in beside the old one: every delivery
is then signed with each, the newest first, and the old secret is deleted
once your receiver has switched.

| Call                                                                   | Answers                                       |
| ---------------------------------------------------------------------- | --------------------------------------------- |
| `sendora.webhooks.create(url=, events=, stream_id=, inbound_content=)` | `CreatedWebhook` with its `secret`, once      |
| `sendora.webhooks.list()`                                              | `WebhookList` with `webhooks`                 |
| `sendora.webhooks.get(webhook_id)`                                     | One webhook                                   |
| `sendora.webhooks.delete(webhook_id)`                                  | `None`; pending deliveries are dropped        |
| `sendora.webhooks.deliveries(webhook_id, status=)`                     | `DeliveryPage` with `deliveries` and `next`   |
| `sendora.webhooks.deliveries_all(webhook_id, status=)`                 | Every delivery, as an iterator                |
| `sendora.webhooks.replay(webhook_id, delivery_id)`                     | The delivery, queued again                    |
| `sendora.webhooks.create_secret(webhook_id)`                           | A second secret with its `secret` value, once |
| `sendora.webhooks.delete_secret(webhook_id, secret_id)`                | `None`; the last live secret is refused       |

Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
`unsubscribed` about a message, with `message_id`, `stream_id`,
`recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and
`details` from the receiver, or for `unsubscribed` the `source` (`link` or
`one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,
`cap`, `used` and the period; `inbound` about a message received on an
inbound stream, with `inbound_message_id`, `envelope_recipient`, the
sizes, the `authentication` verdicts and, for a webhook with
`inbound_content="full"`, the message itself under `content`: `from_`,
`to`, `subject`, `text`, `html`, `attachments` and the rest. Every event
carries `id`, the delivery id, and `attempt`.

A failed delivery is retried with growing delays for about a day, then it
is `dead` until you replay it. Deliveries arrive at least once and not
always in order, so key your handling on `event.id`.

### Receiving

Verify the signature before you parse anything, answer 2xx quickly, and
do the work afterwards. `verify_webhook` takes the raw body exactly as it
arrived and the `Sendora-Signature` header, and answers the event as a
class of its own. The raw body is `request.body` in Django,
`request.get_data()` in Flask and `await request.body()` in FastAPI and
Starlette.

```python
import os
from collections.abc import Mapping

from sendora import (
    BouncedEvent,
    InboundEvent,
    UnknownEvent,
    WebhookVerificationError,
    verify_webhook,
)


def receive(body: bytes, headers: Mapping[str, str]) -> int:
    try:
        event = verify_webhook(
            body,
            signature=headers.get("Sendora-Signature"),
            secret=os.environ["SENDORA_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return 400
    match event:
        case BouncedEvent(details=details) if details.hard:
            print(f"{event.recipient} is undeliverable: {details.classification}")
        case InboundEvent(content=content) if content is not None:
            print(content.subject, [part.name for part in content.attachments])
        case UnknownEvent():
            print("an event newer than this release:", event.event, event.data)
        case _:
            pass
    return 204
```

Tell the events apart with `isinstance` or a class pattern, as above;
comparing `event.event` with a string does not narrow the type. A signed
event this release cannot read, one newer than it, is an `UnknownEvent`
with its payload as it came rather than an error, so your endpoint does
not fail it until it is dead; upgrade to read it as its class.

The signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,
one `v1` per live secret of the webhook, each an HMAC-SHA256 with that
secret over `<t>.<raw body>`, compared in constant time; the one your
secret produces is enough. To roll a secret, create the new one with
`webhooks.create_secret`, move your receiver to it while both sign every
delivery, then delete the old one. A signature older than five minutes is
refused (`stale_signature`; `tolerance=` changes the limit), a wrong one
raises `invalid_signature`, and a missing secret raises `ValueError`.

## The account: servers, their keys and domains

An account key (`ak_…`) manages the account and never sends. An
administrator creates one in the dashboard under Account › API keys; the
account holds up to two live ones. `SendoraAccount` takes the same
arguments as `Sendora`.

```python
from sendora import Sendora, SendoraAccount

account = SendoraAccount()  # the key from SENDORA_ACCOUNT_TOKEN

server = account.servers.create(name="Fakturering")
sendora = Sendora(server.token.token)
```

A server is created with its default transactional stream and its first
key, whose value is in the answer this once. A server holds up to two
live keys and never falls below one, so rotation is create the new key,
switch to it, revoke the old one.

| Call                                                 | Answers                                                                              |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `account.servers.create(name=, mode=)`               | `CreatedServer` with its first key as `token`, once; a taken name is `server_exists` |
| `account.servers.list()`                             | `ServerList` with `servers`, oldest first                                            |
| `account.servers.get(server_id)`                     | One server                                                                           |
| `account.servers.update(server_id, name=)`           | The renamed server                                                                   |
| `account.servers.delete(server_id)`                  | `None`; `server_in_flight` while its messages are still being delivered              |
| `account.servers.tokens.create(server_id, name=)`    | `CreatedToken` with its `token` value, once; a third live key is `token_limit`       |
| `account.servers.tokens.list(server_id)`             | `TokenList` with `tokens`, revoked ones included                                     |
| `account.servers.tokens.get(server_id, token_id)`    | One key, without its value                                                           |
| `account.servers.tokens.revoke(server_id, token_id)` | `None`; the last live key is refused (`last_token`)                                  |

Sending domains belong to the account, and every server sends from them.
A domain sends once both DNS records are seen. A lookup that fails at the
resolver (`dns_error`) changes nothing, and a verified domain whose record
goes missing keeps sending for 24 hours while the account's administrators
are told, with `failing_since` saying when that began.

| Call                                | Answers                                                                      |
| ----------------------------------- | ---------------------------------------------------------------------------- |
| `account.domains.create(domain=)`   | `SendingDomain` with `return_path` (CNAME) and `dkim` (TXT) to add           |
| `account.domains.list()`            | `DomainList` with `domains`                                                  |
| `account.domains.get(domain_id)`    | One domain with the state of its records                                     |
| `account.domains.verify(domain_id)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |
| `account.domains.delete(domain_id)` | `None`; mail from it is refused from then on                                 |

```python
domain = account.domains.create(domain="example.se")
print(domain.return_path.type, domain.return_path.host, domain.return_path.value)
print(domain.dkim.type, domain.dkim.host, domain.dkim.value)
```

A key of the wrong kind raises `wrong_token_kind`: a server key on the
account's routes, or an account key on a server's.

## Errors

Every failed call raises a `SendoraError`, whose class says what happened
and whose `code` says why:

| Class                      | When                                                       |
| -------------------------- | ---------------------------------------------------------- |
| `APIConnectionError`       | No answer: `connection_failed`                             |
| `APITimeoutError`          | A step took longer than `timeout`: `timeout`               |
| `BadRequestError`          | 400                                                        |
| `AuthenticationError`      | 401                                                        |
| `PermissionDeniedError`    | 403                                                        |
| `NotFoundError`            | 404                                                        |
| `ConflictError`            | 409                                                        |
| `UnprocessableEntityError` | 422                                                        |
| `RateLimitError`           | 429, `rate_limited` and `monthly_cap_reached` alike        |
| `InternalServerError`      | 500 or more                                                |
| `APIStatusError`           | Any other status, and the base of the classes for a status |
| `ResponseValidationError`  | An answer the SDK could not read; the call took effect     |
| `WebhookVerificationError` | `verify_webhook`: `invalid_signature` or `stale_signature` |

```python
from sendora import RateLimitError, SendoraError

try:
    sendora.email.send(**message)
except RateLimitError as error:
    print("try again in", error.retry_after, "seconds")
except SendoraError as error:
    if error.code != "recipient_suppressed":
        raise
    print([(entry.address, entry.reason) for entry in error.suppressed])
```

| Code                              | Status | Meaning                                                                                               |
| --------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
| `invalid_request`                 | 400    | A field is wrong; `issues` says which.                                                                |
| `idempotency_key_required`        | 400    | A batch was sent without a key.                                                                       |
| `unauthorized`                    | 401    | The key is missing, malformed or revoked.                                                             |
| `wrong_token_kind`                | 403    | A server key where an account key is needed, or the reverse; `message` names the kind.                |
| `payment_required`                | 402    | The account has no active subscription.                                                               |
| `tenant_paused`                   | 403    | Sendora has paused the account.                                                                       |
| `tenant_not_active`               | 403    | The account is not approved to send, or not active for an inbound stream.                             |
| `spam_complaint_locked`           | 403    | Only support can lift a spam-complaint suppression.                                                   |
| `unsubscribe_locked`              | 403    | Only support can lift a recipient's own unsubscribe.                                                  |
| `not_found`                       | 404    | No such id on this server, or no such suppressed address.                                             |
| `domain_exists`                   | 409    | The account already has the domain; `existing_id` names it.                                           |
| `domain_reserved`                 | 400    | The name is Sendora's own; no account can receive on it.                                              |
| `inbound_domain_exists`           | 409    | The stream already has a domain (`existing_id` names it), or another account holds the name verified. |
| `webhook_exists`                  | 409    | The server already has a webhook for that URL; `existing_id` names it.                                |
| `stream_exists`                   | 409    | The server already has a stream with that name; `existing_id` names it.                               |
| `inbound_stream_exists`           | 409    | The server already has a live inbound stream; `existing_id` names it.                                 |
| `default_stream`                  | 409    | The default stream cannot be archived.                                                                |
| `last_token`                      | 409    | The only live key cannot be revoked.                                                                  |
| `last_secret`                     | 409    | The only live secret of a webhook cannot be deleted.                                                  |
| `secret_limit`                    | 409    | The webhook already holds two live secrets; `max` says how many.                                      |
| `token_limit`                     | 409    | The server or the account already holds two live keys; `max` says how many.                           |
| `server_exists`                   | 409    | The account already has a server with that name.                                                      |
| `server_limit`                    | 409    | The account has as many servers as it may; `max` says how many.                                       |
| `server_reserved`                 | 409    | The server sends Sendora's own sign-in mail and cannot be renamed or removed.                         |
| `server_in_flight`                | 409    | Messages of the server are still being delivered; delete it later.                                    |
| `request_too_large`               | 413    | The body exceeds 10 MB, or 64 MB for a broadcast.                                                     |
| `from_domain_not_verified`        | 422    | The From domain is not a verified sending domain.                                                     |
| `stream_not_found`                | 422    | `stream_id` names no stream of this server.                                                           |
| `stream_archived`                 | 422    | The stream is archived and takes no new messages.                                                     |
| `stream_paused`                   | 422    | Sendora paused the stream after complaints; support resumes it.                                       |
| `stream_not_sendable`             | 422    | An inbound stream receives mail; it takes no messages and has no list.                                |
| `stream_not_broadcast`            | 422    | A broadcast goes on a broadcast stream; this one is transactional.                                    |
| `substitution_missing`            | 422    | The content names a `{{ key }}` a message does not give; `index` and `keys` say which.                |
| `broadcast_not_open`              | 409    | The broadcast is completed or cancelled; nothing is left to cancel.                                   |
| `unsubscribe_placeholder_missing` | 422    | A broadcast message lacks `{{ unsubscribe_url }}` in a part.                                          |
| `list_unsubscribe_reserved`       | 422    | Sendora writes List-Unsubscribe on broadcast streams; leave it out.                                   |
| `recipient_suppressed`            | 422    | Recipients on the suppression list; `suppressed` lists them.                                          |
| `test_address_on_live_server`     | 422    | Addresses at simulator.sendora.se are for test servers; `addresses` lists them.                       |
| `stream_kind_not_allowed`         | 422    | A test server has no inbound stream; receive on a live server.                                        |
| `mode_immutable`                  | 400    | A server's mode is fixed at creation; create a new server for the other mode.                         |
| `idempotency_key_mismatch`        | 422    | The key was used before with a different body.                                                        |
| `content_expired`                 | 410    | The received message's content window has passed; the message itself stays.                           |
| `content_unreadable`              | 409    | The received message's stored content cannot be opened; Sendora has been told.                        |
| `rate_limited`                    | 429    | Per-minute limit; `retry_after`, `scope`, `limit`. From the edge, too many requests at once.          |
| `monthly_cap_reached`             | 429    | The monthly cap is used up; `scope`, `cap`, `used`, `resets_at`.                                      |
| `sending_disabled`                | 503    | Sending is paused for everyone; `retry_after`.                                                        |
| `connection_failed`               | none   | The request did not reach the API, or its answer was lost; `__cause__` says why.                      |
| `timeout`                         | none   | A step of the request took longer than `timeout`.                                                     |
| `unexpected_response`             | any    | An answer without a JSON error body, such as from a proxy.                                            |
| `invalid_signature`               | none   | A webhook's signature is missing, malformed or wrong.                                                 |
| `stale_signature`                 | none   | A webhook's signature is older than the tolerance.                                                    |

`error.retryable` is true when waiting and calling again could succeed.
Neither the key nor a request's subject, text, HTML or attachments is ever
part of an error, its message, its `repr` or its pickled form, and an
error survives `pickle`, so it crosses a process pool as its own class. A
code newer than this release comes through as its string, its class
chosen by the status.

## New values and fields

The API may add a value to a set the SDK knows, such as a recipient
status, before your release does. The SDK hands it over as its string
rather than failing the call, so a `match` or an `if` chain over such a
field keeps a `case _:` or an `else`, and never `assert_never`. A field
newer than your release is not an attribute yet, but every answer keeps
what the API sent: `answer.to_dict()` answers it as it came, with the
API's camelCase keys, and `answer.to_json()` its text.

## Retries and timeouts

Lost connections, timeouts, `rate_limited` (the edge's own included),
`sending_disabled` and server errors are retried with jittered backoff,
twice by default, honouring `retry_after` when it fits within five
seconds of waiting in all. A longer `retry_after` is raised at once for
you to schedule. A call that creates something (`streams.create`,
`tokens.create`, `webhooks.create`, `webhooks.create_secret`,
`webhooks.replay`, `inbound_domains.create`, `servers.create`,
`servers.tokens.create`, `domains.create`) is repeated only after a
`rate_limited` or `sending_disabled` answer, never after a lost connection
or a server error, so nothing is created twice. Nothing else is retried.

`timeout` is in seconds, 30 by default, and bounds each step of a
request: connecting, a read, a write and waiting for a connection.
`max_retries=0` turns retries off. `with_options` answers a copy of the
client that shares its connections, for one call that may take longer or
a job that must not retry:

```python
from sendora import Sendora

sendora = Sendora(timeout=10, max_retries=3)
patient = sendora.with_options(timeout=120, max_retries=0)
```

## Async

`AsyncSendora` and `AsyncSendoraAccount` have the same methods as
coroutines, and their iterators are async iterators. They run under
asyncio, as FastAPI, Starlette and Django do; under another event loop,
such as trio, the first call raises `TypeError`. Wrap a call in
`asyncio.timeout()` to bound it as a whole; cancelling a task cancels its
request.

```python
import asyncio

from sendora import AsyncSendora


async def main() -> None:
    async with AsyncSendora() as sendora:
        accepted = await sendora.email.send(
            from_="no-reply@example.se",
            to=["anna@example.com"],
            subject="Välkommen",
            text="Hej Anna!",
            tag="welcome",
        )
        print(accepted.message_id)
        async for message in sendora.messages.search_all(tag="welcome"):
            print(message.message_id, message.subject)


asyncio.run(main())
```

## Your own HTTP client

Pass an `httpx2.Client` of your own, an `httpx2.AsyncClient` to the async
clients, for a proxy, a private certificate authority or your own
connection limits. The SDK takes its connections, proxy and TLS settings
and nothing else: the key, the timeout and the SDK's headers go on every
request, redirects are never followed, and the SDK never closes a client
you gave it. Certificates are verified against the operating system's
store; a container without one needs its `ca-certificates` package or
`SSL_CERT_FILE`.

```python
import httpx2

from sendora import Sendora

http_client = httpx2.Client(proxy="http://proxy.example.se:3128")
sendora = Sendora(http_client=http_client)
```

## Testing your code

For tests that talk to Sendora, create a test server: it goes through
everything a live one does, validation, the suppression list, the log and
the webhooks, but nothing it accepts is delivered. Its keys start
`sk_test_`, and `is_test_key` tells them apart, so a production process
can refuse one at startup. Addresses at `simulator.sendora.se`
(`hardbounce@`, `softbounce@`, `deferred@`, `complaint@`, with any
`+label`) produce those outcomes; any other address is marked delivered.
A server's mode is fixed when it is created, so going live means a new
server and a new key. Test mail is free and counts against its own
monthly cap, shared by the account's test servers.

```python
import os

from sendora import Sendora, SendoraAccount, is_test_key

account = SendoraAccount()
ci = account.servers.create(name="CI", mode="test")

token = os.environ["SENDORA_API_TOKEN"]
if os.environ.get("APP_ENV") == "production" and is_test_key(token):
    raise RuntimeError("SENDORA_API_TOKEN is a test key; nothing would be delivered")
sendora = Sendora(token)
```

For unit tests that must not reach the network, give the client an
`httpx2.Client` over a `MockTransport` that answers as the API would:

```python
import json

import httpx2

from sendora import Sendora


def answer(request: httpx2.Request) -> httpx2.Response:
    assert request.url.path == "/v1/email"
    assert json.loads(request.content)["to"] == ["anna@example.com"]
    return httpx2.Response(
        200,
        json={
            "messageId": "0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11",
            "status": "accepted",
            "submittedAt": "2026-09-26T10:00:00.000Z",
            "test": False,
        },
    )


mock = httpx2.Client(transport=httpx2.MockTransport(answer))
sendora = Sendora("sk_unit_test", http_client=mock)
accepted = sendora.email.send(
    from_="no-reply@example.se", to=["anna@example.com"], subject="Hej", text="Hej"
)
assert accepted.status == "accepted"
```

## Logging

The SDK logs one thing: each retry, at `DEBUG` on the `sendora` logger,
with the method, the path, the code and the wait. httpx2 logs one `INFO`
line per request with its URL on `httpx2`, and httpcore2 logs response
headers at `DEBUG` on `httpcore2`, an attachment's filename among them, so
set both to `WARNING` in production:

```python
import logging

logging.getLogger("httpx2").setLevel(logging.WARNING)
logging.getLogger("httpcore2").setLevel(logging.WARNING)
```

An error tracker that records the local variables of each frame, as
Sentry does by default, may record the contents of a request from
httpx2's and httpcore2's frames when a connection fails while it is being
written. The SDK's own frames hold neither the key nor the message. Turn
local variables off (`include_local_variables=False` in Sentry) if a
message's contents must not reach the tracker.

## Tracing

The client sends every request through the `httpx2.Client` you give it,
so its event hooks see each attempt, retries included, and can time it:

```python
import logging
import time

import httpx2

from sendora import Sendora

log = logging.getLogger("app.sendora")


def started(request: httpx2.Request) -> None:
    request.extensions["started"] = time.monotonic()


def finished(response: httpx2.Response) -> None:
    request = response.request
    took = time.monotonic() - float(request.extensions["started"])
    log.info(
        "%s %s %s %.3fs", request.method, request.url.path, response.status_code, took
    )


traced = httpx2.Client(event_hooks={"request": [started], "response": [finished]})
sendora = Sendora(http_client=traced)
```

Record the path and not the whole URL: a search's query carries the
recipient address you filtered on.

## For coding agents

The package ships `sendora/skills/sendora/SKILL.md`, a short guide in the
agent skill format with the rules above; point your agent at it or at
this README. Every public class and method carries a docstring with an
example, and the package is typed for your editor and your type checker.

## Versions

Until 1.0.0, a minor release may change what an earlier one answers, and
the changelog marks each such change. From 1.0.0 the package follows
semantic versioning.

## Docs

Guides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).
