Metadata-Version: 2.5
Name: sendclipper
Version: 0.1.0
Summary: Official Python SDK for the SendClipper email API: send mail, manage domains, templates, sandboxes, webhooks, contacts and suppressions.
Project-URL: Homepage, https://sendclipper.com
Project-URL: Documentation, https://sendclipper.com/docs#sdk-python
Project-URL: Source, https://github.com/sendclipper/sendclipper-python
Project-URL: Issues, https://github.com/sendclipper/sendclipper-python/issues
Project-URL: Changelog, https://github.com/sendclipper/sendclipper-python/blob/main/CHANGELOG.md
Author-email: SendClipper <support@sendclipper.com>
License-Expression: MIT
License-File: LICENSE
Keywords: email,sdk,sendclipper,smtp,transactional-email,webhooks
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# SendClipper Python SDK

The official Python library for the [SendClipper](https://sendclipper.com) email API.

```bash
pip install sendclipper
# or
uv add sendclipper
# or
poetry add sendclipper
```

Python 3.9 or newer. No runtime dependencies. Fully typed.

## Setup

Create an API key in the dashboard under **API keys**.

```python
from sendclipper import SendClipper

sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper()  reads SENDCLIPPER_API_KEY
```

## Send an email

```python
data, error = sc.emails.send({
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>",
    "text": "Thanks for your order.",
})

if error:
    print(error.name, error.message)  # e.g. domain_not_verified
else:
    print(f"Email {data['id']} accepted")
```

Parameters and responses are plain dicts whose keys are the API's own field names (`templateId`, `sendClass`, ...), exactly as the [API reference](https://sendclipper.com/docs) writes them. Your editor still completes them: every one is a `TypedDict` in `sendclipper.types`.

`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.

## Send with a template

Design and publish a template in the dashboard, then send it by id or by its send name:

```python
sc.emails.send({
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "templateId": "tpl_9f2b7c10ad42",  # or "template": "welcome"
    "variables": {"first_name": "Ada"},
})
```

A tag with no value and no `{{tag|fallback}}` is refused by name (`template_variables_missing`), never sent blank.

## Send a batch

Up to 500 messages in one request. Each item is accepted or refused on its own:

```python
data, error = sc.batch.send([
    {"from": "hello@send.acme.io", "to": "ada@lumen.io", "subject": "Hi Ada", "html": "<p>Hi</p>"},
    {"from": "hello@send.acme.io", "to": "alan@lumen.io", "templateId": "tpl_9f2b7c10ad42", "variables": {"first_name": "Alan"}},
])

for item in data["data"]:
    if item["status"] == "failed":
        print(item["index"], item["error"]["code"])
```

## Errors

API methods never raise for an API refusal or a network failure. They return a `Result` with `data`, `error` and `headers`, and exactly one of `data` and `error` is set. A `Result` unpacks to `(data, error)`:

```python
result = sc.domains.list()
result.data     # the parsed response, or None
result.error    # an ErrorResponse, or None
result.headers  # response headers (lower-case names), or None when no answer arrived
result.ok       # error is None

domains = sc.domains.list().unwrap()  # data, or raises SendClipperApiError
```

```python
@dataclass(frozen=True)
class ErrorResponse:
    name: str                   # SendClipper's code: "validation_failed", "rate_limited", "network_error", ...
    message: str                # a sentence for a person
    status_code: Optional[int]  # None when no answer arrived (network, timeout)
    detail: Any = None          # code-specific facts
```

The iterators (`emails.iterate()` and friends) raise `SendClipperApiError` instead, since a loop has no `error` to hand back. `err.code` is the API's code and `err.status` the HTTP status.

## Retries and idempotency

Network errors, `429` and `5xx` answers are retried twice with backoff, honouring `Retry-After`. Every JSON write carries an `Idempotency-Key`, and the API answers a repeated key with the first result for 24 hours. A retried send therefore never goes out twice.

To make your own retries safe as well (a job that runs again after a crash), pass a key that names the logical operation:

```python
sc.emails.send(params, idempotency_key=f"order-{order.id}-receipt")
```

## Request options

Every method takes request options as keyword arguments after its own parameters:

```python
sc.emails.list({"status": "bounced"}, timeout=5, headers={"X-Trace-Id": trace_id})
```

| Option | Meaning |
|---|---|
| `idempotency_key` | Your own key for a write |
| `timeout` | Per-attempt timeout in seconds (default 30) |
| `max_retries` | Retries for this call (default 2) |
| `headers` | Extra request headers |

Client options: `SendClipper(api_key, base_url=..., timeout=..., max_retries=..., default_headers=..., transport=...)`. `base_url` also reads `SENDCLIPPER_BASE_URL`. `transport` replaces the standard-library HTTP sender, e.g. to go through a proxy library: it receives a `TransportRequest` and returns a `TransportResponse`.

## Verify webhooks

SendClipper signs each delivery. Verify it against the **raw** body, before parsing:

```python
import os
from flask import Flask, request
from sendclipper import verify_webhook, WebhookVerificationError

app = Flask(__name__)

@app.post("/hooks/sendclipper")
def sendclipper_hook():
    try:
        event = verify_webhook(
            request.get_data(),  # raw bytes, not request.json
            request.headers,
            os.environ["SENDCLIPPER_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError as err:
        return err.reason, 400
    # event["id"] is stable across retries: deduplicate on it
    print(event["event"], event["data"]["to"])
    return "", 200
```

The same call works in Django (`request.body`, `request.headers`) and FastAPI (`await request.body()`, `request.headers`). During a secret rotation a delivery carries two signatures and either one is accepted. A delivery signed more than five minutes away from now is refused. `sc.webhooks.verify(...)` is the same function.

To test your handler locally, `signature_header(secret, raw_body)` builds a valid header.

## Sandboxes

A sandbox traps mail while you develop: nothing is delivered.

```python
box = sc.sandboxes.create({"name": "my-app-dev"}).unwrap()
creds = sc.sandboxes.credentials(box["id"]).unwrap()  # SMTP host, port, username, password

# Or send through the sandbox's JSON door with its own token, using the same fields as emails.send():
token = sc.sandboxes.reset_credentials(box["id"]).unwrap()
sc.sandboxes.send(token["apiToken"], {"from": "app@example.com", "to": "test@example.com", "subject": "Hi", "html": "<p>Hi</p>"})

# In a test: wait for the message to arrive
msg, error = sc.sandboxes.messages.wait_for(box["id"], match=lambda m: m["subject"] == "Hi")
```

## Everything else

| Resource | Methods |
|---|---|
| `emails` | `send`, `get`, `list`, `iterate`, `stats` |
| `batch` | `send` |
| `domains` | `list`, `create`, `records`, `verify`, `enable_receiving`, `disable_receiving`, `set_region`, `deletion_preview`, `remove` |
| `templates` | `list`, `get`, `html`, `create`, `update`, `remove`, `duplicate`, `publish`, `unpublish`, `preview`, `set_tags`, `projects` |
| `templates.tags` | `list`, `create`, `update`, `remove` |
| `templates.assets` | `list`, `upload` |
| `sandboxes` | `list`, `get`, `create`, `update`, `remove`, `credentials`, `reset_credentials`, `send` |
| `sandboxes.messages` | `list`, `get`, `raw`, `remove`, `clear`, `mark_all_read`, `wait_for` |
| `webhooks` | `list`, `create`, `update`, `remove`, `rotate_secret`, `deliveries`, `verify` |
| `webhooks.dispatches` | `list`, `iterate`, `get`, `replay`, `replay_all` |
| `contacts` | `list`, `iterate`, `create`, `import_`, `import_preview`, `export` |
| `audiences` | `list` |
| `suppressions` | `list`, `iterate`, `create`, `remove`, `export` |
| `mailboxes` | `list`, `get`, `create`, `update`, `archive` |
| `mailboxes.messages` | `list`, `iterate`, `get`, `attachment` |
| `automations` | `enroll` |
| `workspace` | `get` |
| `api_keys` | `list` |

`contacts.import_` has a trailing underscore because `import` is a Python keyword. Management routes need a key with **Full access**. A sending-only key can call `emails.send` and `batch.send`.

Full API reference: <https://sendclipper.com/docs>.

## License

MIT
