Metadata-Version: 2.5
Name: senddy
Version: 0.3.6
Summary: Official Python SDK for Senddy.io
Project-URL: Homepage, https://github.com/senddy-io/senddy-python
Project-URL: Repository, https://github.com/senddy-io/senddy-python
License-Expression: MIT
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: datamodel-code-generator~=0.57.0; extra == 'dev'
Requires-Dist: mypy~=2.3.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.22; extra == 'dev'
Requires-Dist: ruff~=0.16.0; extra == 'dev'
Description-Content-Type: text/markdown

# Senddy Python SDK

Official Python SDK for the [Senddy.io](https://senddy.io) email API.

**[Full API reference →](./REFERENCE.md)** — every method, parameter, and return type.

## Installation

Requires **Python 3.9+**.

```bash
pip install senddy
```

## Quick Start

```python
from senddy import Senddy, SendEmailParams

client = Senddy("senddy_live_api_key")

result = client.emails.send(SendEmailParams(
    from_="sender@senddy.io",
    to="recipient@example.com",
    subject="Hello",
    html="<p>Welcome!</p>",
))

print(result.id)  # email_abc123
```

## Async Support

```python
from senddy import AsyncSenddy, SendEmailParams

async with AsyncSenddy("senddy_live_api_key") as client:
    result = await client.emails.send(SendEmailParams(
        from_="sender@senddy.io",
        to="recipient@example.com",
        subject="Hello",
        html="<p>Welcome!</p>",
    ))
```

## Configuration

All options are optional — sensible defaults are used if you don't override them.

```python
client = Senddy(
    "senddy_live_api_key",
    timeout=30.0,                   # seconds
    retries=2,
    headers={"X-Custom": "value"},  # extra headers on every request
)
```

If no API key is passed to the constructor, the SDK reads from the `SENDDY_API_KEY` environment variable.

Both clients support context managers for proper resource cleanup:

```python
with Senddy("senddy_live_api_key") as client:
    # client.close() called automatically on exit
    ...
```

## Emails

### Send

```python
from senddy import SendEmailParams, Attachment, RequestOptions

result = client.emails.send(
    SendEmailParams(
        from_="sender@senddy.io",
        to=["alice@example.com", "bob@example.com"],
        subject="Hello",
        html="<p>Hi there</p>",
        text="Hi there",                # optional plain-text fallback
        cc="cc@example.com",            # optional
        bcc="bcc@example.com",          # optional
        reply_to="reply@example.com",   # optional
        in_reply_to="<msg-1@senddy.io>",                 # optional: thread a reply (In-Reply-To)
        references=["<msg-0@senddy.io>"],                # optional: thread history (References)
        headers={"X-Campaign-Id": "welcome"},           # optional custom headers
        tags={"campaign": "welcome"},   # optional metadata
        attachments=[Attachment(        # optional
            filename="report.pdf",
            content=base64_string,
            content_type="application/pdf",
        )],
    ),
    # optional — send() already auto-generates a key per call; set this only to
    # dedupe the same send across separate calls (see Retries & idempotency).
    options=RequestOptions(idempotency_key="order-1234-receipt"),
)
```

> **Note:** The `from_` parameter uses a trailing underscore because `from` is a Python reserved word. The SDK maps it to `from` in the API request automatically.

> **Multiple recipients:** a send with several `to`/`cc` recipients is **one shared email** —
> everyone sees the full `To:`/`Cc:` list and reply-all threads for all of them (one shared
> `Message-ID`). `bcc` recipients receive it but are never shown to anyone. To keep recipients
> hidden from each other, send **separate single-recipient requests**. Combined `to`+`cc`+`bcc` ≤
> 50; each deliverable recipient is billed as one credit.

### Inline images (CID)

To embed an image in the body (rather than link to a hosted URL), use `inline_image()`. It
returns the `attachment` to send and the `src` to drop into your HTML — the `content_id` and
the `cid:` reference are derived together, so they can never drift:

```python
from senddy import SendEmailParams, inline_image

logo = inline_image(
    filename="logo.png",
    content=base64_string,  # base64-encoded image bytes
    content_type="image/png",
)

client.emails.send(
    SendEmailParams(
        from_="you@yourdomain.com",
        to="alice@example.com",
        subject="Welcome",
        html=f'<p>Welcome!</p><img src="{logo.src}">',  # logo.src == "cid:logo.png"
        attachments=[logo.attachment],
    )
)
```

The `content_id` defaults to the sanitized filename; pass an explicit `content_id` when
embedding two images that share a filename. Without the helper, set `disposition="inline"` and a
`content_id` on the `Attachment` yourself and reference the **same** token via
`<img src="cid:TOKEN">` (the API matches the two exactly — RFC 2392 — so they must be identical).

#### Embedding vs. hosting images

Inline (CID) embedding is not always the right choice — weigh it against a hosted `<img src="https://…">`:

- **Embedding** guarantees the image is present (no hotlink that can break or be blocked) and
  works offline, but it **increases message size** (base64 is ~33% larger than the raw bytes,
  and the bytes count against the 10MB-per-attachment / 40MB-total limits), can **raise spam
  scores** for large or image-heavy mail, and is still subject to the recipient's "show images"
  gate in some clients.
- **Hosting** keeps the message small and lets you swap/track the asset after sending, but the
  image won't render until the client fetches it (privacy/"show images" prompts, broken if the
  host is unreachable).

Rule of thumb: embed small, essential, static images (a logo, a signature); host large or
swappable assets. Either way, always provide meaningful `alt` text.

### Get

```python
email = client.emails.get("email_abc123")
print(email.status)      # 'delivered'
print(email.recipients)  # list of EmailRecipient
print(email.events)      # list of EmailEvent
```

### List

```python
from senddy import ListEmailsParams

result = client.emails.list(ListEmailsParams(
    limit=25,
    offset=0,
    status="delivered",
    since="2026-01-01T00:00:00Z",
))

for email in result.data:
    print(email.subject)
print(result.pagination.total)
```

### Get rendered content

```python
content = client.emails.get_content("email_abc123")
print(content.html)  # str or None
print(content.text)  # str or None
```

### Download EML

```python
download = client.emails.download("email_abc123")
# download.content is bytes containing the raw EML
# download.content_type is 'message/rfc822'
```

### Resend

```python
resent = client.emails.resend("email_abc123")
print(resent.id, resent.original_id)
```

## Suppressions

Each entry applies to one sending domain, the domain of the From address, or, with no
domain, to every domain of the organization. A bounce or complaint suppresses the address
for the domain that sent it. Domains are matched exactly: `mail.acme.com` and `acme.com` are
separate.

### List

With `domain`, the entries that would refuse a send from it: its own and the
organization-wide ones. Each entry has `domain`, `None` when organization-wide.

```python
from senddy import ListSuppressionsParams

result = client.suppressions.list(ListSuppressionsParams(
    limit=50,
    reason="bounce",
    domain="acme.example.com",
))
```

### Create

For one of your domains, or organization-wide without `domain`:

```python
from senddy import CreateSuppressionParams

entry = client.suppressions.create(CreateSuppressionParams(
    email_address="block@example.com",
    domain="acme.example.com",
))
```

### Delete

Removes one entry: the domain's, or the organization-wide one without `domain`. The
address's entries for other domains stay.

```python
result = client.suppressions.delete("block@example.com", domain="acme.example.com")
print(result.removed, result.domain)  # True acme.example.com
```

### Export

```python
from senddy import ExportSuppressionsParams

export = client.suppressions.export(ExportSuppressionsParams(format="csv"))
# export.content is a raw string (CSV by default, or a JSON string for format="json")
```

## Domains

```python
from senddy import CreateDomainParams

# Add a sending domain
domain = client.domains.create(CreateDomainParams(domain="mail.example.com"))

# Trigger DNS verification. `verified` reflects ownership (the TXT challenge);
# spf_verified / dmarc_verified / dmarc_policy are advisory DNS observations
# reported regardless of ownership.
result = client.domains.verify(domain.id)
result.verified  # bool — ownership proven
result.dkim_verified  # bool | None — does mail from this domain leave signed, by its
#                       own key or an ancestor's. None is no verdict, never a failure.
result.authenticated_via  # "own" | "inherited" | "unsigned" | "unknown"
result.signing_domain  # str | None — whose key signs it
result.is_root  # bool — for "unsigned", whether the fix is here or on the domain above
result.spf_verified  # bool | None — always None for a subdomain: a receiver checks SPF
#                      at the envelope domain (the root's return path or the platform
#                      bounce domain), never this one's
result.dmarc_verified  # bool
result.dmarc_policy  # str | None — e.g. "reject"

# Branch on authenticated_via, not on the boolean: a None dkim_verified covers both
# "the check failed" and "an ancestor lost its key", and `is not False` would call the
# second one healthy.

# Add a subdomain under a verified root (inherits the root's verification)
from senddy import AddSubdomainParams

client.domains.add_subdomain(domain.id, AddSubdomainParams(subdomain="mail"))

# List, get, delete
client.domains.list()
client.domains.get(domain.id)
client.domains.delete(domain.id)

# Rotate DKIM keys
client.domains.regenerate_dkim(domain.id)

# Bulk DNS health check across all domains
client.domains.check_dns_health()
```

## Inbound Routes

```python
from senddy import CreateInboundRouteParams, UpdateInboundRouteParams

# Create a catchall route that forwards to your webhook
route = client.inbound_routes.create(CreateInboundRouteParams(
    match_type="catchall",
    webhook_url="https://your-app.example.com/inbound",
))

# List, get, update, delete
client.inbound_routes.list()
client.inbound_routes.get(route.id)
client.inbound_routes.update(route.id, UpdateInboundRouteParams(enabled=False))
client.inbound_routes.delete(route.id)

# Verify the MX record points at Senddy
client.inbound_routes.verify_mx(route.id)
```

See the [inbound docs](https://senddy.io/docs/inbound) for match patterns, webhook signing, and full configuration.

## Inbound Emails

```python
from senddy import ListInboundEmailsParams

result = client.inbound_emails.list(ListInboundEmailsParams(route_id=42, limit=50))

email = client.inbound_emails.get("inbound_xyz")
print(email.text_body, email.attachments)
```

## Webhook Endpoints

```python
from senddy import CreateWebhookEndpointParams, UpdateWebhookEndpointParams

# Register an endpoint to receive event callbacks. event_type is one of:
# sent, delivered, bounced, complained, opened, clicked, deferred, failed, or "*" for
# all events. "deferred" is a progress signal, not an outcome — see REFERENCE.md.
# "failed" means Senddy gave up before handing the message over (credit refunded).
result = client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy",
    domain="mail.example.com",
    event_type="delivered",
))
# Save result.secret — you'll need it to verify the signature on incoming webhooks.

# Test, list, update, delete
client.webhook_endpoints.test(result.id)
client.webhook_endpoints.list()
client.webhook_endpoints.update(result.id, UpdateWebhookEndpointParams(url="https://new-url.example.com"))
client.webhook_endpoints.delete(result.id)

# Inspect delivery history
deliveries = client.webhook_endpoints.deliveries(result.id)
```

An endpoint's `domain` can also cover many domains at once:

```python
# Every domain under stage.example.com (a.stage.example.com, b.a.stage.example.com),
# but not stage.example.com itself.
client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy-stage",
    domain="*.stage.example.com",
    event_type="*",
))

# The organization-wide fallback: every event no other endpoint takes.
client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy-fallback",
    domain="*",
    event_type="*",
))
```

An event goes to the endpoint for its own domain if one subscribes to the event type,
otherwise to the nearest `*.` endpoint above it that does, otherwise to a `*` endpoint that
does. The part after `*.` must
be one of your organization's domains or under one, or the API answers
`400 validation_error`.

### Verifying webhook signatures

`verify_webhook_signature` is a stateless helper (no API key needed) — call it from
your webhook handler with the raw request body and the `X-Webhook-*` headers. It
returns the parsed event on success and raises `WebhookVerificationError` on a bad
signature or a stale (replayed) timestamp.

```python
from senddy import verify_webhook_signature, WebhookVerificationError

# In your HTTP handler — pass the RAW body (str or bytes), not a re-serialized object.
try:
    event = verify_webhook_signature(
        payload=raw_body,
        signature=request.headers["X-Webhook-Signature"],
        timestamp=request.headers["X-Webhook-Timestamp"],
        secret=os.environ["SENDDY_WEBHOOK_SECRET"],
        # tolerance_seconds=300,  # optional replay window (default 300; 0 disables)
    )
    # event is the parsed payload (typed as `Any`)
except WebhookVerificationError:
    ...  # reject with 400 — do not process
```

### Typed inbound-email webhooks

For the **inbound-email** webhook (the body Senddy POSTs to your inbound route's
`webhook_url`), use the typed helper to get an `InboundEmailEvent` TypedDict:

```python
from senddy import (
    verify_inbound_email_webhook,
    WebhookVerificationError,
    InboundEmailEvent,
)

event: InboundEmailEvent = verify_inbound_email_webhook(
    payload=raw_body,
    signature=request.headers["X-Webhook-Signature"],
    timestamp=request.headers["X-Webhook-Timestamp"],
    secret=os.environ["SENDDY_INBOUND_SECRET"],  # per-route signing secret
)

# All fields autocompleted + type-checked under mypy / pyright:
event["schema_version"]              # Literal["2"]
event["from"]                        # str
event["spam_score"]                  # Optional[float]
event["threading"]["message_id"]     # Optional[str]
event["attachments"]                 # list[InboundEmailEventAttachment]
```

`InboundEmailEvent` is a `TypedDict` using the functional form because the
contract has a `from` key (a Python keyword). There is no runtime shape check;
the cast trusts the contract. The signature/timestamp/JSON-validity checks are
identical to `verify_webhook_signature`.

### Fetching inbound attachment bytes

Inbound webhook events carry attachment **metadata** only — bytes are fetched
on demand via `attachments[].download_url`, a self-contained capability URL
(Senddy-signed JWT in the query string). **No SDK helper is needed** — follow
the URL with any HTTP client:

```python
import httpx

attachment = event["attachments"][0]
bytes_data = httpx.get(attachment["download_url"]).content

# Or stream it:
# with httpx.stream("GET", attachment["download_url"]) as r:
#     for chunk in r.iter_bytes():
#         ...
```

No `Authorization` header is required. The URL is valid until
`attachment["expires_at"]` (24h on the free tier, 72h on paid). After that the
URL + bytes are both gone — there is no refresh path. If you process
attachments from a durable queue, persist `download_url` along with the event.

#### Skipping the fetch with `content_hash`

Every attachment carries `content_hash`, the hex SHA-256 of the bytes
`download_url` would return. Check it before you download: a sender's
signature logo is byte-identical on every message they ever send, so most
attachments in a busy inbox are files you already hold.

```python
attachment = event["attachments"][0]
digest = attachment["content_hash"]
if digest and store.has(digest):
    return  # already have these exact bytes — no fetch, no storage write

bytes_data = httpx.get(attachment["download_url"]).content
store.put(digest, bytes_data)
```

`content_hash` covers the bytes only — the same PNG attached under two
filenames hashes identically. It is `None` only for messages received before
the field shipped.

Two more fields help you handle the part correctly rather than decide whether
to fetch it: `charset` (decode text attachments with it — bytes are served
exactly as the sender encoded them) and `content_id` / `content_disposition`
(the part's declared `Content-ID` / `Content-Disposition`). Neither disposition
nor the presence of a `Content-ID` reliably tells you whether an attachment
matters — pasted screenshots arrive `inline`, and business PDFs sometimes do
too.

## Recipient Lists

Recipient allow-lists scope which addresses an API key may send to.

```python
from senddy import CreateRecipientListParams, UpdateRecipientListParams

# Create a list, then list / update / delete
rl = client.recipient_lists.create(
    CreateRecipientListParams(name="Internal", patterns=["*@example.com"])
)

result = client.recipient_lists.list()  # each item includes active_key_count
client.recipient_lists.update(rl.id, UpdateRecipientListParams(patterns=["*@example.com"]))
client.recipient_lists.delete(rl.id)
```

## Billing

```python
from senddy import (
    BillingUsageParams,
    ChangeTierParams,
    CreateCheckoutParams,
    ListBillingHistoryParams,
    UpdateAutoRefillParams,
)

# Reads
balance = client.billing.get_balance()
print(balance.credit_balance, balance.billing_tier)

client.billing.get_history(ListBillingHistoryParams(limit=50, type="deduction"))  # paginated
client.billing.get_payments()  # paginated
client.billing.get_auto_refill()
client.billing.get_tier()  # includes projected invoice on Pro
client.billing.get_usage(BillingUsageParams(days=30))

# Writes
session = client.billing.create_checkout(CreateCheckoutParams(credits=10000))
print(session.checkout_url)
client.billing.update_auto_refill(
    UpdateAutoRefillParams(enabled=True, threshold=1000, amount_cents=2000)
)
client.billing.change_tier(ChangeTierParams(tier="pro"))
```

## Error Handling

```python
from senddy import (
    APIError,
    ValidationError,
    RateLimitError,
    AuthenticationError,
)

try:
    client.emails.send(params)
except ValidationError as err:
    print(f"Validation: {err.details}")
except RateLimitError as err:
    print(f"Rate limited. Retry after {err.retry_after}s")
except AuthenticationError as err:
    print(f"Auth error: {err}")
except APIError as err:
    print(f"API error {err.status_code}: {err}")
```

### Error Types

| Class                     | Status | Description                                                                      |
| ------------------------- | ------ | -------------------------------------------------------------------------------- |
| `ValidationError`         | 400    | Invalid request, includes `.details` list                                        |
| `AuthenticationError`     | 401    | Missing or invalid API key                                                       |
| `ForbiddenError`          | 403    | Insufficient permissions                                                         |
| `NotFoundError`           | 404    | Resource not found                                                               |
| `RateLimitError`          | 429    | Rate limit exceeded, includes `.retry_after`                                     |
| `ServiceUnavailableError` | 503    | Senddy couldn't accept it right now, includes `.retry_after`; an `InternalError` |
| `InternalError`           | 5xx    | Server error                                                                     |

All error classes inherit from `APIError`, which inherits from `Exception`.

Every error carries `.status_code`, `.code`, `.request_id`, `.details` and `.indeterminate`.

`.details` is whatever the response put under that key, or `None`. `ValidationError` narrows it to its field-error list; elsewhere it is untyped, because its shape depends on the error (see the `422 idempotency_mismatch` example below).

### Did it send? `.indeterminate`

**`.indeterminate` is True when the request may have taken effect anyway.** The send is _not_ known to have failed, and recording it as one is a mistake — as is sending it again with a fresh key, which is how a recipient gets two copies.

It is True when a state-changing request went out and the server never gave a verdict on it: the connection timed out or broke, the server answered 5xx, or it was still processing your earlier attempt when the SDK stopped waiting. A 4xx _is_ a verdict — the server considered the request and refused it — so validation errors, permission errors, `domain_exists`, `422 idempotency_mismatch` and `429` are all definite failures, unless an earlier attempt of the same call went unanswered. So is `503 service_unavailable` (a `ServiceUnavailableError`): Senddy answers it when it can't store a send, such as during a storage outage, before anything is stored or sent. Any other 5xx, including a `503` with another code, stays indeterminate. The rate limiter can refuse a retry while the first attempt is still sending, so a later refusal never settles an earlier doubt. A failed read is always definite, since a read changes nothing.

It is also True for one error the server states outright: `409 idempotency_result_unknown`, which says an earlier request holding that key stopped before recording what it did, so the message may already be out with nothing naming it. That one arrives as an ordinary, prompt answer — only its code makes the outcome unknown.

Canceling the call yourself (`task.cancel()`, `asyncio.wait_for`, Ctrl-C) raises that exception, not an `APIError`, so `indeterminate` can't describe it. If the request may already have gone out, treat it as indeterminate too.

```python
from senddy import APIError, RequestOptions

try:
    client.emails.send(params, options=RequestOptions(idempotency_key="reply-8842"))
except APIError as err:
    if err.indeterminate:
        # Unknown, not failed. Retry later with the SAME key — never a fresh one —
        # unless err.code is idempotency_result_unknown (see below).
        mark_unconfirmed("reply-8842")
    else:
        mark_failed("reply-8842")  # the server refused it; nothing was sent
```

Retrying an indeterminate send needs a key you chose, since the SDK's generated one is gone with the call. **For any send where a duplicate would matter, supply your own key.**

The one indeterminate error a retry with the same key cannot deliver is `409 idempotency_result_unknown`: that key will not send again for the rest of its 24 hours, and at most a retry returns the original's response, if that request turns out to be still finishing. Check whether the message arrived before sending it again under a new key.

## Pagination

List methods return a result with `.data` and `.pagination` (`limit`, `offset`, `total`, `has_more`). Page through results by advancing `offset`:

```python
from senddy import ListEmailsParams

offset, limit = 0, 100
while True:
    result = client.emails.list(ListEmailsParams(limit=limit, offset=offset))
    for email in result.data:
        ...  # process email
    if not result.pagination.has_more:
        break
    offset += len(result.data)
```

Or let the SDK do the paging for you with `list_all`, a generator that fetches one page at a time (available on `emails`, `suppressions`, `domains`, and `inbound_emails`). `limit` sets the page size; filters are preserved across pages. The async client returns an async iterator:

```python
# Sync
for email in client.emails.list_all(ListEmailsParams(status="delivered", limit=100)):
    ...  # process every delivered email, fetched page by page

# Async
async for email in aclient.emails.list_all(ListEmailsParams(status="delivered")):
    ...
```

## Retries & idempotency

The SDK retries transient failures (HTTP 429, 5xx, network errors, and request timeouts) with exponential backoff and jitter. On a `429` or a `503` it waits at least as long as the `Retry-After` header asks, up to 60 seconds per pause; it ignores the header on other answers. Non-retryable errors (4xx other than 429, and other than the in-progress conflict described below) are raised immediately. Pass `retries=0` to the client to disable retries. The timeout is set by `timeout` on the client (seconds); override it per request with `RequestOptions(timeout=...)`.

To avoid sending a request twice, **only safe requests are retried automatically**:

- **Reads** (`GET`/`HEAD`/`OPTIONS`) are always retried.
- **Writes** (`POST`/`PUT`/`PATCH`/`DELETE`) are retried **only** when they carry an `Idempotency-Key`; otherwise they're attempted exactly once.
- **`emails.send()` is retry-safe by default** — it auto-generates one `Idempotency-Key` per call and reuses it across that call's retries, so a 429/5xx never produces a duplicate send.

### Sends that outlast the timeout

A send the API is still working on when the client's timeout fires is not a failed send. The retry that follows it meets `409 idempotency_conflict` — "the first attempt with this key is still running" — and the SDK waits that out and asks again with the same key until the server hands back the response it stored for that send.

That wait is capped at **150 seconds from the first attempt** — generous headroom over the slowest send seen, though not a guarantee, since the API does not yet bound how long a send can take — and it is separate from `retries`, which by then is usually spent. The cap bounds the waiting rather than the call: the poll already in flight when the budget runs out still gets its own `timeout`, so the real worst case is about `150 s + timeout`, or roughly three minutes at the default. Both the sync and async clients behave this way.

A poll is itself a request, so on the lower per-second rate limits the polling can trip the limit. A `429` during a wait already under way counts as another poll rather than ending the call: the limiter refuses before it reaches the idempotency check, so it says nothing about the send. `Retry-After` is honored, and the same 150-second budget still bounds the polling.

Every other `409` (`domain_exists`, `duplicate_name`, …) and `422 idempotency_mismatch` — the same key with a different body — still raise immediately.

### Reusing your own key for a message that changed

If you supply your own key and the message cannot be rendered byte-identically on a repeat — a freshly minted sign-in link in the body, say — the repeat is refused with `422 idempotency_mismatch` rather than replayed. The error names the send that key already completed, so you can still record it and match the delivery and bounce webhooks that follow:

```python
from senddy import APIError, RequestOptions

try:
    client.emails.send(params, options=RequestOptions(idempotency_key="reply-8842"))
except APIError as err:
    if err.code == "idempotency_mismatch" and err.details:
        original = err.details.get("original")
        if original:
            # The message went out earlier under this key — record it, don't send again.
            record_already_sent(original["id"], original["rfc_message_id"])
```

`original` is absent when the server holds no record of what that key did — storing the response failed, the request stopped part-way, or the earlier request came from another API key and was refused or stopped before sending. The error doesn't say which, so treat it as unconfirmed, not unsent: the earlier request may still have gone out, and resending with a fresh key could deliver it twice. `original` is only returned to a key that may read emails (`email:read`); a send-only key gets the refusal without it.

A send the server refuses before it commits hands its key back, so retrying the same key after fixing the cause (verifying the domain, topping up credits) works rather than meeting a day of conflicts. A `409 idempotency_result_unknown` is the exception: that key is spent, because the message it guards may already be out.

Pass your own key to dedupe a send across _separate_ calls (e.g. a retry from your own task queue) — the server treats a repeat of the same key as the same request:

```python
from senddy import RequestOptions

client.emails.send(params, options=RequestOptions(idempotency_key="order-1234-receipt"))
```

A key is shared by every API key in your organization, and test-mode and live keys are kept apart: a live send never gets back a test send's response, or the reverse. So if several integrations send with their own keys, give each one a prefix (`billing:order-1234`, `shipping:order-1234`), or one integration's key will refuse or replay another's message. When a request is refused or stops before sending, only the API key that made it can reuse its key with a different body. Another API key gets `422 idempotency_mismatch` with no `original`, which looks the same as a send whose result went unrecorded, so handle it as described above rather than as proof that nothing was sent.

## API coverage

The SDK wraps the full public API surface (every method exists on both `Senddy` and `AsyncSenddy`):

- `emails` — `send`, `get`, `list`, `get_content`, `download`, `resend`
- `suppressions` — `list`, `create`, `delete`, `export`
- `domains` — `list`, `get`, `create`, `add_subdomain`, `verify`, `recheck`, `delete`, `regenerate_dkim`, `rotate_dkim`, `promote_dkim_rotation`, `cancel_dkim_rotation`, `set_return_path`, `check_dns_health`
- `inbound_routes`, `inbound_emails`, `webhook_endpoints`
- `recipient_lists` — `list`, `create`, `update`, `delete`
- `billing` — `get_balance`, `get_history`, `get_payments`, `get_auto_refill`, `get_tier`, `get_usage`, `create_checkout`, `update_auto_refill`, `change_tier`
- the stateless `verify_webhook_signature()` + `verify_inbound_email_webhook()` helpers

See the [API reference](./REFERENCE.md) for every method signature and type.

## Requirements

- Python >= 3.9
- Runtime dependency: [httpx](https://www.python-httpx.org/)

## Type Safety

The package ships with a `py.typed` marker (PEP 561) and all public types are fully annotated — works with mypy, pyright, and IDE autocompletion out of the box. Response models are generated from Senddy's OpenAPI specification, so they track the API exactly.

## License

MIT
