Skip to main content

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_key falls back to MILLIONSEND_API_KEY. Missing key raises MissingApiKeyError on the first call.
  • base_url falls back to MILLIONSEND_BASE_URL, then http://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.code is the stable name discriminant (validation_error, not_found, restricted_api_key, sending_paused, invalid_idempotent_request, …).
  • e.status_code is the HTTP status, or None for 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 / ApiKeys resources here.
  • Resend's Segments is an alias of audiences; MillionSend's Segments is the distinct dynamic-filter feature. Use Audiences for 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)

Source distribution for millionsend 0.1.0
File Size Uploaded
millionsend-0.1.0.tar.gz 11.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for millionsend 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

This release

0.1.0 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page