senddart
Official Python SDK for the SendDart email API — send transactional and marketing email from your own verified domain.
Zero dependencies (Python standard library only). Python 3.8+.
Install
pip install senddart
Setup
Grab your API key from the SendDart dashboard.
import senddart
senddart.api_key = "mb_xxxxxxxxx"
Send your first email
import senddart
senddart.api_key = "mb_xxxxxxxxx"
params: senddart.Emails.SendParams = {
"from": "Acme <hello@yourdomain.com>",
"to": ["delivered@test.senddart.com"],
"subject": "Hello from SendDart",
"html": "<p>Your first email 🎉</p>",
}
email = senddart.Emails.send(params)
print(email["id"])
Every method returns the parsed JSON response — except the three binary
download helpers (Emails.Receiving.get_attachment, Emails.Receiving.get_raw
and Domains.records_csv), which return bytes. On any non-2xx status the SDK
raises senddart.SendDartError carrying the API error body:
try:
senddart.Emails.send(params)
except senddart.SendDartError as e:
print(e.status_code, e.name, e.message) # e.g. 422 validation_error "..."
Attachments
Attach files by hosted URL (path, fetched at send time) or inline base64 (content):
senddart.Emails.send({
"from": "Acme <hello@yourdomain.com>",
"to": ["delivered@test.senddart.com"],
"subject": "Your invoice",
"html": "<p>Invoice attached.</p>",
"attachments": [
{"filename": "invoice.pdf", "path": "https://yourdomain.com/invoices/invoice.pdf"},
{"filename": "report.csv", "content": base64_content, "content_type": "text/csv"},
],
})
Batch send
senddart.Batch.send([
{"from": "hello@yourdomain.com", "to": ["delivered@test.senddart.com"], "subject": "Hi A", "html": "<p>A</p>"},
{"from": "hello@yourdomain.com", "to": ["delivered@test.senddart.com"], "subject": "Hi B", "html": "<p>B</p>"},
]) # up to 100 emails per request
A batch succeeds in one of two ways, chosen by its size alone — branch on
queued, not on the absence of an error:
| Batch size | Status | Response | What happened |
|---|---|---|---|
| 1–40 | 200 |
no queued key at all — never queued: False |
every id in data is already handed to the mail service |
| 41–100 | 202 |
queued: True, queued_count == len(result["data"]) |
ids are real, but the emails are still scheduled — nothing has been transmitted yet |
result = senddart.Batch.send(payloads)
if result.get("queued"):
# Accepted, not sent. Poll senddart.Emails.get(id) for the outcome.
...
Queuing is the only way the documented 100-email maximum can be accepted at all:
100 inline sends run past the platform's request ceiling. A batch carrying an
@test.senddart.com simulator recipient in to, cc or bcc stays inline at any
size. An inline batch near the 40-email boundary can take ~100s server-side, far
past the 30s default senddart.timeout — raise it for batches that large, and
always pass an idempotency_key, since a client that gives up mid-request cannot
tell what was already sent.
Options
senddart.base_url = "https://www.senddart.com/api" # override your API host
senddart.timeout = 30 # per-request timeout in seconds (default 30)
senddart.max_retries = 2 # auto-retry 429/503 responses (default 2; 0 disables)
Requests time out after 30 seconds by default. Automatic retries consider only HTTP 429 and 503 and honor Retry-After
(with capped exponential backoff otherwise). Partial or uncertain results stop
immediately. Generic 503 responses retry only reads or a send protected by an
operation key; other writes need a documented rejection before processing.
Network errors, timeouts, 409, 422, and other 5xx responses are never retried.
See Recovery and tracking contracts below before retrying in application code.
Errors
SendDartError carries the {statusCode, name, message} envelope. Match on
name and read status_code — messages are scrubbed server-side and a few
handlers override the status a name usually maps to, so neither is safe to
hard-code. Extra fields ride along on the exception:
try:
senddart.Emails.send(params)
except senddart.SendDartError as e:
if e.name == "daily_quota_exceeded":
print(e.limit["used"], e.limit["limit"], e.limit["next_plan"])
if e.retry_after:
time.sleep(e.retry_after)
print(e.body) # the full parsed error body
try:
senddart.Batch.send(payloads, options={"idempotency_key": "batch-1"})
except senddart.SendDartError as e:
already_sent = e.sent # [{"id": ...}, ...] — do NOT resend these
print(e.sent_count)
The domain-first model
SendDart is DOMAIN-FIRST: each of your verified sending domains has its own contact pool — the same address on two domains is two records with separate consent. That means:
Contactstake adomain(required to create/list on the flat/contactsAPI; disambiguates an email id on get/update/remove).SegmentsandTopicsbelong to a domain (domainrequired on create and list).Campaigns.createREQUIRESdomain— it picks the contact pool the campaign targets (thefromaddress may be a different verified domain).Automations.createREQUIRESdomain, andEvents.sendREQUIRESdomain— only automations belonging to that domain are triggered, so the same event name (e.g.user.created) across several products can never double-fire.
Resources
Each resource is a class with methods following a consistent
create / get / list / update / remove shape (plus resource-specific verbs):
Emails (with nested Emails.Receiving), Batch,
Domains, Audiences, Contacts, ContactProperties, Campaigns,
Segments, Topics, Templates, Automations, Webhooks, Events,
ApiKeys (list only — see below), Logs, Polls.
# Emails
senddart.Emails.send(params)
senddart.Emails.list({"limit": 20, "after": cursor}) # cursor pagination
senddart.Emails.list({"status": "bounced", "search": "acme.com"}) # filters
senddart.Emails.list({"folder": "sent"}) # outbox / sent / scheduled / failed — any other value 422s
senddart.Emails.get(email_id)
senddart.Emails.update(email_id, {"scheduled_at": "2026-08-01T09:00:00Z"}) # reschedule
senddart.Emails.cancel(email_id)
senddart.Emails.sources() # per-campaign/automation send metrics
senddart.Emails.list_attachments(email_id)
senddart.Emails.get_attachment(email_id, attachment_id)
# Inbound email
senddart.Emails.Receiving.list()
senddart.Emails.Receiving.list_addresses() # per-address inbound stats
senddart.Emails.Receiving.get(email_id)
senddart.Emails.Receiving.list_attachments(email_id)
senddart.Emails.Receiving.get_attachment(email_id, attachment_id) # -> bytes
senddart.Emails.Receiving.get_raw(email_id) # -> bytes (RFC822)
senddart.Emails.Receiving.forward(email_id, {"from": "me@yourdomain.com", "to": "delivered@test.senddart.com"})
senddart.Emails.Receiving.reply(email_id, {"from": "me@yourdomain.com", "html": "<p>Thanks!</p>"})
senddart.Emails.Receiving.remove(email_id)
# Domains (incl. claiming a domain verified elsewhere + one-click DNS)
senddart.Domains.create({"name": "yourdomain.com"})
senddart.Domains.verify(domain_id)
senddart.Domains.claim({"name": "yourdomain.com"})
senddart.Domains.verify_claim(domain_id)
senddart.Domains.detect_dns(domain_id)
senddart.Domains.apply_cloudflare_dns(domain_id, {"token": cf_token})
senddart.Domains.mx_check("yourdomain.com") # live MX lookup
senddart.Domains.records_csv(domain_id) # -> bytes (text/csv)
# Contacts (domain-first)
senddart.Contacts.create({"domain": "yourdomain.com", "email": "user@example.com", "first_name": "Ada"})
senddart.Contacts.list({"domain": "yourdomain.com"})
senddart.Contacts.get({"id": contact_id}) # by id (exact) …
senddart.Contacts.get({"id": "user@example.com", "domain": "yourdomain.com"}) # … or by email + domain
senddart.Contacts.update({"id": contact_id, "unsubscribed": True})
senddart.Contacts.remove({"id": contact_id})
senddart.Contacts.batch({"audience_id": aud_id, "contacts": [{"email": "a@b.com"}]})
# Domain-first: import straight into a domain's pool, no audience id needed.
senddart.Contacts.batch({"domain": "yourdomain.com", "contacts": [{"email": "a@b.com"}]})
senddart.Contacts.import_csv({"audience_id": aud_id, "csv": "email,company\na@b.com,Acme"})
senddart.Contacts.create_import_upload({"audience_id": aud_id, "filename": "big.csv", "size": 90_000_000})
senddart.Contacts.add_to_segment(contact_id, segment_id)
senddart.Contacts.list_segments(contact_id)
senddart.Contacts.update_topics(contact_id, {"topics": [{"id": topic_id, "subscription": "opt_in"}]})
# Contact properties (custom fields / merge tags)
senddart.ContactProperties.create({"key": "plan", "type": "string"})
# Campaigns & Segments (domain-first)
senddart.Campaigns.create({"domain": "yourdomain.com", "from": sender, "subject": subject, "html": html})
senddart.Campaigns.send(campaign_id, {"scheduled_at": "tomorrow at 9am"})
senddart.Campaigns.stats(campaign_id)
senddart.Campaigns.engagement(campaign_id) # who opened / clicked / replied
senddart.Campaigns.ab(campaign_id)
senddart.Segments.create({"domain": "yourdomain.com", "name": "VIP", "filter": {"status": "subscribed"}})
senddart.Segments.list({"domain": "yourdomain.com"})
senddart.Segments.contacts(segment_id) # preview who matches
# Topics (domain-first)
senddart.Topics.create({"domain": "yourdomain.com", "name": "Product updates", "default_subscription": "opt_in"})
senddart.Topics.list({"domain": "yourdomain.com"})
# Templates
senddart.Templates.create({"name": "Welcome", "subject": "Hi {{first_name}}", "html": html})
senddart.Templates.duplicate(template_id)
senddart.Templates.publish(template_id)
senddart.Emails.send({"from": sender, "to": to, "template_id": tmpl_id, "variables": {"first_name": "Ada"}})
# Audiences
senddart.Audiences.list()
senddart.Audiences.import_sheet(audience_id, {"url": sheet_url})
# API keys (listing only — creating, re-scoping and revoking is dashboard-only)
senddart.ApiKeys.list()
# Logs & Polls
senddart.Logs.list({"limit": 100, "method": "POST", "status": 429})
senddart.Logs.get(log_id)
senddart.Polls.list()
senddart.Polls.get(email_id)
API keys are managed in the dashboard
ApiKeys.list() is the whole surface: the SDK deliberately exposes no method to
create, re-scope or revoke a key. Key lifecycle belongs to a signed-in dashboard
session, and the API enforces it — POST /api-keys, PATCH /api-keys/:id and
DELETE /api-keys/:id answer 403 dashboard_only to any API-key caller,
whatever its permission. That is the point: a key that leaks cannot mint itself
a replacement, widen its own access, or revoke the keys you would use to shut it
off. Create and revoke keys at
senddart.com instead.
Automations & Events
Every automation belongs to one of your sending domains — domain is required
on create, and Events.send names the domain it targets.
automation = senddart.Automations.create({
"name": "Welcome series",
"domain": "yourdomain.com",
"trigger": "contact.created",
})
senddart.Automations.add_step(automation["id"], {
"type": "send_email",
"config": {"template_id": "tmpl_welcome"},
})
senddart.Automations.update(automation["id"], {"status": "enabled"})
# Fire a custom event — only yourdomain.com's automations are triggered
senddart.Events.send({
"event": "signup.completed",
"domain": "yourdomain.com",
"email": "delivered@test.senddart.com",
"payload": {"plan": "pro"},
})
senddart.Events.create({"name": "signup.completed", "schema": {"plan": "string"}})
senddart.Events.update(event_id, {"schema": {"plan": "string", "seats": "number"}})
# Inspect execution
runs = senddart.Automations.runs(automation["id"], {"limit": 25, "status": "failed"})
senddart.Automations.get_run(automation["id"], runs["data"][0]["id"])
senddart.Automations.stop(automation["id"])
The step graph is edited while the automation is disabled —
add_step / update_step / delete_step (and changing domain, trigger or
connections) all 422 on an enabled automation.
Automations.create_with_ai builds or extends the graph from a prompt:
senddart.Automations.create_with_ai(automation["id"], {"prompt": "Wait 2 days, then send the welcome email"})
Webhooks
hook = senddart.Webhooks.create({
"endpoint": "https://yourapp.com/hooks/senddart", # must be https://
"events": ["email.delivered", "email.bounced", "email.unsubscribed"],
})
signing_secret = hook["signing_secret"] # shown ONCE, only here
senddart.Webhooks.list()
senddart.Webhooks.update(hook["id"], {"status": "disabled"})
senddart.Webhooks.rotate(hook["id"]) # new secret, returned once
senddart.Webhooks.test(hook["id"])
Verify incoming deliveries locally (no HTTP request) — pass the EXACT raw
request body string, the svix-* headers, and your signing secret:
result = senddart.Webhooks.verify(raw_body, request.headers, signing_secret)
if not result["valid"]:
abort(401) # result["reason"] says why, e.g. 'no_match'
Pagination
list() methods accept optional cursor pagination — {"limit", "after", "before"}.
limit is an integer 1–100 (default 20); after and before are item ids and
cannot be combined. Responses are {"object": "list", "has_more": bool, "data": [...]} —
there is no total and no next_cursor, so page forward with the last
data[-1]["id"]:
page = senddart.Campaigns.list({"limit": 25})
while page["has_more"]:
page = senddart.Campaigns.list({"limit": 25, "after": page["data"][-1]["id"]})
Called with no pagination params, most list endpoints return the whole
collection up to a 1,000-item ceiling (Campaigns, Contacts, Segments,
ContactProperties, Domains, ApiKeys, Topics, Polls, and the nested
contact/segment/topic lists) — past that the response is truncated and
has_more is True, so keep paging rather than trusting one call to be
complete. Audiences, Automations, Automations.runs, Templates,
Webhooks and Events cap at 20 instead — pass limit explicitly when it
matters.
An unknown cursor is not an error: it returns an empty page with
has_more: False.
Idempotency
Pass an idempotency key to safely retry a send — replaying the same key returns the original response instead of sending twice:
senddart.Emails.send(params, options={"idempotency_key": "order-123"})
senddart.Batch.send(payloads, options={"idempotency_key": "orders-2026-08-08"})
- The key must be 1 to 255 characters — measured after the server trims it,
so 255, not 256 (
senddart.IDEMPOTENCY_KEY_MAX_LENGTH). The SDK sends the key verbatim and lets the server be the authority: an out-of-range key comes back as aSendDartErrorwithname == "invalid_idempotency_key"(400). Emails.send,Batch.send, and received-email reply/forward honour it. Every other endpoint — includingEvents.send— accepts and forwards the header but the API ignores it, so a retry there creates a second resource. De-duplicate on your side instead.- Reusing a key with a different body raises
invalid_idempotent_request(409); reusing it while the first request is still running raisesconcurrent_idempotent_requests(409).
Documentation
Full docs: https://www.senddart.com/docs
License
MIT
Recovery and tracking contracts
Use a stable, unique operation key for each intended send, batch, reply, or forward. Keep the same key and payload when recovering that operation. These are the supported idempotent send endpoints; events do not implement this header. Existing calls without options still work.
senddart.Emails.Receiving.reply(email_id, reply, {"idempotency_key": "reply-operation-1"})
senddart.Emails.Receiving.forward(email_id, forward, {"idempotency_key": "forward-operation-1"})
health = senddart.Domains.tracking_health(domain_id)
Automatic retries consider only 429/503. They stop on an original email id,
positive sent_count, nonempty sent or reserved, or batch_incomplete.
An ordinary rate limit can retry; a generic 503 can retry a read or a send with
the same supported key. Other writes retry only documented pre-processing
rejections (service_unavailable, sending_service_unavailable,
sending_configuration_unavailable, contacts_busy, contacts_timeout).
No network/body-read failure, 409, 422, or other 5xx is retried automatically.
The default transport refuses redirects; a custom transport/client must enforce
its own policy.
On a failed or unconfirmed send, inspect id with the email retrieval method
before creating another send. A 422 with an ID can identify an uncertain
provider handoff; 422 does not always mean nothing happened. For interrupted
batches, sent contains confirmed sends, reserved contains the original
attempted prefix (including uncertain handoffs), and unsent_count counts the
never-attempted tail. Do not resend the full batch or the reserved prefix under
a new key. Reconcile original IDs first, then submit only known unattempted
items as a new operation. Recovery fields remain available in the full error
body as well as language-specific fields/accessors.
Tracking health returns custom_host, status (shared, ready, or
unavailable), and checked_at. Configure custom tracking through the domain
API and check health before relying on it. A healthy endpoint cannot guarantee
an open event: recipients may block images, and coupon redemption alone is not
proof that the tracking pixel loaded. SDKs preserve supplied HTML/text and do
not infer opens or rewrite editor spacing.
Campaign cancellation also stops pending follow-ups for an already-sent campaign while retaining its sent history. Permanent received-email deletion acknowledges a durable cleanup request; attachment/object cleanup can finish asynchronously. Retrying that deletion is safe; it cannot be undone after the purge request is accepted.
Release files for senddart 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| senddart-1.0.0.tar.gz | 60.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| senddart-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 104.9 kB
Release files / senddart-1.0.0.tar.gz
| Download URL | senddart-1.0.0.tar.gz |
|---|---|
| Size | 60.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ef5c6058bf02cc768d112cac55bc6a995f049820db929a41a75f706208d5521c
|
|
BLAKE2b-256 checksum How to use checksums |
ceb020321c6a7c896129503b2316b4b65f318ecb88c12e577ce8564eb2840141
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / senddart-1.0.0-py3-none-any.whl
| Download URL | senddart-1.0.0-py3-none-any.whl |
|---|---|
| Size | 44.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3426edc748ee211f672a984b0f0ae7ea7863f2e03847e6f7af3f832499a96989
|
|
BLAKE2b-256 checksum How to use checksums |
e845eb5e7f3b94db634549253d82d637b8afed7480ce6796afa48cc3c4583214
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|