Skip to main content

Official Python SDK for Senddy.io

Project description

Senddy Python SDK

Official Python SDK for the Senddy.io email API.

Full API reference → — every method, parameter, and return type.

Installation

Requires Python 3.9+.

pip install senddy

Quick Start

from senddy import Senddy, SendEmailParams

client = Senddy("senddy_live_api_key")

result = client.emails.send(SendEmailParams(
    from_="sender@senddy.io",
    to="recipient@example.com",
    subject="Hello",
    html="<p>Welcome!</p>",
))

print(result.id)  # email_abc123

Async Support

from senddy import AsyncSenddy, SendEmailParams

async with AsyncSenddy("senddy_live_api_key") as client:
    result = await client.emails.send(SendEmailParams(
        from_="sender@senddy.io",
        to="recipient@example.com",
        subject="Hello",
        html="<p>Welcome!</p>",
    ))

Configuration

All options are optional — sensible defaults are used if you don't override them.

client = Senddy(
    "senddy_live_api_key",
    timeout=30.0,                   # seconds
    retries=2,
    headers={"X-Custom": "value"},  # extra headers on every request
)

If no API key is passed to the constructor, the SDK reads from the SENDDY_API_KEY environment variable.

Both clients support context managers for proper resource cleanup:

with Senddy("senddy_live_api_key") as client:
    # client.close() called automatically on exit
    ...

Emails

Send

from senddy import SendEmailParams, Attachment, RequestOptions

result = client.emails.send(
    SendEmailParams(
        from_="sender@senddy.io",
        to=["alice@example.com", "bob@example.com"],
        subject="Hello",
        html="<p>Hi there</p>",
        text="Hi there",                # optional plain-text fallback
        cc="cc@example.com",            # optional
        bcc="bcc@example.com",          # optional
        reply_to="reply@example.com",   # optional
        in_reply_to="<msg-1@senddy.io>",                 # optional: thread a reply (In-Reply-To)
        references=["<msg-0@senddy.io>"],                # optional: thread history (References)
        headers={"X-Campaign-Id": "welcome"},           # optional custom headers
        tags={"campaign": "welcome"},   # optional metadata
        attachments=[Attachment(        # optional
            filename="report.pdf",
            content=base64_string,
            content_type="application/pdf",
        )],
    ),
    # optional — send() already auto-generates a key per call; set this only to
    # dedupe the same send across separate calls (see Retries & idempotency).
    options=RequestOptions(idempotency_key="order-1234-receipt"),
)

Note: The from_ parameter uses a trailing underscore because from is a Python reserved word. The SDK maps it to from in the API request automatically.

Multiple recipients: a send with several to/cc recipients is one shared email — everyone sees the full To:/Cc: list and reply-all threads for all of them (one shared Message-ID). bcc recipients receive it but are never shown to anyone. To keep recipients hidden from each other, send separate single-recipient requests. Combined to+cc+bcc ≤ 50; each deliverable recipient is billed as one credit.

Get

email = client.emails.get("email_abc123")
print(email.status)      # 'delivered'
print(email.recipients)  # list of EmailRecipient
print(email.events)      # list of EmailEvent

List

from senddy import ListEmailsParams

result = client.emails.list(ListEmailsParams(
    limit=25,
    offset=0,
    status="delivered",
    since="2026-01-01T00:00:00Z",
))

for email in result.data:
    print(email.subject)
print(result.pagination.total)

Get rendered content

content = client.emails.get_content("email_abc123")
print(content.html)  # str or None
print(content.text)  # str or None

Download EML

download = client.emails.download("email_abc123")
# download.content is bytes containing the raw EML
# download.content_type is 'message/rfc822'

Resend

resent = client.emails.resend("email_abc123")
print(resent.id, resent.original_id)

Suppressions

List

from senddy import ListSuppressionsParams

result = client.suppressions.list(ListSuppressionsParams(
    limit=50,
    search="example.com",
    reason="hard_bounce",
))

Create

from senddy import CreateSuppressionParams

entry = client.suppressions.create(CreateSuppressionParams(
    email_address="block@example.com",
))

Delete

result = client.suppressions.delete("block@example.com")
print(result.removed)  # True

Export

from senddy import ExportSuppressionsParams

export = client.suppressions.export(ExportSuppressionsParams(format="csv"))
# export.content is a raw string (CSV by default, or a JSON string for format="json")

Domains

from senddy import CreateDomainParams

# Add a sending domain
domain = client.domains.create(CreateDomainParams(domain="mail.example.com"))

# Trigger DNS verification. `verified` reflects ownership (the TXT challenge);
# spf_verified / dmarc_verified / dmarc_policy are advisory DNS observations
# reported regardless of ownership.
result = client.domains.verify(domain.id)
result.verified  # bool — ownership proven
result.spf_verified  # bool
result.dmarc_verified  # bool
result.dmarc_policy  # str | None — e.g. "reject"

# Add a subdomain under a verified root (inherits the root's verification)
from senddy import AddSubdomainParams

client.domains.add_subdomain(domain.id, AddSubdomainParams(subdomain="mail"))

# List, get, delete
client.domains.list()
client.domains.get(domain.id)
client.domains.delete(domain.id)

# Rotate DKIM keys
client.domains.regenerate_dkim(domain.id)

# Bulk DNS health check across all domains
client.domains.check_dns_health()

Inbound Routes

from senddy import CreateInboundRouteParams, UpdateInboundRouteParams

# Create a catchall route that forwards to your webhook
route = client.inbound_routes.create(CreateInboundRouteParams(
    match_type="catchall",
    webhook_url="https://your-app.example.com/inbound",
))

# List, get, update, delete
client.inbound_routes.list()
client.inbound_routes.get(route.id)
client.inbound_routes.update(route.id, UpdateInboundRouteParams(enabled=False))
client.inbound_routes.delete(route.id)

# Verify the MX record points at Senddy
client.inbound_routes.verify_mx(route.id)

See the inbound docs for match patterns, webhook signing, and full configuration.

Inbound Emails

from senddy import ListInboundEmailsParams

result = client.inbound_emails.list(ListInboundEmailsParams(route_id=42, limit=50))

email = client.inbound_emails.get("inbound_xyz")
print(email.text_body, email.attachments)

Webhook Endpoints

from senddy import CreateWebhookEndpointParams, UpdateWebhookEndpointParams

# Register an endpoint to receive event callbacks. event_type is one of:
# sent, delivered, bounced, complained, opened, clicked, or "*" for all events.
result = client.webhook_endpoints.create(CreateWebhookEndpointParams(
    url="https://your-app.example.com/webhooks/senddy",
    domain="mail.example.com",
    event_type="delivered",
))
# Save result.secret — you'll need it to verify the signature on incoming webhooks.

# Test, list, update, delete
client.webhook_endpoints.test(result.id)
client.webhook_endpoints.list()
client.webhook_endpoints.update(result.id, UpdateWebhookEndpointParams(url="https://new-url.example.com"))
client.webhook_endpoints.delete(result.id)

# Inspect delivery history
deliveries = client.webhook_endpoints.deliveries(result.id)

Verifying webhook signatures

verify_webhook_signature is a stateless helper (no API key needed) — call it from your webhook handler with the raw request body and the X-Webhook-* headers. It returns the parsed event on success and raises WebhookVerificationError on a bad signature or a stale (replayed) timestamp.

from senddy import verify_webhook_signature, WebhookVerificationError

# In your HTTP handler — pass the RAW body (str or bytes), not a re-serialized object.
try:
    event = verify_webhook_signature(
        payload=raw_body,
        signature=request.headers["X-Webhook-Signature"],
        timestamp=request.headers["X-Webhook-Timestamp"],
        secret=os.environ["SENDDY_WEBHOOK_SECRET"],
        # tolerance_seconds=300,  # optional replay window (default 300; 0 disables)
    )
    # event is the parsed payload (typed as `Any`)
except WebhookVerificationError:
    ...  # reject with 400 — do not process

Typed inbound-email webhooks

For the inbound-email webhook (the body Senddy POSTs to your inbound route's webhook_url), use the typed helper to get an InboundEmailEvent TypedDict:

from senddy import (
    verify_inbound_email_webhook,
    WebhookVerificationError,
    InboundEmailEvent,
)

event: InboundEmailEvent = verify_inbound_email_webhook(
    payload=raw_body,
    signature=request.headers["X-Webhook-Signature"],
    timestamp=request.headers["X-Webhook-Timestamp"],
    secret=os.environ["SENDDY_INBOUND_SECRET"],  # per-route signing secret
)

# All fields autocompleted + type-checked under mypy / pyright:
event["schema_version"]              # Literal["2"]
event["from"]                        # str
event["spam_score"]                  # Optional[float]
event["threading"]["message_id"]     # Optional[str]
event["attachments"]                 # list[InboundEmailEventAttachment]

InboundEmailEvent is a TypedDict using the functional form because the contract has a from key (a Python keyword). There is no runtime shape check; the cast trusts the contract. The signature/timestamp/JSON-validity checks are identical to verify_webhook_signature.

Fetching inbound attachment bytes

Inbound webhook events carry attachment metadata only — bytes are fetched on demand via attachments[].download_url, a self-contained capability URL (Senddy-signed JWT in the query string). No SDK helper is needed — follow the URL with any HTTP client:

import httpx

attachment = event["attachments"][0]
bytes_data = httpx.get(attachment["download_url"]).content

# Or stream it:
# with httpx.stream("GET", attachment["download_url"]) as r:
#     for chunk in r.iter_bytes():
#         ...

No Authorization header is required. The URL is valid until attachment["expires_at"] (24h on the free tier, 72h on paid). After that the URL + bytes are both gone — there is no refresh path. If you process attachments from a durable queue, persist download_url along with the event.

Recipient Lists

Recipient allow-lists scope which addresses an API key may send to.

from senddy import CreateRecipientListParams, UpdateRecipientListParams

# Create a list, then list / update / delete
rl = client.recipient_lists.create(
    CreateRecipientListParams(name="Internal", patterns=["*@example.com"])
)

result = client.recipient_lists.list()  # each item includes active_key_count
client.recipient_lists.update(rl.id, UpdateRecipientListParams(patterns=["*@example.com"]))
client.recipient_lists.delete(rl.id)

Billing

from senddy import (
    BillingUsageParams,
    ChangeTierParams,
    CreateCheckoutParams,
    ListBillingHistoryParams,
    UpdateAutoRefillParams,
)

# Reads
balance = client.billing.get_balance()
print(balance.credit_balance, balance.billing_tier)

client.billing.get_history(ListBillingHistoryParams(limit=50, type="deduction"))  # paginated
client.billing.get_payments()  # paginated
client.billing.get_auto_refill()
client.billing.get_tier()  # includes projected invoice on Pro
client.billing.get_usage(BillingUsageParams(days=30))

# Writes
session = client.billing.create_checkout(CreateCheckoutParams(credits=10000))
print(session.checkout_url)
client.billing.update_auto_refill(
    UpdateAutoRefillParams(enabled=True, threshold=1000, amount_cents=2000)
)
client.billing.change_tier(ChangeTierParams(tier="pro"))

Error Handling

from senddy import (
    APIError,
    ValidationError,
    RateLimitError,
    AuthenticationError,
)

try:
    client.emails.send(params)
except ValidationError as err:
    print(f"Validation: {err.details}")
except RateLimitError as err:
    print(f"Rate limited. Retry after {err.retry_after}s")
except AuthenticationError as err:
    print(f"Auth error: {err}")
except APIError as err:
    print(f"API error {err.status_code}: {err}")

Error Types

Class Status Description
ValidationError 400 Invalid request, includes .details list
AuthenticationError 401 Missing or invalid API key
ForbiddenError 403 Insufficient permissions
NotFoundError 404 Resource not found
RateLimitError 429 Rate limit exceeded, includes .retry_after
InternalError 5xx Server error

All error classes inherit from APIError, which inherits from Exception.

Pagination

List methods return a result with .data and .pagination (limit, offset, total, has_more). Page through results by advancing offset:

from senddy import ListEmailsParams

offset, limit = 0, 100
while True:
    result = client.emails.list(ListEmailsParams(limit=limit, offset=offset))
    for email in result.data:
        ...  # process email
    if not result.pagination.has_more:
        break
    offset += len(result.data)

Or let the SDK do the paging for you with list_all, a generator that fetches one page at a time (available on emails, suppressions, domains, and inbound_emails). limit sets the page size; filters are preserved across pages. The async client returns an async iterator:

# Sync
for email in client.emails.list_all(ListEmailsParams(status="delivered", limit=100)):
    ...  # process every delivered email, fetched page by page

# Async
async for email in aclient.emails.list_all(ListEmailsParams(status="delivered")):
    ...

Retries & idempotency

The SDK retries transient failures (HTTP 429, 5xx, network errors, and request timeouts) with exponential backoff and jitter, honoring the Retry-After header. Non-retryable errors (4xx other than 429) are raised immediately. Pass retries=0 to the client to disable retries. The timeout is set by timeout on the client (seconds); override it per request with RequestOptions(timeout=...).

To avoid sending a request twice, only safe requests are retried automatically:

  • Reads (GET/HEAD/OPTIONS) are always retried.
  • Writes (POST/PUT/PATCH/DELETE) are retried only when they carry an Idempotency-Key; otherwise they're attempted exactly once.
  • emails.send() is retry-safe by default — it auto-generates one Idempotency-Key per call and reuses it across that call's retries, so a 429/5xx never produces a duplicate send.

Pass your own key to dedupe a send across separate calls (e.g. a retry from your own task queue) — the server treats a repeat of the same key as the same request:

from senddy import RequestOptions

client.emails.send(params, options=RequestOptions(idempotency_key="order-1234-receipt"))

API coverage

The SDK wraps the full public API surface (every method exists on both Senddy and AsyncSenddy):

  • emailssend, get, list, get_content, download, resend
  • suppressionslist, create, delete, export
  • domainslist, get, create, add_subdomain, verify, delete, regenerate_dkim, check_dns_health
  • inbound_routes, inbound_emails, webhook_endpoints
  • recipient_listslist, create, update, delete
  • billingget_balance, get_history, get_payments, get_auto_refill, get_tier, get_usage, create_checkout, update_auto_refill, change_tier
  • the stateless verify_webhook_signature() + verify_inbound_email_webhook() helpers

See the API reference for every method signature and type.

Requirements

  • Python >= 3.9
  • Runtime dependency: httpx

Type Safety

The package ships with a py.typed marker (PEP 561) and all public types are fully annotated — works with mypy, pyright, and IDE autocompletion out of the box. Response models are generated from Senddy's OpenAPI specification, so they track the API exactly.

License

MIT

Project details


Download files

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

Source Distribution

senddy-0.3.2.tar.gz (51.5 kB view details)

Uploaded Source

Built Distribution

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

senddy-0.3.2-py3-none-any.whl (36.0 kB view details)

Uploaded Python 3

File details

Details for the file senddy-0.3.2.tar.gz.

File metadata

  • Download URL: senddy-0.3.2.tar.gz
  • Upload date:
  • Size: 51.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for senddy-0.3.2.tar.gz
Algorithm Hash digest
SHA256 9b16fc07d97181b139dd37f47552a048a191d793c9726986ac0fd9b760f63573
MD5 7fe3cad4a0ba4d2ab3fd3c40bade98fe
BLAKE2b-256 2ec23a0efd4debaf62b0e06f1c04e5218ccee9e86660f605a5a91fd9d8fe85d0

See more details on using hashes here.

File details

Details for the file senddy-0.3.2-py3-none-any.whl.

File metadata

  • Download URL: senddy-0.3.2-py3-none-any.whl
  • Upload date:
  • Size: 36.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for senddy-0.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 ec4b07d005939afa8891422dd2b998f89baacf5895328894edf24a6822670e91
MD5 eb18f78febfde92baeebffff98117bfc
BLAKE2b-256 0c6254aebbaa320bccd494b0084527ba3718a2518f3639f9845e56eabf0840eb

See more details on using hashes here.

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