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

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

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.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sendly_python-0.1.0.tar.gz (37.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sendly_python-0.1.0-py3-none-any.whl (21.2 kB view details)

Uploaded Python 3

File details

Details for the file sendly_python-0.1.0.tar.gz.

File metadata

  • Download URL: sendly_python-0.1.0.tar.gz
  • Upload date:
  • Size: 37.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sendly_python-0.1.0.tar.gz
Algorithm Hash digest
SHA256 79af0530e1a6971b9f3c409890a9071aa195136f0b612a1d3c9b206e683d1e48
MD5 5472c1853b342cb9130bb2a74d2f5546
BLAKE2b-256 eff3d0ae7bcc3d596981d1feebc3ea0728b90e8da87fe76d6a9637c767348c43

See more details on using hashes here.

Provenance

The following attestation bundles were made for sendly_python-0.1.0.tar.gz:

Publisher: release.yml on DevinoSolutions/sendly-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sendly_python-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: sendly_python-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 21.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sendly_python-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2d0d1db7308d2b20b101c005bd08bdbd6df7ae69614e2c9b483fb350ac96ba3b
MD5 9165605b0c2e4e498e734286edcdf141
BLAKE2b-256 87d05d8a385594cc3bd92c96fbfae52625076fa26903234834369625d7f15a0b

See more details on using hashes here.

Provenance

The following attestation bundles were made for sendly_python-0.1.0-py3-none-any.whl:

Publisher: release.yml on DevinoSolutions/sendly-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page