Skip to main content

Sync and async Python SDK for the SendStack email SaaS API.

Project description

sendstack

Official Python SDK for the SendStack email SaaS API.

Built on httpx, it ships both a synchronous client (Sendstack) and an asynchronous client (AsyncSendstack) covering the SendStack developer API.

Use it for:

  • transactional and scheduled email
  • batch email
  • reusable attachment uploads
  • sending domains
  • email templates
  • webhook endpoints and webhook event retries
  • suppression lists

Python >=3.11 is required.

Install

pip install sendstack

For local development:

uv sync --extra dev

Quick Start

Sync

import os

from sendstack import Sendstack, RequestOptions

token = os.environ["SENDSTACK_TOKEN"]

client = Sendstack(token)

message = client.emails.send(
    {
        "from": "Noria <hello@example.com>",
        "to": "friend@example.com",
        "subject": "Hello from SendStack",
        "html": "<p>Your email pipeline is working.</p>",
        "text": "Your email pipeline is working.",
    },
    RequestOptions(idempotency_key="welcome-email-1"),
)

print(message["id"], message["status"])

Sendstack opens an httpx.Client for you. Close it when you are done, or use it as a context manager:

with Sendstack(token) as client:
    client.emails.send({"from": "hello@example.com", "to": "friend@example.com", "text": "Hi"})

Async

import asyncio
import os

from sendstack import AsyncSendstack, RequestOptions


async def main() -> None:
    async with AsyncSendstack(os.environ["SENDSTACK_TOKEN"]) as client:
        message = await client.emails.send(
            {
                "from": "Noria <hello@example.com>",
                "to": "friend@example.com",
                "subject": "Hello from SendStack",
                "text": "Your async pipeline is working.",
            },
            RequestOptions(idempotency_key="welcome-email-1"),
        )
        print(message["id"], message["status"])


asyncio.run(main())

Every example below works on both clients. On Sendstack a method returns the decoded payload; on AsyncSendstack the same call returns a coroutine you await.

Base URL

The SDK defaults to https://mailer.norialabs.com, matching the current live API. Override it when you move to the SendStack domain:

client = Sendstack(token, base_url="https://sendstack.norialabs.com")

Docs Split

This README is the package guide: install, initialization, SDK methods, request options, errors, and examples.

The SaaS docs remain the canonical source for product/API behavior: account setup, API tokens, domain verification, DNS records, webhook event catalogs, deliverability concepts, provider behavior, dashboard workflows, and the raw HTTP API reference. Current live SaaS docs are at https://mailer.norialabs.com/api/docs.

Auth

The API uses bearer auth:

Authorization: Bearer <token>

Passing a token as the first argument configures that header automatically:

client = Sendstack("mlr_live_...")

You can also pass a custom auth strategy. Tokens may be static or resolved per request (sync or async callables both work):

from sendstack import BearerAuthStrategy, Sendstack

client = Sendstack(
    base_url="https://mailer.norialabs.com",
    auth=BearerAuthStrategy(token=lambda context: get_fresh_token()),
)

Use HeadersAuthStrategy when the target environment expects custom headers, and RequestOptions(authenticated=False) to strip auth for a single call.

Method Reference

SDK method HTTP route Returns
attachments.upload(payload, options=None) POST /attachments UploadedAttachment
emails.send(payload, options=None) POST /emails SendEmailResult
emails.send_batch(payload, options=None) POST /emails/batch SendEmailBatchResult
emails.list(options=None, *, limit=None, cursor=None, status=None) GET /emails CursorPage
emails.get(message_id, options=None) GET /emails/{id} EmailMessage
emails.events(message_id, options=None) GET /emails/{id}/events CursorPage
emails.cancel(message_id, options=None) POST /emails/{id}/cancel EmailMessage
emails.requeue(message_id, options=None) POST /emails/{id}/requeue EmailMessage
domains.create(payload, options=None) POST /domains Domain
domains.list(options=None) GET /domains CursorPage
domains.get(domain_id, options=None) GET /domains/{id} Domain
domains.verify(domain_id, options=None) POST /domains/{id}/verify Domain
templates.create(payload, options=None) POST /templates EmailTemplate
templates.list(options=None) GET /templates CursorPage
templates.get(template_id, options=None) GET /templates/{id} EmailTemplate
templates.update(template_id, payload, options=None) PATCH /templates/{id} EmailTemplate
templates.remove(template_id, options=None) DELETE /templates/{id} None
webhooks.create(payload, options=None) POST /webhook-endpoints WebhookEndpoint
webhooks.list(options=None) GET /webhook-endpoints CursorPage
webhooks.update(webhook_id, payload, options=None) PATCH /webhook-endpoints/{id} WebhookEndpoint
webhooks.remove(webhook_id, options=None) DELETE /webhook-endpoints/{id} None
webhook_events.retry(event_id, options=None) POST /events/{id}/retry RetryWebhookEventResult
suppressions.add(payload, options=None) POST /suppressions CreateSuppressionResult
suppressions.list(options=None) GET /suppressions CursorPage
suppressions.remove(recipient, options=None) DELETE /suppressions/{recipient} None

webhook_events is also available as webhookEvents, and emails.send_batch as emails.sendBatch, as convenience camelCase aliases.

Methods take a plain dict payload and return plain dict responses (the {"ok": true, "data": ...} envelope is unwrapped for you). The exported model types — EmailMessage, Domain, WebhookEndpoint, and friends — are optional typing aids you can annotate with.

Emails

client.emails.send(
    {
        "from": "hello@example.com",
        "to": ["a@example.com", "b@example.com"],
        "reply_to": "support@example.com",
        "subject": "Welcome",
        "html": "<p>Hello</p>",
        "text": "Hello",
        "tags": [{"name": "campaign", "value": "welcome"}],
        "metadata": {"account": "acct_123"},
        "track_opens": True,
        "track_clicks": True,
    }
)

to, cc, bcc, and reply_to accept a single address or a list.

Batch sends accept either a list or {"emails": [...]}:

client.emails.send_batch(
    [
        {"from": "hello@example.com", "to": "a@example.com", "subject": "One", "text": "First"},
        {"from": "hello@example.com", "to": "b@example.com", "subject": "Two", "text": "Second"},
    ]
)

List, fetch, inspect events, then cancel or requeue:

page = client.emails.list(limit=25, status="queued")
message = client.emails.get("msg_123")
events = client.emails.events("msg_123")
client.emails.cancel("msg_123")
client.emails.requeue("msg_123")

Scheduled email

scheduled_at accepts an ISO-8601 string or a datetime (serialized to ISO-8601 with a Z suffix):

from datetime import datetime, timezone

client.emails.send(
    {
        "from": "hello@example.com",
        "to": "friend@example.com",
        "subject": "Later",
        "text": "Scheduled.",
        "scheduled_at": datetime(2026, 3, 28, 9, 0, tzinfo=timezone.utc),
    }
)

Attachments

attachment = client.attachments.upload(
    {
        "filename": "invoice.pdf",
        "content_base64": invoice_pdf_base64,
        "content_type": "application/pdf",
    }
)

client.emails.send(
    {
        "from": "billing@example.com",
        "to": "customer@example.com",
        "subject": "Invoice",
        "text": "Attached.",
        "attachments": [
            {"filename": "invoice.pdf", "attachment_id": attachment["attachment_id"]},
        ],
    }
)

Domains

domain = client.domains.create(
    {
        "domain": "example.com",
        "region": "af-south-1",
        "tls": "enforced",
        "capabilities": {"sending": "enabled"},
    }
)

client.domains.verify(domain["id"])

Templates

template = client.templates.create(
    {
        "name": "Welcome",
        "slug": "welcome",
        "subject": "Welcome, {{firstName}}",
        "html": "<p>Hello {{firstName}}</p>",
        "text": "Hello {{firstName}}",
    }
)

client.emails.send(
    {
        "from": "hello@example.com",
        "to": "friend@example.com",
        "template": {"id": template["id"], "variables": {"firstName": "Amina"}},
    }
)

Webhooks

endpoint = client.webhooks.create(
    {
        "url": "https://example.com/webhooks/sendstack",
        "event_types": ["email.sent", "email.failed"],
    }
)

client.webhook_events.retry("event_123")
client.webhooks.update(endpoint["id"], {"enabled": False})

Suppressions

client.suppressions.add({"recipient": "bad@example.com", "reason": "manual"})

suppressions = client.suppressions.list()
client.suppressions.remove("bad@example.com")

Field Aliases

Python users write idiomatic snake_case, which is exactly the wire format for email, attachment, webhook, and domain fields — reply_to, track_opens, track_clicks, provider_id, template_id, template_data, scheduled_at, content_base64, content_type, attachment_id, content_id, event_types, custom_return_path. No translation needed.

The camelCase aliases (replyTo, trackOpens, trackClicks, providerId, templateId, templateData, scheduledAt, contentBase64, attachmentId, eventTypes, …) are also accepted and converted to the wire field names. Everything else is passed through unchanged, so the SDK stays forward-compatible with new API fields.

Request Options

All methods accept a RequestOptions. Mutating methods also honor idempotency_key.

from sendstack import RequestOptions

client.emails.send(
    {
        "from": "hello@example.com",
        "to": "friend@example.com",
        "subject": "Hello",
        "text": "Hello",
    },
    RequestOptions(
        idempotency_key="email-123",
        timeout_seconds=10.0,
        query={"debug": True},
        headers={"x-tenant-id": "tenant_123"},
    ),
)

Supported options:

  • headers: extra headers (merged over constructor headers)
  • query: per-request query params (merged over constructor query)
  • timeout_seconds: request timeout, default 30.0
  • authenticated: set False to strip auth headers for a request
  • auth: a BearerAuthStrategy / HeadersAuthStrategy, or False
  • retry: a RetryOptions, an attempt count, or False
  • middleware: request/response middleware (runs after constructor middleware)
  • parse_response: custom response parser
  • transform_response: custom response transformer
  • unwrap_data: unwrap {"ok": true, "data": ...} envelopes, default True
  • client: a per-request httpx client
  • idempotency_key: sets the Idempotency-Key header
  • body: raw body for request(...)

Retries

Retries are off by default (a single attempt). Enable them per request or on the client:

from sendstack import RequestOptions, RetryOptions

client.emails.list(
    RequestOptions(retry=RetryOptions(max_attempts=3, delay_seconds=0.5))
)

# Or just an attempt count:
client.emails.list(RequestOptions(retry=3))

Default retry behavior:

  • retries network exceptions, unless they are already a SendstackError
  • retries responses only for 408, 425, 429, 500, 502, 503, 504
  • uses a short exponential backoff when no custom delay_seconds is given

RetryOptions accepts delay_seconds and should_retry as values or callables (sync or async).

Custom httpx Clients

Inject your own httpx.Client or httpx.AsyncClient:

import httpx

from sendstack import Sendstack

client = Sendstack("mlr_live_...", client=httpx.Client(timeout=5.0))

If you inject a client, the SDK uses it but does not close it for you.

Middleware

from sendstack import Sendstack


def add_sdk_header(context, next_call):
    context.headers["x-sdk"] = "sendstack-python"
    return next_call(context)


client = Sendstack("mlr_live_...", middleware=[add_sdk_header])

Middleware can mutate headers, rewrite the final URL, or short-circuit a request by returning a response context without calling next_call.

Lower-Level Request

Every resource method uses request(...) internally. Use it directly for new API routes before the SDK grows a typed wrapper:

from sendstack import RequestOptions

result = client.request("GET", "/emails", RequestOptions(query={"limit": 25, "status": "queued"}))

Pass RequestOptions(unwrap_data=False) if you need the raw {"ok", "data"} envelope.

Errors

Failed responses raise SendstackError.

from sendstack import SendstackError

try:
    client.emails.send({"from": "hello@example.com", "to": "bad", "subject": "Hi", "text": "Hi"})
except SendstackError as error:
    print(error.status_code, error.code, error.message, error.details)

SendstackError includes:

  • status_code
  • code
  • details
  • response_body
  • message

It understands SendStack envelopes ({"ok": false, "error": {...}}) as well as FastAPI-style {"detail": "..."} bodies.

Exports

Clients and errors:

  • Sendstack, AsyncSendstack
  • SendstackClient, AsyncSendstackClient (aliases)
  • SendstackError
  • DEFAULT_BASE_URL

Auth, options, and machinery:

  • BearerAuthStrategy, HeadersAuthStrategy, SendstackAuthStrategy
  • RequestOptions, RetryOptions
  • SendstackMiddleware, ResponseParser, ResponseTransformer
  • SendstackRequestContext, SendstackResponseContext, SendstackRetryContext

Model types (optional typing aids):

  • Recipient, SendstackTag, TemplateReference
  • SendEmailRequest, SendEmailResult, SendEmailBatchResult
  • EmailMessage, EmailEvent, EmailStatus
  • UploadAttachmentRequest, UploadedAttachment
  • CreateDomainRequest, Domain, DomainRegion, DomainTlsPolicy, DomainCapability
  • CreateTemplateRequest, UpdateTemplateRequest, EmailTemplate
  • CreateWebhookEndpointRequest, UpdateWebhookEndpointRequest, WebhookEndpoint
  • WebhookEventType, KnownWebhookEvent
  • RetryWebhookEventResult
  • CreateSuppressionRequest, CreateSuppressionResult, Suppression, SuppressionReason
  • CursorPage

Development

uv sync --extra dev
uv run ruff check .
uv run pytest
uv build

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

sendstack-0.1.1.tar.gz (30.9 kB view details)

Uploaded Source

Built Distribution

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

sendstack-0.1.1-py3-none-any.whl (19.7 kB view details)

Uploaded Python 3

File details

Details for the file sendstack-0.1.1.tar.gz.

File metadata

  • Download URL: sendstack-0.1.1.tar.gz
  • Upload date:
  • Size: 30.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sendstack-0.1.1.tar.gz
Algorithm Hash digest
SHA256 db5221a640e20b84bb96f39ff9ab09e1062832243d1b6c640189ac1c5ad67a71
MD5 06caade4e10be426687d66d9c618d9dd
BLAKE2b-256 a2f7551d3d18f43b4726ee19829e71919bc27a90c05a8ee607a476e40c8fad58

See more details on using hashes here.

Provenance

The following attestation bundles were made for sendstack-0.1.1.tar.gz:

Publisher: ci.yml on thekiharani/py-packages

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

File details

Details for the file sendstack-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: sendstack-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 19.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sendstack-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 1faff7b9a57713b426ce3ba734efc9faf0a05637e7d8b4c24ae0bfd3622814b7
MD5 bcd68a10ca99b2587e4a3ee13d8c985b
BLAKE2b-256 1142c78aa6aad8d40eacb13ae728e19acf7f3557b2ed9cee5a791de77e1652c6

See more details on using hashes here.

Provenance

The following attestation bundles were made for sendstack-0.1.1-py3-none-any.whl:

Publisher: ci.yml on thekiharani/py-packages

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