millionsend
Official Python SDK for MillionSend — a self-hostable, Resend-compatible email API on AWS SES.
The API is wire-compatible with Resend and this SDK deliberately mirrors the shape of the resend PyPI package, so migrating is mostly a find-and-replace: swap the import, set base_url to your instance.
Install
pip install millionsend
Requires Python 3.9+. Depends only on requests.
Quickstart
import millionsend
millionsend.api_key = "ms_123"
millionsend.base_url = "https://mail.acme.dev" # your instance
email = millionsend.Emails.send({
"from": "Acme <onboarding@acme.dev>",
"to": "delivered@resend.dev",
"subject": "Hello from MillionSend",
"html": "<strong>It works!</strong>",
})
print(email.id) # responses support both email["id"] and email.id
Configuration
Config is module-level (no client object to construct):
import millionsend
millionsend.api_key = "ms_123" # or env MILLIONSEND_API_KEY
millionsend.base_url = "https://mail.acme.dev" # or env MILLIONSEND_BASE_URL
millionsend.timeout = 30 # optional, seconds (default 60)
api_keyfalls back toMILLIONSEND_API_KEY. Missing key raisesMissingApiKeyErroron the first call.base_urlfalls back toMILLIONSEND_BASE_URL, thenhttp://localhost:3001. MillionSend is self-hosted, so set this to your deployment in production.
Request/response casing: request params are plain dicts in the API's snake_case (reply_to, scheduled_at, first_name). Responses are dict subclasses that also allow attribute access (resp.id, resp.data[0].id).
Errors
Every call raises on a non-2xx response. The base is MillionSendError; known error names map to subclasses so you can catch them:
from millionsend import NotFoundError, MillionSendError
try:
contact = millionsend.Contacts.get(email="ghost@acme.dev")
except NotFoundError:
... # 404
except MillionSendError as e:
print(e.code, e.status_code, e.message)
e.codeis the stablenamediscriminant (validation_error,not_found,restricted_api_key,sending_paused,invalid_idempotent_request, …).e.status_codeis the HTTP status, orNonefor client-side/transport failures (connection refused, DNS, timeout).
Subclasses: ValidationError, NotFoundError, RestrictedApiKeyError, SendingPausedError, InvalidIdempotentRequestError, ApplicationError, MissingApiKeyError.
Resources
Emails
millionsend.Emails.send(payload, idempotency_key="order-42") # POST /emails
millionsend.Emails.get(email_id) # GET /emails/{id}
millionsend.Emails.cancel(email_id) # POST /emails/{id}/cancel (scheduled only)
millionsend.Batch.send([payload_a, payload_b], idempotency_key="batch-1") # up to 100
to / cc / bcc / reply_to accept a string or a list of strings.
Audiences & contacts
audience = millionsend.Audiences.create({"name": "Registered users"})
millionsend.Audiences.list(limit=20)
millionsend.Audiences.get(audience.id)
millionsend.Audiences.remove(audience.id)
millionsend.Contacts.create({
"audience_id": audience.id,
"email": "ada@acme.dev",
"first_name": "Ada",
"properties": {"plan": "pro"},
})
millionsend.Contacts.get(audience_id=audience.id, email="ada@acme.dev") # by id or email (email wins)
millionsend.Contacts.get("contact-id") # bare id works too
millionsend.Contacts.update({"id": "contact-id", "unsubscribed": True, "first_name": None}) # None clears
millionsend.Contacts.remove(email="ada@acme.dev")
millionsend.Contacts.list(audience_id=audience.id, limit=50)
# Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics.update
millionsend.Contacts.Topics.update({
"email": "ada@acme.dev",
"topics": [{"id": "topic-id", "subscription": "opt_out"}],
})
Reads, updates, and deletes also work without an audience (top-level /contacts). Creation always requires audience_id — the API answers 422 without it.
Topics
millionsend.Topics.create({"name": "Product updates", "default_subscription": "opt_in"})
millionsend.Topics.get(topic_id)
millionsend.Topics.list() # bare {"data": [...]} — topics are unpaginated
millionsend.Topics.remove(topic_id)
Broadcasts
broadcast = millionsend.Broadcasts.create({
"audience_id": audience.id,
"from": "Acme <news@acme.dev>",
"subject": "Launch",
"html": "<p>Hi {{{FIRST_NAME|there}}}</p>",
})
millionsend.Broadcasts.list()
millionsend.Broadcasts.get(broadcast.id)
millionsend.Broadcasts.update(broadcast.id, {"subject": "Launch 🚀"}) # draft only
millionsend.Broadcasts.send(broadcast.id, scheduled_at="2026-09-01T09:00:00Z") # omit to send now
millionsend.Broadcasts.cancel(broadcast.id) # scheduled only
millionsend.Broadcasts.remove(broadcast.id) # draft only
Segments (MillionSend extension)
Dynamic segments are a saved filter over an audience's contacts — a MillionSend feature with no Resend equivalent (the wire path is /segments2).
segment = millionsend.Segments.create({
"name": "Pro plan",
"audience_id": audience.id,
"filter": {"match": "all", "conditions": [
{"field": "property:plan", "op": "equals", "value": "pro"},
]},
})
millionsend.Segments.get(segment.id) # includes a live contact_count
millionsend.Segments.list()
millionsend.Segments.update(segment.id, {"name": "Pro tier"})
millionsend.Segments.remove(segment.id)
Migrating from Resend
- import resend
- resend.api_key = "re_123"
+ import millionsend
+ millionsend.api_key = "ms_123"
+ millionsend.base_url = "https://mail.acme.dev"
- resend.Emails.send({...})
+ millionsend.Emails.send({...})
Method names and payloads match. Notes:
- Domains and API keys are managed in the MillionSend dashboard, not via the API, so there are no
Domains/ApiKeysresources here. - Resend's
Segmentsis an alias of audiences; MillionSend'sSegmentsis the distinct dynamic-filter feature. UseAudiencesfor a straight port. - MillionSend raises on API errors just like
resend; the exception carries.code/.status_code/.message.
License
MIT
Metadata
Release files for millionsend 0.1.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 | |
|---|---|---|---|
| millionsend-0.1.0.tar.gz | 11.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| millionsend-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.8 kB
Release files / millionsend-0.1.0.tar.gz
| Download URL | millionsend-0.1.0.tar.gz |
|---|---|
| Size | 11.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4a4179e2771a990b0dcb4d5e18c25cdbcadb8bacb33315fe2e7b2247395ca66c
|
|
BLAKE2b-256 checksum How to use checksums |
facbb876510c79ea4ce0170c5b636e4d211ff3f5b1f6728e885223af300ae74d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logRelease files / millionsend-0.1.0-py3-none-any.whl
| Download URL | millionsend-0.1.0-py3-none-any.whl |
|---|---|
| Size | 12.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e150075ee35fce328dc25cbd57215ee6c7359357e5cf80212656d2cf6cdb664f
|
|
BLAKE2b-256 checksum How to use checksums |
fb99659923f472ac766ecf61e48a707f514408c61a4b1f2cb7470a0893404a22
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency log