SendClipper Python SDK
The official Python library for the SendClipper email API.
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.
from sendclipper import SendClipper
sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper() reads SENDCLIPPER_API_KEY
Send an email
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 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:
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:
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):
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
@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:
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:
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:
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.
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
Metadata
Release files for sendclipper 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sendclipper-0.1.0.tar.gz | 33.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sendclipper-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.1 kB
Release files / sendclipper-0.1.0.tar.gz
| Download URL | sendclipper-0.1.0.tar.gz |
|---|---|
| Size | 33.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
75d7851a28de92045deae0162dc4e76bb8e0183b621a2683564663f048278834
|
|
BLAKE2b-256 checksum How to use checksums |
60017e372b383360e0af37e25a8bcf511c23c19516e3779fa945f9c25a6e5de7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / sendclipper-0.1.0-py3-none-any.whl
| Download URL | sendclipper-0.1.0-py3-none-any.whl |
|---|---|
| Size | 33.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4c1dcacf63354a12d2dd4b440d0323327eeed0acdfb7e8938e7769f519188fb9
|
|
BLAKE2b-256 checksum How to use checksums |
b8641cb0febbe37affb1e81c6b056771b12bf58a4445e64d8086c9bcb1faa82a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log