Skip to main content

Sendly Python SDK

Official Python SDK for the Sendly REST API — transactional email, contacts, events, domains, templates, email verification, webhooks, and suppression.

CI

  • Full type hints (ships py.typed), mypy --strict clean.
  • One small runtime dependency: httpx.
  • Fail-loud by design: no silent fallbacks, no degraded mode.

Installation

pip install sendly-python

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

import sendly

Alternatively, install the latest main directly from GitHub:

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.

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.

Quickstart

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

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:

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

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

Options

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):

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

Usage by resource

Emails

# 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

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

# 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

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

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

# 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

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

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

# 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.

Campaigns

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:

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:

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

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:

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.
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.

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

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.

Release files for sendly-python 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sendly-python 0.2.0
File Size Uploaded
sendly_python-0.2.0.tar.gz 73.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sendly-python 0.2.0
File Interpreter ABI Platform
sendly_python-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 109.6 kB

Release files / sendly_python-0.2.0.tar.gz

Download URL sendly_python-0.2.0.tar.gz
Size 73.7 kB
Tags Source
SHA-256 checksum
How to use checksums
77d68de439f34416aa5931a2651b9dac37a56657a7d0e91fcbb1490eafd8d812
BLAKE2b-256 checksum
How to use checksums
ed342cc3f32fea26767abbb36caac7effd19fccdf6098950c91ce4213552c71a
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 Aug 18, 2026.

Transparency log

Release files / sendly_python-0.2.0-py3-none-any.whl

Download URL sendly_python-0.2.0-py3-none-any.whl
Size 35.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cd66ac0b9fb0d363233060d8811330f669b30c696788c8eb06cf4d2e0d1ec7ec
BLAKE2b-256 checksum
How to use checksums
76235be8329ffaf8fe231fa64740ca2464307c121436ac81676f960b2a1e50c3
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 Aug 18, 2026.

Transparency log

Release history Release notifications | RSS feed

1.1.0

2 release files

1.0.0

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page