Metadata-Version: 2.5
Name: norialabs-send
Version: 0.1.0
Summary: Python client for Noria Send: send transactional email and SMS through one internal service instead of wiring a provider into every product.
Project-URL: Homepage, https://github.com/norialabs/send
Project-URL: Source, https://github.com/norialabs/send
Project-URL: Issues, https://github.com/norialabs/send/issues
Author-email: Joseph Gitonga <thekiharani@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: email,noria,onfon,send,ses,sms,transactional
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Communications :: Telephony
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: httpx>=0.28.1
Description-Content-Type: text/markdown

# norialabs-send

Python client for [Noria Send](https://github.com/norialabs/send): transactional email and
SMS through one internal service. Sync and async, typed, no dependency beyond `httpx`.

```bash
pip install norialabs-send      # or: uv add norialabs-send
```

```python
from noria_send import Send

send = Send(api_key=os.environ["NORIA_SEND_KEY"])

send.emails.send({
    "from_": "Noria <hello@norialabs.com>",
    "to": "founder@example.com",
    "subject": "Welcome",
    "html": "<p>Hi there</p>",
})

send.sms.send({"from_": "NORIA", "to": "0712345678", "text": "Your code is 482913"})
```

`base_url` defaults to `https://send.noria.co.ke`; pass it to reach a local service or another
instance.

`from_` carries the trailing underscore because `from` is a keyword; it is sent as `from`.
Every other field is exactly what goes on the wire.

## Async

The same surface, for FastAPI and anything else on asyncio.

```python
from noria_send import AsyncSend

async with AsyncSend(api_key=key, base_url=url) as send:
    await send.emails.send({"to": "a@example.com", "subject": "Hi", "text": "there"})
    await send.sms.send({"to": "0712345678", "text": "Your code is 482913"})
```

## Sending

```python
send.emails.send(email, idempotency_key=f"signin-{token.id}")
send.emails.send_batch([email, email])

send.sms.send(message, idempotency_key=f"otp-{token.id}")
send.sms.send_batch([message, message])
```

Both also take `template` and `variables` instead of a body, `scheduled_at` for a future send
and `tags`. Email adds `attachments` as base64, plus `cc`, `bcc`, `reply_to` and `headers`.

An SMS takes one recipient, because one row carries one provider message id and that is what a
delivery receipt is matched against. Use `send_batch` for many.

## Reading what was sent

One ledger covers every channel:

```python
send.messages.list(channel="sms", status="failed", limit=50)
send.messages.get(message_id)
send.messages.events(message_id)
send.messages.cancel(message_id)
send.messages.requeue(message_id)
```

## Domains, senders, templates, suppressions, webhooks

```python
send.domains.create("norialabs.com")          # returns the DNS records to publish
send.senders.create("NORIA")                  # registered pending approval
send.templates.upsert("welcome", subject="Hi {{name}}", html=html)
send.templates.upsert("otp", channel="sms", text="Code {{code}}")
send.suppressions.add("0712345678", channel="sms", reason="unsubscribe")
send.is_suppressed("0712345678", "sms")
send.webhooks.create("https://app.example.com/hooks/send", ["delivered", "bounced"])
```

A slug is unique per channel, so an `otp` email template and an `otp` SMS template can coexist.

## Errors

Failures raise `SendError` with `code`, `status`, `details` and `request_id`, plus
`suppressed`, `over_quota` and `retryable` for the common branches. The client retries 408,
429 and 5xx with backoff and never retries `quota_exceeded`, `suppressed_recipient`,
`domain_not_verified`, `sender_not_approved` or `message_too_long`.

```python
try:
    send.sms.send(message)
except SendError as error:
    if error.suppressed:
        return
    raise
```

## Webhooks

```python
from noria_send import verify_webhook

event = verify_webhook(
    payload=await request.body(),
    signature=request.headers["noria-signature"],
    secret=os.environ["NORIA_SEND_WEBHOOK_SECRET"],
)
```

HMAC-SHA256 over `<timestamp>.<raw body>`, compared in constant time, with a five minute
tolerance against replay. `event["data"]["channel"]` says which channel the event came from.

## Tests

```bash
uv sync
uv run pytest
uv run ruff check src tests
```
