Metadata-Version: 2.4
Name: sendara
Version: 0.5.0
Summary: Sendara — email API (transactional + marketing email, broadcasts, contacts, templates, domains, 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, and signed webhook events — 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(
            from_="hello@acme.com",
            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 and reuses that value for retries within the
same call. Pass your own to deduplicate across process restarts or separate calls:

```python
client.emails.send(
    from_="hello@acme.com",
    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=,
                   store_payload=, test_send=, scheduled_at=, validate_recipient=)
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)
    # audience params: list_ids=[...], exclude_list_ids=[...], audience_list_id=,
    # or recipients=[...]. Same keys apply to emails.send_bulk(request).

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=, period=) / .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`.

`billing.checkout` accepts `starter`, `pro`, `growth`, or `scale`, with a
`month` or `year` period. It returns a `CheckoutResult` for both successful
outcomes:

```python
checkout = client.billing.checkout(plan="growth", period="year")
if checkout.updated:
    print("subscription changed to", checkout.plan)
else:
    redirect(checkout.url)  # hosted checkout for a new subscription
```

## Development

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

MIT licensed.
