Metadata-Version: 2.4
Name: sendara
Version: 0.4.0
Summary: Sendara — email API (transactional + marketing email, broadcasts, contacts, templates, inbound, webhooks) for Python.
Project-URL: Homepage, https://sendara.dev
Project-URL: Documentation, https://sendara.dev/docs
Author: Sendara
License: MIT
Keywords: api,broadcasts,email,marketing,sendara,transactional
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# Sendara — Python SDK

The official Python client for the [Sendara](https://sendara.dev) email API:
transactional and marketing email, broadcasts, contacts and lists, templates,
domains, inbound email, and webhooks — with sync and async clients, typed
models, a typed error hierarchy, automatic retries, and an auto-paginating
message iterator.

```bash
pip install sendara
```

Requires Python 3.9+. Runtime dependency: `httpx`.

## Quickstart

```python
import os
from sendara import Sendara, SendaraError

client = Sendara(os.environ["SENDARA_API_KEY"])  # sk_live_... or sk_test_...

# Send an email
result = client.emails.send(
    from_="hello@acme.com",
    to="user@example.com",
    subject="Welcome to Acme",
    html="<h1>Welcome 🎉</h1>",
)
print(result.id, result.status)

# Paginate every message (cursor handled for you)
for message in client.messages.iter(limit=100):
    print(message.id, message.status)
```

### Async

```python
import asyncio
from sendara import AsyncSendara

async def main():
    async with AsyncSendara("sk_live_...") as client:
        await client.emails.send(to="user@example.com", subject="Hi", html="<p>Hi</p>")
        async for message in client.messages.iter(limit=100):
            print(message.id)

asyncio.run(main())
```

Both clients are context managers (`with` / `async with`) and accept
`base_url`, `timeout`, `max_retries`, and an optional `http_client`.

```python
client = Sendara(
    "sk_live_...",
    base_url="https://api.sendara.dev",
    timeout=30.0,
    max_retries=3,
)
```

## Idempotency

Every send accepts an `idempotency_key`. The SDK generates one (UUIDv4)
automatically when you omit it, so retries are always safe. Pass your own to
deduplicate across processes:

```python
client.emails.send(to="u@e.com", subject="s", text="t", idempotency_key="order-42")
```

## Retries

Idempotent requests (all GET/PUT/DELETE and sends) are retried automatically on
`429`, `409`, `5xx`, and network/timeout errors, using exponential backoff with
jitter. A `Retry-After` header is honored when present. Attempts are bounded by
`max_retries`.

## Errors

Every failure raises a subclass of `SendaraError` carrying `status`, `code`,
`message`, and `request_id`:

| HTTP        | Exception              |
| ----------- | ---------------------- |
| 400 / 422   | `ValidationError`      |
| 401         | `AuthenticationError`  |
| 403         | `PermissionError_`     |
| 404         | `NotFoundError`        |
| 409         | `ConflictError`        |
| 429         | `RateLimitError` (`.retry_after`) |
| 5xx         | `ServerError`          |
| network     | `APIConnectionError` / `APITimeoutError` |

```python
from sendara import RateLimitError, SendaraError

try:
    client.emails.send(to="u@e.com", subject="s", html="<p>x</p>")
except RateLimitError as e:
    print("retry after", e.retry_after)
except SendaraError as e:
    print(e.status, e.code, e.message)
```

## Verifying webhooks

`webhooks.verify` recomputes `HMAC-SHA256(secret, "<timestamp>.<raw_body>")`
(hex) and checks it in constant time against the `Sendara-Signature` header.
Pass the **raw** request body, never a re-serialized object.

```python
from sendara import webhooks

# Flask example
@app.post("/webhooks/sendara")
def hook():
    event = webhooks.verify(
        os.environ["SENDARA_WEBHOOK_SECRET"],
        request.get_data(),     # raw bytes
        request.headers,        # case-insensitive mapping
    )
    print(event["event_type"], event["message_id"])
    return "", 200
```

`verify` raises `WebhookVerificationError` on any mismatch (bad signature,
missing headers, or a timestamp outside the `tolerance` window, default 300s).

## Method reference

```text
client.emails.send(to, subject, html=, text=, from_=, message_type=,
                   template_id=, template_vars=, idempotency_key=, metadata=, test_send=)
client.emails.send_raw(request)            # POST /v1/send (escape hatch)
client.emails.send_batch([req, ...])       # POST /v1/send/batch
client.emails.send_bulk(request)           # POST /v1/send/bulk

client.broadcasts.create(**params) / .list(limit=, offset=) / .get(id)
                 .send(id) / .cancel(id) / .delete(id)

client.messages.list(channel=, status=, search=, from_=, to=, limit=, cursor=)
client.messages.iter(channel=, status=, search=, from_=, to=, limit=)   # auto-paginating
client.messages.get(id) / .get(idempotency_key=)   # full event timeline

client.suppressions.list(channel=) / .create(channel, recipient, reason=)
                    .delete(channel, recipient)

client.domains.list() / .create(domain) / .get(domain) / .verify(domain)

client.api_keys.list() / .create(scope=, test_mode=) / .rotate(id) / .revoke(id)

client.usage.get(period=)
client.usage.set_spend_cap(key_id=, soft_limit_micros=, hard_limit_micros=)

client.billing.get() / .checkout(plan=) / .portal()

client.templates.create(**params) / .list() / .get(id) / .update(id, **params)
                .delete(id) / .render(id, vars=)

client.contacts.create(**params) / .list(limit=, offset=) / .get(id)
               .update(id, **params) / .delete(id) / .import_(s3_key=, format=)
client.lists.create(name, list_type=, segment_rules=) / .list() / .get(id)
            .update(id, **params) / .delete(id)
            .add_member(id, contact_id) / .remove_member(id, contact_id) / .members(id)

client.webhooks.create(endpoint_url, event_types=) / .list() / .get(id)
               .update(id, **params) / .delete(id)
               .deliveries(id, limit=) / .rotate_secret(id)

client.uploads.create(file, filename=, content_type=)   # multipart image

client.test_recipients.list() / .create(email) / .resend(id) / .delete(id)

webhooks.verify(secret, payload, headers=, signature=, timestamp=, tolerance=300)
```

The async client (`AsyncSendara`) exposes every method above with `await`;
`messages.iter` becomes an `async for`.

## Development

```bash
cd sdk/python
pip install -e '.[dev]'
python -m pytest
```

MIT licensed.
