Sync and async Python SDK for Sendstack and fully compatible APIs.
Project description
sendstack
Official Python SDK for Sendstack messaging APIs.
sendstack is built on httpx and gives you sync and async clients for:
- developer API email, SMS, WhatsApp, and health endpoints
- merchant/control-plane messaging routes, including group email
- custom auth, retries, middleware, and injected
httpxclients - raw
request(...)access for endpoints not yet wrapped by a helper
Install
pip install sendstack
Python requirement: >=3.11
For local development:
uv sync --extra dev
Shared Setup
The examples below assume:
BASE_URL = "https://sendstack.noria.co.ke/api/v1"
API_KEY = "sk_live_example_secret"
MERCHANT_TOKEN = "control-plane-token"
MERCHANT_ID = "merchant_123"
Change BASE_URL once if your Sendstack host changes.
Quick Start
Developer API
from sendstack import Mailer, RequestOptions
client = Mailer(
API_KEY,
base_url=BASE_URL,
)
email_message = client.emails.send(
{
"from": "Noria Demo <mail@noria.co.ke>",
"to": ["hello@example.com"],
"reply_to": ["support@noria.co.ke", "ops@noria.co.ke"],
"subject": "Hello from Sendstack",
"html": "<p>Your <strong>SDK</strong> is working.</p>",
"text": "Your SDK is working.",
},
RequestOptions(idempotency_key="welcome-email-1"),
)
sms_quote = client.sms.quote(
{
"from": "SENDSTACK",
"to": "+254722111222",
"text": "Hello from SMS",
}
)
whatsapp_message = client.whatsapp.send(
{
"from": "WABA",
"to": "+254733000333",
"text": "Hello from WhatsApp",
}
)
print(email_message["id"], email_message["status"])
print(sms_quote["estimated_units"])
print(whatsapp_message["id"], whatsapp_message["status"])
Merchant API
from sendstack import BearerAuthStrategy, Mailer, RequestOptions
merchant_client = Mailer(
base_url=BASE_URL,
auth=BearerAuthStrategy(token=MERCHANT_TOKEN),
)
group_quote = merchant_client.merchant.emails.quote_group(
MERCHANT_ID,
{
"from": "sender@example.com",
"to": ["a@example.com", "b@example.com"],
"cc": "finance@example.com",
"subject": "Monthly update",
"html": "<p>Hello from the control plane</p>",
"text": "Hello from the control plane",
},
)
group_send = merchant_client.merchant.emails.send_group(
MERCHANT_ID,
{
"from": "sender@example.com",
"to": ["a@example.com", "b@example.com"],
"reply_to": ["support@example.com", "ops@example.com"],
"subject": "Monthly update",
"html": "<p>Queued from the control plane</p>",
"text": "Queued from the control plane",
},
RequestOptions(idempotency_key="merchant-group-send-1"),
)
print(group_quote["estimated_units"])
print(group_send["recipient_count"])
Async
import asyncio
from sendstack import AsyncMailer, RequestOptions
async def main() -> None:
async with AsyncMailer(
API_KEY,
base_url=BASE_URL,
) as client:
email_message = await client.emails.send(
{
"from": "Noria Demo <mail@noria.co.ke>",
"to": ["hello@example.com"],
"subject": "Hello",
"html": "<p>World</p>",
"text": "World",
},
RequestOptions(idempotency_key="async-email-1"),
)
print(email_message["id"])
asyncio.run(main())
How To Read The Examples
clientThe main Sendstack SDK client you use to call the API.client.emailsThe email part of the API.client.smsThe SMS part of the API.client.whatsappThe WhatsApp part of the API.client.merchantMerchant and control-plane routes on a Sendstack client.quote(...)Ask Sendstack for the estimated units before you send a message.send(...)Queue a message for delivery.get(...)Fetch one message by its ID.list(...)Fetch many messages, usually with filters or pagination.healthEndpoints that tell you whether the Sendstack service is up.- variables like
email_message,sms_quote, andgroup_sendThese are just local Python variable names holding API responses.
Choose A Client
MailerSync client backed byhttpx.ClientAsyncMailerAsync client backed byhttpx.AsyncClient
Use AsyncMailer when your app already runs async code. Use Mailer when you need synchronous calls.
Auth
Developer API Auth
When you pass a non-empty api_key, sendstack sends:
X-API-Key: <api_key>
That matches the current Sendstack developer API.
from sendstack import Mailer
client = Mailer(
API_KEY,
base_url=BASE_URL,
)
Merchant And Control-Plane Auth
Merchant routes usually need a custom auth strategy instead of an API key.
from sendstack import BearerAuthStrategy, Mailer
merchant_client = Mailer(
base_url=BASE_URL,
auth=BearerAuthStrategy(token=MERCHANT_TOKEN),
)
You can also use HeadersAuthStrategy when the target environment expects custom headers.
If you are not using API-key auth, you can omit api_key entirely.
Per-Request Overrides
All helpers and raw requests accept RequestOptions. That lets you override auth, headers, timeout, retry, and the httpx client for a single call.
Developer API
Emails
Available methods:
client.emails.quote(payload, options=None)client.emails.send(payload, options=None)client.emails.get(email_id, options=None)client.emails.list(options=None, *, limit=None, cursor=None, per_page=None, status=None)
Example:
quote = client.emails.quote(
{
"from": "sender@example.com",
"to": ["recipient@example.com"],
"subject": "Welcome",
"html": "<p>Hello from Sendstack</p>",
"text": "Hello from Sendstack",
}
)
email_message = client.emails.send(
{
"from": "sender@example.com",
"to": ["recipient@example.com"],
"reply_to": ["support@example.com", "help@example.com"],
"subject": "Welcome",
"html": "<p>Hello from Sendstack</p>",
"text": "Hello from Sendstack",
}
)
listing = client.emails.list(limit=10, status="queued")
Email content rules:
- provide at least one of
html,text, orattachments htmlis optionaltextis optional- using both
htmlandtextis usually the best default - the single-email route still requires exactly one
torecipient to,cc,bcc, andreply_tomay be passed as a string or a list
SMS
Available methods:
client.sms.quote(payload, options=None)client.sms.send(payload, options=None)client.sms.get(message_id, options=None)client.sms.list(options=None, *, limit=None, cursor=None, per_page=None, status=None)
Example:
quote = client.sms.quote(
{
"from": "SENDSTACK",
"to": "+254722111222",
"text": "Hello from SMS",
}
)
sms_message = client.sms.send(
{
"from": "SENDSTACK",
"to": "+254722111222",
"text": "Queued SMS",
}
)
Available methods:
client.whatsapp.quote(payload, options=None)client.whatsapp.send(payload, options=None)client.whatsapp.get(message_id, options=None)client.whatsapp.list(options=None, *, limit=None, cursor=None, per_page=None, status=None)
Example:
quote = client.whatsapp.quote(
{
"from": "WABA",
"to": "+254733000333",
"text": "Hello from WhatsApp",
}
)
whatsapp_message = client.whatsapp.send(
{
"from": "WABA",
"to": "+254733000333",
"text": "Queued WhatsApp",
}
)
Template send example:
whatsapp_message = client.whatsapp.send(
{
"from": "WABA",
"to": "+254733000333",
"template_id": "11111111-1111-1111-1111-111111111111",
"template_variables": {"first_name": "Mercy"},
}
)
WhatsApp rules:
- send plain text with
text - send a template message with
template_id - use
template_variablesonly with template sends - the SDK accepts
template_variablesand normalizes it to the API fieldvariables
Health
Available methods:
client.health.live(options=None)client.health.check(options=None)client.health.ready(options=None)
These helpers target Sendstack's root-scoped /livez, /healthz, and /readyz endpoints even when your base_url ends in /api/v1.
Merchant API
Messages
Available methods:
client.merchant.messages.list(merchant_id, options=None, *, limit=None, cursor=None, per_page=None, channel=None, status=None)client.merchant.messages.get(merchant_id, message_id, options=None)
Example:
messages = merchant_client.merchant.messages.list(
MERCHANT_ID,
limit=20,
channel="email",
status="queued",
)
Merchant Email
Available methods:
client.merchant.emails.quote(merchant_id, payload, options=None)client.merchant.emails.send(merchant_id, payload, options=None)client.merchant.emails.quote_group(merchant_id, payload, options=None)client.merchant.emails.send_group(merchant_id, payload, options=None)
Compatibility aliases:
client.merchant.emails.quoteGroup(...)client.merchant.emails.sendGroup(...)
Group email example:
group_send = merchant_client.merchant.emails.send_group(
MERCHANT_ID,
{
"from": "sender@example.com",
"to": ["a@example.com", "b@example.com"],
"cc": "finance@example.com",
"reply_to": ["support@example.com", "ops@example.com"],
"subject": "Monthly update",
"html": "<p>Queued group email</p>",
"text": "Queued group email",
},
RequestOptions(idempotency_key="merchant-group-email-1"),
)
Merchant group email rules:
tomay be a string or a list- the backend deduplicates recipients across
to,cc, andbcc - use merchant group email for current Sendstack multi-recipient email sends
Merchant SMS
Available methods:
client.merchant.sms.quote(merchant_id, payload, options=None)client.merchant.sms.send(merchant_id, payload, options=None)
Merchant WhatsApp
Available methods:
client.merchant.whatsapp.quote(merchant_id, payload, options=None)client.merchant.whatsapp.send(merchant_id, payload, options=None)
Pagination And Idempotency
List helpers support:
cursorlimitper_pageAlias forlimit
Sendstack list responses are returned unchanged and currently use:
itemsnext_cursorhas_morelimit
Use RequestOptions(idempotency_key="...") on send requests when you want Sendstack idempotency protection.
Request Options
All helpers and raw requests accept RequestOptions.
from sendstack import RequestOptions
message = client.emails.send(
{
"from": "sender@example.com",
"to": "hello@example.com",
"subject": "Hello",
"html": "<p>Hello</p>",
"text": "Hello",
"scheduled_at": "2026-03-28T09:00:00.000Z",
},
RequestOptions(
headers={"x-tenant-id": "tenant_123"},
timeout_seconds=10.0,
idempotency_key="tenant-123-send-1",
),
)
Supported options:
headersquerytimeout_secondsauthenticatedauthretrymiddlewareparse_responsetransform_responseunwrap_dataclientidempotency_keybody
Merge rules:
- request headers merge over constructor headers
- request query params merge over constructor query params
- request middleware runs after constructor middleware
- request-level
client,timeout_seconds,auth,retry,parse_response, andtransform_responsereplace constructor values
Customization
Custom httpx Clients
You can inject your own httpx.Client or httpx.AsyncClient:
import httpx
from sendstack import Mailer
http_client = httpx.Client(timeout=5.0)
client = Mailer(
API_KEY,
base_url=BASE_URL,
client=http_client,
)
If you inject your own client, sendstack will use it but will not close it for you.
Retry
from sendstack import RequestOptions, RetryOptions
result = client.emails.list(
RequestOptions(
retry=RetryOptions(
max_attempts=2,
delay_seconds=0,
)
)
)
Default retry behavior:
- retries network exceptions by default unless they are already
MailerError - retries responses only for
408,425,429,500,502,503,504 - uses a short exponential backoff when you enable retries without a custom delay
Middleware
from sendstack import Mailer
def add_sdk_header(context, next_call):
context.headers["x-sdk"] = "sendstack"
return next_call(context)
client = Mailer(
API_KEY,
base_url=BASE_URL,
middleware=[add_sdk_header],
)
Middleware can:
- mutate headers
- rewrite the final URL
- short-circuit a request by returning a response without calling
next
Raw Requests
Use request(...) when you want direct access to an endpoint that does not yet have a helper:
from sendstack import RequestOptions
result = client.request(
"POST",
"/reports/export",
RequestOptions(
body={"format": "csv"},
headers={"x-request-id": "req_123"},
),
)
Custom Response Parsing
By default:
- successful
{ok: true, data: ...}responses are unwrapped todata - plain JSON responses are returned unchanged
- non-2xx responses raise
MailerError
You can customize parsing and transformation:
result = client.request(
"GET",
"/metrics",
RequestOptions(
parse_response=lambda response, _context: response.headers.get("x-total"),
transform_response=lambda context: {
"total": int(context.payload),
"status": context.response.status_code,
},
),
)
Use RequestOptions(unwrap_data=False) if you need the raw {ok, data} envelope.
Errors
sendstack raises MailerError for API-level failures.
It understands both:
- Sendstack-style FastAPI errors such as
{"detail": "..."} - older mailer-style error envelopes such as
{ok: false, error: {...}}
Useful fields on MailerError:
status_codecodedetailsresponse_body
Example:
from sendstack import MailerError
try:
client.emails.send(...)
except MailerError as error:
print(error.status_code)
print(error.code)
print(error.details)
Python-Friendly Field Aliases
The SDK keeps wire payloads API-shaped, but it accepts these Python aliases:
reply_to -> replyToscheduled_at -> scheduledAtconfiguration_set_name -> configurationSetNametenant_name -> tenantNameendpoint_id -> endpointIdfeedback_forwarding_email_address -> feedbackForwardingEmailAddressfeedback_forwarding_email_address_identity_arn -> feedbackForwardingEmailAddressIdentityArnfrom_email_address_identity_arn -> fromEmailAddressIdentityArnlist_management_options -> listManagementOptionscontact_list_name -> contactListNametopic_name -> topicNamecontent_type -> contentTypecontent_id -> contentIdcontent_disposition -> dispositiontemplate_variables -> variablesexpires_at -> expiresAt
Everything else is passed through as provided so the SDK stays forward-compatible with new API fields.
Compatibility Appendix
The SDK also includes helpers for older fully mailer-compatible APIs. These are not part of the current public Sendstack API surface:
client.emails.send_batch(payloads, options=None)client.emails.sendBatch(payloads, options=None)client.domainsclient.api_keysclient.apiKeysclient.webhooks
Use them only when the target API actually exposes those routes.
Development
Install development dependencies:
uv sync --extra dev
Run lint:
uv run ruff check .
Run tests:
uv run pytest
Build the package:
uv build
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sendstack-0.1.0.tar.gz.
File metadata
- Download URL: sendstack-0.1.0.tar.gz
- Upload date:
- Size: 30.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1b58faeb3f992a4751f5978fca576fd481719beb5222973e05baaa6c4e64812
|
|
| MD5 |
545df3c88db60f278df4df71c580e3ee
|
|
| BLAKE2b-256 |
8211a6994a53b07c3d8cf80d909e942bcbcaf6099bf7059fb8a7234b851c4220
|
Provenance
The following attestation bundles were made for sendstack-0.1.0.tar.gz:
Publisher:
ci.yml on thekiharani/py-packages
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sendstack-0.1.0.tar.gz -
Subject digest:
e1b58faeb3f992a4751f5978fca576fd481719beb5222973e05baaa6c4e64812 - Sigstore transparency entry: 1287599450
- Sigstore integration time:
-
Permalink:
thekiharani/py-packages@0832298989d1c6b79e0b4e045c00bb5967d40551 -
Branch / Tag:
refs/heads/release - Owner: https://github.com/thekiharani
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@0832298989d1c6b79e0b4e045c00bb5967d40551 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sendstack-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sendstack-0.1.0-py3-none-any.whl
- Upload date:
- Size: 17.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
301a5fcb4ecbccf893ec22bd0552706a48b370ab9a7e7cc58da5c27bd612bbd0
|
|
| MD5 |
f0086a9cb13717d2e6a39e1afa766cfe
|
|
| BLAKE2b-256 |
f3893b4698128b87e4fb2998bcc926125636739404281c39ed4098728b7793cd
|
Provenance
The following attestation bundles were made for sendstack-0.1.0-py3-none-any.whl:
Publisher:
ci.yml on thekiharani/py-packages
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sendstack-0.1.0-py3-none-any.whl -
Subject digest:
301a5fcb4ecbccf893ec22bd0552706a48b370ab9a7e7cc58da5c27bd612bbd0 - Sigstore transparency entry: 1287599500
- Sigstore integration time:
-
Permalink:
thekiharani/py-packages@0832298989d1c6b79e0b4e045c00bb5967d40551 -
Branch / Tag:
refs/heads/release - Owner: https://github.com/thekiharani
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@0832298989d1c6b79e0b4e045c00bb5967d40551 -
Trigger Event:
push
-
Statement type: