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)
millionsend.allow_insecure_http = False          # accept a non-loopback http:// base_url
  • 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.
  • Plain http:// is only accepted for loopback hosts (localhost, 127.0.0.1, ::1); any other http:// URL raises MillionSendError on the first call, since the API key is sent as a bearer header. Set millionsend.allow_insecure_http = True to talk to a non-TLS instance elsewhere (e.g. inside a private network).

Request/response casing: request params are plain dicts in the API's snake_case (reply_to, scheduled_at, first_name) and are sent to the wire as given — nothing is filtered or renamed. Responses are dict subclasses that also allow attribute access (resp.id, resp.data[0].id).

List methods take keyword arguments (list(limit=50, after=cursor)) or resend-python's params dict (list({"limit": 50, "after": cursor})).

Request options

Emails.send, Batch.send and Contacts.Batch.create take resend-python's options dict as the second argument, or the same values as keywords:

millionsend.Emails.send(payload, {"idempotency_key": "order-42"})
millionsend.Emails.send(payload, idempotency_key="order-42")

millionsend.Batch.send(payloads, {"idempotency_key": "batch-1", "batch_validation": "permissive"})
millionsend.Batch.send(payloads, batch_validation="permissive")
  • idempotency_key → Idempotency-Key header (POST only). A replay with the same key and body returns the original ids; a different body raises InvalidIdempotentRequestError.
  • batch_validation → x-batch-validation header: "strict" (default) rejects the whole batch on the first invalid item; "permissive" processes the valid items and lists the rest in the response's errors[] ({index, message}).

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: MissingApiKeyError, InvalidApiKeyError, ValidationError, InvalidParameterError, InvalidPayloadError, PayloadTooLargeError, NotFoundError, ConflictError, ForbiddenError, RestrictedApiKeyError, SendingPausedError, RateLimitExceededError, DailyQuotaExceededError, PlanLimitReachedError, InvalidIdempotentRequestError, ConcurrentIdempotentRequestsError, InternalServerError, ApplicationError. Unknown names raise the base MillionSendError.

Resources

Emails

millionsend.Emails.send({
    "from": "Acme <onboarding@acme.dev>",
    "to": ["ada@acme.dev"],
    "cc": "ops@acme.dev",
    "bcc": ["audit@acme.dev"],
    "reply_to": "support@acme.dev",
    "subject": "Your receipt",
    "html": "<p>Thanks!</p>",
    "text": "Thanks!",
    "scheduled_at": "in 2 hours",                        # or ISO 8601 with offset
    "tags": [{"name": "category", "value": "receipt"}],
    "topic_id": topic.id,                                # skip recipients opted out of the topic
    "headers": {"X-Entity-Ref-ID": "order-42"},
    "attachments": [{
        "filename": "receipt.pdf",
        "content": base64_pdf,                           # base64 string
        "content_type": "application/pdf",               # optional
        "content_id": "receipt",                         # optional, for cid: references
    }],
}, idempotency_key="order-42")

millionsend.Emails.get(email_id)                              # GET /emails/{id} (includes a nullable 0-10 `score`)
millionsend.Emails.list(limit=50, after=cursor)               # GET /emails
millionsend.Emails.update({"id": email_id, "scheduled_at": "2026-09-01T09:00:00Z"})  # PATCH, scheduled only
millionsend.Emails.cancel(email_id)                           # POST /emails/{id}/cancel (scheduled only)
millionsend.Emails.remove(email_id)                           # DELETE /emails/{id}
millionsend.Emails.get_insights(email_id)                     # GET /emails/{id}/insights (404 until computed)

millionsend.Batch.send([payload_a, payload_b], batch_validation="permissive")  # up to 100; see `errors`

to / cc / bcc / reply_to accept a string or a list of strings. template is passed through too; the server answers 422 until templates can be sent from.

Contacts

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

millionsend.Contacts.create({
    "email": "ada@acme.dev",
    "first_name": "Ada",
    "last_name": "Lovelace",
    "unsubscribed": False,
    "properties": {"plan": "pro", "seats": 3},
    "segments": [{"id": segment.id}],
    "topics": [{"id": topic.id, "subscription": "opt_in"}],
})
millionsend.Contacts.get(email="ada@acme.dev")  # by id or email (email wins)
millionsend.Contacts.get("contact-id")          # bare id works too, as does resend's id="contact-id"
millionsend.Contacts.update({"id": "contact-id", "unsubscribed": True, "first_name": None})  # None clears
millionsend.Contacts.update({"email": "ada@acme.dev", "properties": {"plan": None}})       # None removes the key
millionsend.Contacts.remove(email="ada@acme.dev")
millionsend.Contacts.list(limit=50)
millionsend.Contacts.list(segment_id=segment.id)  # GET /segments/{id}/contacts

# Bulk create (MillionSend extension) — up to 1000 per call
result = millionsend.Contacts.Batch.create(
    [{"email": "a@acme.dev"}, {"email": "b@acme.dev", "first_name": "B"}],
    on_conflict="upsert",            # error (default) | skip | upsert
    batch_validation="permissive",   # strict (default) | permissive
)
result.data[0].status  # created | updated | skipped
result.counts.failed
result.errors          # permissive mode: [{index, message}]

# Segment membership — mirrors resend's contacts.segments
millionsend.Contacts.Segments.add({"contact_id": "contact-id", "segment_id": segment.id})
millionsend.Contacts.Segments.remove({"email": "ada@acme.dev", "segment_id": segment.id})

# 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.

Contact properties

Property definitions for the properties map on contacts.

prop = millionsend.ContactProperties.create({"key": "plan", "type": "string", "fallback_value": "free"})
millionsend.ContactProperties.list()
millionsend.ContactProperties.get(prop.id)
millionsend.ContactProperties.update({"id": prop.id, "fallback_value": None})  # None clears
millionsend.ContactProperties.remove(prop.id)

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.update(topic_id, {"name": "Product news", "visibility": "public"})
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({
    "name": "September launch",   # internal, optional
    "segment_id": segment.id,     # optional
    "topic_id": topic.id,         # optional
    "from": "Acme <news@acme.dev>",
    "reply_to": "support@acme.dev",
    "subject": "Launch",
    "preview_text": "It's here",
    "html": "<p>Hi {{{FIRST_NAME|there}}}</p>",
    "text": "Hi there",
    "send": False,                # True sends (or schedules) instead of saving a draft
    "scheduled_at": "in 1 hour",  # with send: True
})
millionsend.Broadcasts.list()
millionsend.Broadcasts.get(broadcast.id)
millionsend.Broadcasts.update(broadcast.id, {"subject": "Launch 🚀", "topic_id": None})  # draft only; None clears
millionsend.Broadcasts.update({"broadcast_id": broadcast.id, "subject": "Launch"})        # resend-python shape
millionsend.Broadcasts.send(broadcast.id, scheduled_at="2026-09-01T09:00:00Z")  # omit to send now
millionsend.Broadcasts.send({"broadcast_id": broadcast.id})                      # resend-python shape
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": [   # optional; omit or None = every contact
        {"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)

Suppressions

Addresses the API refuses to send to. Addressable by id or email.

millionsend.Suppressions.add({"email": "bounced@acme.dev", "origin": "manual"})  # origin: bounce | complaint | manual | unsubscribe
millionsend.Suppressions.get("bounced@acme.dev")
millionsend.Suppressions.list(origin="bounce", limit=50)
millionsend.Suppressions.remove("bounced@acme.dev")

millionsend.Suppressions.Batch.add({"emails": ["a@acme.dev", "b@acme.dev"], "origin": "unsubscribe"})  # up to 1000
millionsend.Suppressions.Batch.remove({"emails": ["a@acme.dev"]})   # or {"ids": [...]}

Suppressions.create is an alias of add.

Domains

domain = millionsend.Domains.create({
    "name": "acme.dev",
    "region": "us-east-1",           # optional; must match the deployment's SES region
    "custom_return_path": "send",    # optional
    "open_tracking": True,           # optional
    "click_tracking": True,          # optional
    "tracking_subdomain": "links",   # optional; links.acme.dev
})
for record in domain.records:        # DNS records to publish
    print(record.type, record.name, record.value)

millionsend.Domains.list()
millionsend.Domains.get(domain.id)
millionsend.Domains.verify(domain.id)
millionsend.Domains.update({"id": domain.id, "open_tracking": False, "tracking_subdomain": None})
millionsend.Domains.remove(domain.id)

Webhooks

webhook = millionsend.Webhooks.create({
    "endpoint": "https://acme.dev/hooks/millionsend",
    "events": ["email.delivered", "email.bounced", "email.complained"],
    "signing_secret": "whsec_...",   # optional: reuse a secret instead of minting one
})
webhook.signing_secret               # also returned by get()

millionsend.Webhooks.list()
millionsend.Webhooks.get(webhook.id)
millionsend.Webhooks.update({"webhook_id": webhook.id, "status": "disabled"})  # endpoint, events, status
millionsend.Webhooks.remove(webhook.id)

API keys

key = millionsend.ApiKeys.create({"name": "ci", "permission": "sending_access", "domain_id": domain.id})
key.token                            # shown once
millionsend.ApiKeys.list()
millionsend.ApiKeys.remove(key.id)

Templates

Addressable by id or alias.

template = millionsend.Templates.create({
    "name": "Welcome",
    "alias": "welcome-v1",           # optional, unique per team
    "subject": "Welcome aboard",     # optional
    "html": "<p>Hi {{{FIRST_NAME}}}</p>",
    "text": "Hi",                    # optional
})
millionsend.Templates.get("welcome-v1")
millionsend.Templates.list()
millionsend.Templates.update({"id": "welcome-v1", "subject": None, "alias": None})  # None clears
millionsend.Templates.duplicate(template.id)
millionsend.Templates.publish(template.id)   # templates are always published; kept for resend compatibility
millionsend.Templates.remove(template.id)

Resend's from, reply_to and variables are forwarded as given; the server answers 422 for them until templates model them.

Usage (MillionSend extension)

usage = millionsend.Usage.get()
usage.plan                     # None when self-hosted
usage.limits.emails_per_day    # None = unlimited
usage.today.emails_sent

Deliverability (MillionSend extension)

Per-email best-practice insights and an account-level deliverability score — no Resend equivalent.

insights = millionsend.Emails.get_insights(email.id)  # raises NotFoundError until computed
print(insights.score, insights.band)                  # 8.5 "excellent"
for check in insights.checks:
    print(check.id, check.status, check.penalty)

account = millionsend.Deliverability.get()            # trailing-30-day account score
print(account.score, account.band, account.guardrail_status)  # scores are None until enough data

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:

  • Same resources: Emails, Batch, Contacts (with .Topics, .Segments), ContactProperties, Topics, Broadcasts, Suppressions (with .Batch), Domains, Webhooks, ApiKeys, Templates. Payloads are sent verbatim, so a resend-python payload works as-is.
  • No audiences: contacts are team-global, so there is no Audiences resource and no audience_id params. The API's /audiences/... routes are a compatibility shim for raw HTTP callers and are deliberately not exposed here. Resend's Segments is an alias of audiences; MillionSend's Segments is the distinct dynamic-filter feature.
  • MillionSend extensions (no Resend equivalent): Segments, Contacts.Batch, Contacts.list(segment_id=...), Usage, Deliverability, Emails.get_insights.
  • Not available: Resend's ApiKeys.update, Webhooks event history/replay/verify, Emails.share / Emails.metrics / receiving, Broadcasts.recipients / clicked_links, Contacts.Segments.list, DomainClaims, ContactImports, Automations, Events, Logs, OAuthGrants, and the *_async variants.
  • MillionSend raises on API errors just like resend; the exception carries .code / .status_code / .message.

License

MIT

Metadata

Release files for millionsend 0.4.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.4.0
File Size Uploaded
millionsend-0.4.0.tar.gz 22.8 kB Details

Built distribution (wheel)

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

Total release size: 44.8 kB

Release files / millionsend-0.4.0.tar.gz

Download URL millionsend-0.4.0.tar.gz
Size 22.8 kB
Tags Source
SHA-256 checksum
How to use checksums
e1dbbcf246e243346fa768e95cabf5ab6e4cb027e31dc77ddc934101405a8e26
BLAKE2b-256 checksum
How to use checksums
5e3740020ac032186c144ba0d49a84f2aa75f47586dd31e24b4f33abcca9e5b7
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 Sep 4, 2026.

Transparency log

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

Download URL millionsend-0.4.0-py3-none-any.whl
Size 22.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4c713a99675722e4769105ca93d9743f56d4cd30d25988723ed7b265152d817c
BLAKE2b-256 checksum
How to use checksums
dd3988aa68d0719cb1666baac0950700cfe0c7cad98409e3e6312d065d822b80
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 Sep 4, 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

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

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