Metadata-Version: 2.4
Name: sendly-python
Version: 0.2.0
Summary: Official Sendly Python SDK
Project-URL: Homepage, https://sendly.now
Project-URL: Documentation, https://docs.sendly.now
Project-URL: Repository, https://github.com/DevinoSolutions/sendly-python
Author-email: Devino Solutions <dev@devino.ca>
License: MIT
License-File: LICENSE
Keywords: api,email,sdk,sendly,transactional-email,webhooks
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx==0.28.1
Provides-Extra: dev
Requires-Dist: mypy==1.15.0; extra == 'dev'
Requires-Dist: pytest==8.3.4; extra == 'dev'
Requires-Dist: ruff==0.9.6; extra == 'dev'
Description-Content-Type: text/markdown

# Sendly Python SDK

Official Python SDK for the [Sendly](https://sendly.now) REST API — transactional
email, contacts, events, domains, templates, email verification, webhooks, and
suppression.

[![CI](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml/badge.svg)](https://github.com/DevinoSolutions/sendly-python/actions/workflows/ci.yml)

- Full type hints (ships `py.typed`), `mypy --strict` clean.
- One small runtime dependency: [`httpx`](https://www.python-httpx.org/).
- Fail-loud by design: no silent fallbacks, no degraded mode.

## Installation

```bash
pip install sendly-python
```

The distribution is published as `sendly-python`; the import name is unchanged:

```python
import sendly
```

Alternatively, install the latest `main` directly from GitHub:

```bash
pip install git+https://github.com/DevinoSolutions/sendly-python.git
```

Requires Python 3.10+.

## Already on Resend, SendGrid, Postmark, Mailgun, or Plunk?

You don't even need this SDK to try Sendly. The API also speaks the
transactional-send dialect of those providers — keep the vendor SDK you already
run and change **two things**: the base URL and the API key.

```python
import resend  # your existing Resend integration

resend.api_key = "sk_your_sendly_key"
resend.api_url = "https://api.sendly.now/api/compat/resend"
# resend.Emails.send(...) now sends through Sendly — same code, same shapes.
```

Every compat request runs through the same pipeline as the native API (domain
verification, suppression, limits), and anything a dialect can express that
Sendly doesn't support returns a clean error in that vendor's own error shape.
Per-provider guides: [docs.sendly.now/migrate](https://docs.sendly.now/migrate).

## Quickstart

The client reads your API key from the `SENDLY_API_KEY` environment variable:

```python
from sendly import Sendly

sendly = Sendly()  # reads SENDLY_API_KEY

result = sendly.emails.send(
    {
        "from": "hello@yourdomain.com",
        "to": "customer@example.com",
        "subject": "Welcome aboard",
        "body": "<h1>Thanks for signing up!</h1>",
    }
)
print(result["id"])
```

Or pass the key explicitly:

```python
sendly = Sendly(api_key="sk_live_...")
```

If neither an explicit key nor `SENDLY_API_KEY` is set, the constructor raises a
`SendlyError` immediately.

### Options

```python
sendly = Sendly(
    api_key="sk_live_...",
    base_url="https://api.sendly.now",  # override for staging/self-hosted
    timeout=30.0,                        # per-request seconds; 0 or None disables
    default_headers={"X-Trace-Id": "..."},
)
```

The client holds an internal connection pool. Reuse a single instance, and close
it when done (or use it as a context manager):

```python
with Sendly() as sendly:
    sendly.emails.send({...})
```

## Usage by resource

### Emails

```python
# Single send (pass idempotency_key to dedupe replays for 24h)
sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"},
                   idempotency_key="order-42-receipt")

# Batch send (up to 100)
sendly.emails.batch({"emails": [{"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"}]})

# List, get, cancel a scheduled send
sendly.emails.list({"limit": 20, "status": "DELIVERED"})
sendly.emails.get("em_123")
sendly.emails.cancel_schedule("em_123")
```

### Contacts

```python
sendly.contacts.create({"email": "user@example.com", "subscribed": True})
sendly.contacts.upsert({"email": "user@example.com", "data": {"plan": "pro"}})
sendly.contacts.list({"limit": 50, "search": "example.com"})
sendly.contacts.get("c_123")
sendly.contacts.update("c_123", {"data": {"plan": "enterprise"}})
sendly.contacts.delete("c_123")
sendly.contacts.bulk_create({"contacts": [{"email": "a@x.com"}, {"email": "b@x.com"}]})
sendly.contacts.bulk_delete({"emails": ["a@x.com"]})
```

### Events

```python
# Track a custom event for a contact (accepts sk_* and pk_* keys)
result = sendly.events.track({"event": "signup", "email": "user@example.com"})
print(result["contact"], result["timestamp"])

# Attach an arbitrary payload and set subscription state
sendly.events.track({"event": "purchase", "email": "user@example.com",
                     "subscribed": True, "data": {"plan": "pro", "amount": 42}})
```

### Domains

```python
sendly.domains.create({"domain": "mail.yourdomain.com", "region": "us-east-1"})
sendly.domains.list()
sendly.domains.get("d_123")
sendly.domains.verify("d_123")
sendly.domains.get_verification("d_123")
sendly.domains.delete("d_123")
```

### Templates

```python
sendly.templates.create({"name": "Welcome", "subject": "Welcome", "body": "<p>Hi</p>",
                         "from": "a@you.com", "type": "MARKETING"})
sendly.templates.list({"limit": 25})  # cursor pagination: pass {"cursor": ...} for the next page
sendly.templates.get("t_123")
sendly.templates.update("t_123", {"name": "Welcome v2"})
sendly.templates.delete("t_123")
```

### Verify

```python
# Validate an email address (syntax, MX, disposable domains, plus-addressing).
# Open endpoint — the SDK still sends your API key, which the API ignores.
result = sendly.verify.email({"email": "user@example.com"})
if not result["valid"]:
    print("Rejected:", result.get("reason"))
```

### Webhooks

```python
created = sendly.webhooks.create({"url": "https://you.com/hook", "eventTypes": ["email.delivered"]})
# Store the signing secret now — it is only returned in full at creation/rotation.
sendly.webhooks.list()
sendly.webhooks.get("w_123")
sendly.webhooks.update("w_123", {"status": "PAUSED"})
sendly.webhooks.rotate_secret("w_123")
sendly.webhooks.list_calls("w_123", {"limit": 20})
sendly.webhooks.delete("w_123")
```

### Suppression

```python
sendly.suppression.add({"email": "bounce@example.com", "reason": "MANUAL"})
sendly.suppression.list({"reason": "MANUAL", "limit": 100})
sendly.suppression.get("bounce@example.com")
sendly.suppression.remove("bounce@example.com")
```

### Lists

```python
# Both calls accept sending-only (pk_*) keys, so they can back a public form.
result = sendly.lists.subscribe("l_123", {"email": "user@example.com"})

# On a double opt-in list the membership is PENDING and carries a confirmToken.
# Sendly does NOT send the confirmation email — deliver this link yourself.
if result["status"] == "PENDING":
    confirm_url = f"https://api.sendly.now/api/lists/confirm?token={result['confirmToken']}"

# Re-subscribing an address that opted out needs an explicit opt-in, or the call
# fails with 409 RESUBSCRIBE_CONFIRMATION_REQUIRED.
sendly.lists.subscribe("l_123", {"email": "user@example.com", "allowResubscribe": True})

sendly.lists.unsubscribe("l_123", {"email": "user@example.com"})
```

## The v1 API

`campaigns`, `segments`, `workflows`, `analytics` and `usage` — plus the v1
methods on `events` — speak Sendly's `/api/v1` surface. Same client, same API
key; two differences worth knowing:

- **Responses are bare resource bodies.** There is no `{success, data}` envelope
  to unwrap, so what the API documents is exactly what you get.
- **Errors are RFC 9457 problem documents.** They raise the same exception
  classes as the legacy surface, with two extra fields — see
  [Error handling](#error-handling).

### Campaigns

```python
campaign = sendly.campaigns.create(
    {
        "name": "August launch",
        "subject": "We are live",
        "body": "<p>Hello</p>",
        "from": "team@you.com",
        "audience_type": "ALL",
    },
    idempotency_key="august-launch",
)

# Send now, or schedule it. Key the replay — a duplicate send mails the audience twice.
sendly.campaigns.send(campaign["id"], idempotency_key="august-launch-send")
sendly.campaigns.send(campaign["id"], {"scheduled_for": "2026-09-01T10:00:00Z"})

sendly.campaigns.pause(campaign["id"])
sendly.campaigns.resume(campaign["id"])
sendly.campaigns.cancel(campaign["id"])

stats = sendly.campaigns.stats(campaign["id"])
print(stats["delivered"], stats["open_rate"])
```

### Pagination

Every v1 list answers `{data, has_more, next_cursor}` — an opaque forward-only
cursor, and no total. Page it yourself with `limit` (1–100, default 20) and
`after`:

```python
page = sendly.campaigns.list({"limit": 50})
while page["has_more"]:
    page = sendly.campaigns.list({"limit": 50, "after": page["next_cursor"]})
```

…or let the `iter_*` companion do it. It yields individual items and follows the
cursor until the last page:

```python
for campaign in sendly.campaigns.iter_list({"limit": 100}):
    print(campaign["name"], campaign["status"])

for contact in sendly.segments.iter_list_contacts("seg_123"):
    print(contact["email"])
```

Keep your filters identical for every page of one walk. Changing them
mid-pagination invalidates the cursor and the API answers `422 validation_error`
telling you to restart from the first page — which is exactly why `iter_*` holds
the query fixed and only advances `after`.

Available on the six cursor-paginated listings: `campaigns.iter_list`,
`segments.iter_list`, `segments.iter_list_contacts`, `workflows.iter_list`,
`workflows.iter_list_executions`, `events.iter_list`. The analytics endpoints and
`events.list_names` / `events.stats` return a bounded aggregate rather than a
cursor, so they have no iterator.

### Segments, workflows, events, analytics, usage

```python
segment = sendly.segments.create({"name": "Power users", "type": "DYNAMIC",
                                  "condition": {"field": "plan", "op": "eq", "value": "pro"}})
sendly.segments.list_contacts(segment["id"], {"limit": 50})

workflow = sendly.workflows.create({"name": "Welcome", "event_name": "signup.completed"})
sendly.workflows.start_execution(workflow["id"], {"contact_id": "c_123"})
# Executions are cancelled by execution id alone — not nested under the workflow.
sendly.workflows.cancel_execution("exe_123")
sendly.workflows.stats(workflow["id"], {"from": "2026-08-01"})

# events.record is the v1 counterpart of the legacy events.track. Same effect,
# v1 dialect. It takes no idempotency_key: events are append-only and the API
# deliberately does not ledger them.
sendly.events.record({"name": "signup.completed", "contact_id": "c_123", "data": {"plan": "pro"}})
sendly.events.list({"event_name": "signup.completed", "limit": 20})
sendly.events.list_names()
sendly.events.stats({"from": "2026-08-01", "to": "2026-08-31"})

sendly.analytics.timeseries({"from": "2026-08-01", "to": "2026-08-31"})
sendly.analytics.campaigns()
sendly.analytics.top_campaigns({"limit": 5})

usage = sendly.usage.get()
print(usage["plan"], usage["monthly"])
```

## Error handling

Every non-2xx response raises a `SendlyError` subclass carrying `status_code`,
`error_code`, `message`, and the raw `body`:

```python
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError, SendlyError

sendly = Sendly()
try:
    sendly.emails.send({"from": "a@you.com", "to": "b@them.com", "subject": "Hi", "body": "<p>Hi</p>"})
except SendlyValidationError as err:
    print("Bad request:", err.error_code, err.message)
except SendlyRateLimitError:
    print("Slow down and retry with backoff")
except SendlyError as err:
    print("Sendly error", err.status_code, err.message)
```

| Exception | HTTP status |
| --- | --- |
| `SendlyValidationError` | 400, 422 |
| `SendlyAuthenticationError` | 401 |
| `SendlyPermissionError` | 403 |
| `SendlyNotFoundError` | 404 |
| `SendlyConflictError` | 409 |
| `SendlyRateLimitError` | 429 |
| `SendlyServerError` | 5xx |
| `SendlyConnectionError` | transport failure (status `0`) |

All inherit from `SendlyError`.

Invalid input raises `SendlyValidationError`. Migrated routes report it as HTTP
`422` with `error_code == "VALIDATION_ERROR"` and a per-field breakdown under
`err.body["error"]["details"]["errors"]`; legacy/malformed requests still use
`400`. Both surface as `SendlyValidationError`.

### v1 errors (RFC 9457)

The `/api/v1` surface reports failures as `application/problem+json` documents.
They raise the **same** exception classes, keyed off the same statuses, so
existing `except` blocks keep working. Three things move:

- `error_code` comes from the problem's `code` — a lowercase, machine-readable
  value like `scope_missing`, `quota_exhausted`, or `idempotency_key_reused`.
- `err.request_id` carries the correlation id. Quote it in support requests.
- `err.field_errors` carries the per-field breakdown on a `validation_error`,
  each entry `{pointer, code, message}` with an RFC 6901 JSON Pointer.

```python
from sendly import Sendly, SendlyValidationError, SendlyRateLimitError

sendly = Sendly()
try:
    sendly.campaigns.create({"name": "Launch"})
except SendlyValidationError as err:
    print(err.error_code, err.message, err.request_id)
    for field in err.field_errors or []:
        print(f"  {field['pointer']}: {field['message']}")
except SendlyRateLimitError as err:
    # Two different failures share this class — check the code before retrying.
    if err.error_code == "quota_exhausted":
        print("Plan limit reached; backing off will not help")
    else:
        print("Too fast — retry with backoff")
```

The full problem document stays on `err.body`, so `type`, `title` and `instance`
remain reachable. On the legacy surface `request_id` and `field_errors` are
`None`.

## Verifying webhooks

Every delivery is signed. Verify it against the **raw** request body — do not
parse the JSON first. Two headers are sent:

- `X-Sendly-Signature` — bare lowercase hex HMAC-SHA256 of `"{timestamp}.{body}"`
  (no `sha256=` prefix).
- `X-Sendly-Timestamp` — the signing time as a **millisecond** Unix epoch.

`verify_signature` also enforces replay protection: a delivery whose timestamp is
more than `DEFAULT_TOLERANCE_MS` (5 minutes) from now is rejected. Pass
`tolerance_ms=math.inf` to disable that check.

```python
import os
from flask import Flask, request
from sendly import construct_event

app = Flask(__name__)

@app.post("/webhook")
def webhook():
    payload = request.get_data()  # raw bytes
    signature = request.headers.get("X-Sendly-Signature", "")
    timestamp = request.headers.get("X-Sendly-Timestamp", "")
    secret = os.environ["SENDLY_WEBHOOK_SECRET"]
    try:
        event = construct_event(payload, signature, timestamp, secret)
    except ValueError:
        return "Invalid signature", 400
    # handle event["event"], event["data"], ...
    return "", 200
```

`verify_signature(payload, signature, timestamp, secret, *, tolerance_ms=...) -> bool`
is also exported if you only need the boolean check. Both use a constant-time
comparison and reject a stale or non-numeric timestamp.

## Async

Only a synchronous client ships in v0.1. An `httpx.AsyncClient`-backed async
variant is planned.

## Development

```bash
python -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate
pip install -e ".[dev]"

ruff check .
ruff format --check .
mypy src
pytest
```

Tests are fully hermetic (httpx `MockTransport`) and hit no network.

## Documentation

Full API reference: <https://docs.sendly.now>

## License

MIT — see [LICENSE](LICENSE).
