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.

Contacts

Contacts are team-global — one record per email address, no audiences to manage.

millionsend.Contacts.create({
    "email": "ada@acme.dev",
    "first_name": "Ada",
    "properties": {"plan": "pro"},
})
millionsend.Contacts.get(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(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"}],
})

Creating a contact whose email already exists on the team (case-insensitive) answers 409 and raises ValidationError.

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

Target a saved segment (segment_id) and/or a topic (topic_id); set neither to send to every contact.

broadcast = millionsend.Broadcasts.create({
    "segment_id": segment.id,  # optional
    "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 the team's contacts — a MillionSend feature with no Resend equivalent.

segment = millionsend.Segments.create({
    "name": "Pro plan",
    "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.
  • No audiences: contacts are team-global, so there is no Audiences resource and no audience_id params. Resend's Segments is an alias of audiences; MillionSend's Segments is the distinct dynamic-filter feature.
  • MillionSend raises on API errors just like resend; the exception carries .code / .status_code / .message.

License

MIT

Metadata

Release files for millionsend 0.2.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.2.0
File Size Uploaded
millionsend-0.2.0.tar.gz 11.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for millionsend 0.2.0
File Interpreter ABI Platform
millionsend-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 22.8 kB

Release files / millionsend-0.2.0.tar.gz

Download URL millionsend-0.2.0.tar.gz
Size 11.3 kB
Tags Source
SHA-256 checksum
How to use checksums
9ed9b1d60ea219bcd7214a25be7e36be9461db2166de4705dfb1a8d2d179e08e
BLAKE2b-256 checksum
How to use checksums
7803c43d8455aecf884ce497a010ec788a931eca49ab87f1f0c0ad6aba3c466c
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 17, 2026.

Transparency log

Release files / millionsend-0.2.0-py3-none-any.whl

Download URL millionsend-0.2.0-py3-none-any.whl
Size 11.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6609729579abb7dfec079c4bb080f5d630f88f34322496249a7c7b9605d12870
BLAKE2b-256 checksum
How to use checksums
54561bda7311b1aa04bf6f1c4657da918a9abf30dc1698c428b2d5ae603e1db9
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 17, 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

This release

0.2.0 This release

2 release files

0.1.0

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