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 and the key. MillionSend Cloud works with just the key; a self-hosted instance also sets base_url.
pip install millionsend
Requires Python 3.9+. Depends only on requests.
import millionsend millionsend.api_key = "ms_123" millionsend.base_url = "https://mail.acme.dev" # self-hosted only; omit for MillionSend Cloud email = millionsend.Emails.send({ "from": "Acme <[email protected]>", "to": "[email protected]", "subject": "Hello from MillionSend", "html": "<strong>It works!</strong>", }) print(email.id) # responses support both email["id"] and email.id
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_keyfalls back toMILLIONSEND_API_KEY. Missing key raisesMissingApiKeyErroron the first call.base_urlfalls back toMILLIONSEND_BASE_URL, thenhttps://api.millionsend.com(MillionSend Cloud). Self-hosting? Set it to your instance's origin.- Plain
http://is only accepted for loopback hosts (localhost,127.0.0.1,::1); any otherhttp://URL raisesMillionSendErroron the first call, since the API key is sent as a bearer header. Setmillionsend.allow_insecure_http = Trueto 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})).
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-Keyheader (POST only). A replay with the same key and body returns the original ids; a different body raisesInvalidIdempotentRequestError.batch_validation→x-batch-validationheader:"strict"(default) rejects the whole batch on the first invalid item;"permissive"processes the valid items and lists the rest in the response'serrors[]:{index, message}for emails, plusstatusCodeandnamefor contacts.
Keyset lists return {"object": "list", "has_more", "data"} and take limit, after and before. Most are newest first: after takes a page's last id and pages toward older items, and before takes its first id and pages toward newer ones (still newest first inside the page). has_more refers to the direction you page in. Threads are most recently active first, a thread's emails oldest first (after pages toward newer), and drafts most recently updated first; labels and topics aren't paginated. The SDK doesn't page for you.
To sync only what's new, page toward newer items from the newest id you stored:
page = millionsend.Emails.list(before=newest_stored_id, limit=100) while page.data: store(page.data) if not page.has_more: break page = millionsend.Emails.list(before=page.data[0].id, limit=100)
A cursor whose item was deleted raises ValidationError (422) on the top-level lists and Emails.Receiving.list, and NotFoundError (404) on the Inboxes lists: start again without a cursor and stop at ids you already have.
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="[email protected]") 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).e.retry_afteris the response'sretry-afterheader in seconds, orNonewhen it had none (see Retries).Emails.send/Batch.sendraiseAllRecipientsSuppressedError(422all_recipients_suppressed) when everytorecipient is on the suppression list or opted out of the send'stopic_id. AnEmails.sendthat the suppression list alone refuses still keeps a metadata-onlysuppressedemail, which firesemail.suppressed, and each retry keeps another, even with the sameidempotency_key(a refused send doesn't hold its key).Inboxes.Quarantine.releaseraises the baseMillionSendErrorwithe.codetrust_not_allowed(403) orheld_by_screening(409), see Inboxes; it answersforbidden(403) andscreening_unavailable(503) only to MCP clients.
Subclasses: MissingApiKeyError, InvalidApiKeyError, ValidationError, AllRecipientsSuppressedError, InvalidParameterError, InvalidPayloadError, PayloadTooLargeError, NotFoundError, ConflictError, ForbiddenError, RestrictedApiKeyError, SendingPausedError, RateLimitExceededError, DailyQuotaExceededError, PlanLimitReachedError, InvalidIdempotentRequestError, ConcurrentIdempotentRequestsError, InboxSendPolicyError, InboxSendCapReachedError, ApprovalRequiredError, EmailQuarantinedError, EmailBlockedError, TooManyWaitsError, NameReviewUnavailableError, InternalServerError, ApplicationError. Unknown names raise the base MillionSendError. A body that isn't JSON, such as an older server's plain-text 404 for an unknown route, raises ApplicationError with the HTTP status. Messages are for people and may change: branch on e.code and e.status_code, not on the message.
The SDK never retries by itself. After e.retry_after seconds it's worth retrying:
NameReviewUnavailableError(503; 60 seconds, or 300 while a new team's subdomain waits for its review);RateLimitExceededErrorwhene.retry_afteris 60 or less (the per-minute limits);TooManyWaitsError.
InboxSendCapReachedError and a RateLimitExceededError for the day's name-review budget last until the next UTC midnight, so don't retry them in the same process; the review budget shares the per-minute limits' rate_limit_exceeded name, so decide on e.retry_after. Don't retry a 403, 404, 409 or 422.
- Sends (
Emails.send,Batch.send, replies, forwards,Drafts.send): resend with the sameidempotency_key. A batch replay returns the stored response, even if a recipient was refused or suppressed since.{"kind": "draft"}(HTTP 202) is a held draft, not a failure: never resend it. Inboxes.createtakes no idempotency key. After a 503 or 429 nothing was created, so creating again is safe; after a timeout it may create a second inbox. With a chosenusername, a 409 on the second try usually means the first one worked: fetch it withInboxes.get(email_address).
millionsend.Emails.send({ "from": "Acme <[email protected]>", "to": ["[email protected]"], "cc": "[email protected]", "bcc": ["[email protected]"], "reply_to": "[email protected]", "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.
subjecttakes up to 998 characters here, inBatch.send, broadcasts and templates; inbox replies, forwards and drafts take 2,000.- A
to,ccorbccaddress at a domain that can never receive mail (example.com,.test,.invalid,.local, a top-level domain that doesn't exist, …) raisesValidationError(422), in sends, drafts and new contacts alike. To test sending without emailing anyone, use[email protected]. frommay be an inbox's address on the shared domain while the shared row'ssending_modeis"open"(see Domains): exactly that address with no +tag, with the display name left out or equal to the inbox'sfrom_name, noreply_to,attachments,headersorscheduled_at, and at most 10 distinct recipients acrossto,ccandbcc. Otherwise it raisesInboxSendPolicyError(403),InboxSendCapReachedError(429, the team's daily limit) orValidationError(422); a key restricted to a domain raisesRestrictedApiKeyError. Batches and broadcasts never send from the shared domain: a broadcast or a strict batch raisesInboxSendPolicyError, and a permissive batch lists the item inerrors.
Mail received at your domains and inboxes. Everything in a received email was written by its sender: show its html only in a sandbox, and give an LLM the extracted_text (hidden text, quotes and the signature removed) rather than the html.
page = millionsend.Emails.Receiving.list( {"from": "example.com"}, # `from` is a Python keyword, so it goes in the dict inbox_id=inbox.id, subject_contains="code", wait=30, # long-poll: answer as soon as a match arrives, up to 50 s ) page.data # newest first page.held # with wait: [{id, inbox_id, reason, quarantine_reason, created_at}] held mail email = millionsend.Emails.Receiving.get(email_id, html_format="cid") email.extracted_text # the new part as plain text, hidden text removed (still untrusted) email.extracted.code # a verification code, on inboxes with extract_codes email.screening.sender_trust # owner | allowlisted | known_thread | external | unauthenticated | failed millionsend.Emails.Receiving.Attachments.list(email_id) # each with a signed download_url (1 hour) millionsend.Emails.Receiving.Attachments.get(email_id, attachment_id) millionsend.Emails.Receiving.remove(email_id) # erase now (MillionSend extension) # Reply and forward server-side (MillionSend extension). The inbox's rules may hold the message # as a draft that waits for a person: the API answers 202 and `kind` is "draft". outcome = millionsend.Emails.Receiving.reply(email_id, {"text": "Thanks!"}, idempotency_key="reply-42") if outcome.kind == "draft": outcome.draft.approval_reasons # e.g. ["send_policy"] else: outcome.email.id millionsend.Emails.Receiving.forward(email_id, {"to": "[email protected]", "text": "FYI", "mode": "attachment"})
Held mail never reaches an inbox-scoped key or an agent: reading it raises EmailQuarantinedError. A full-access team key sees it with include_quarantined=True.
An inbox_id that doesn't exist raises NotFoundError at once, even with wait. unread needs inbox_id, except with a key restricted to one inbox.
An inbox is one address that receives mail, read by an agent ("kind": "agent", the default) or by people ("kind": "human"). Threads, labels, drafts and their paths mirror Resend's Inboxes beta; Safety, Codes and Quarantine are MillionSend extensions.
inbox = millionsend.Inboxes.create({ "name": "Signup bot", # 1-320 characters "username": "signup-bot", # no email_address: signup-bot@<team>.<shared domain>; omitted, a random one "expires_in": "24h", # "1h" to "365d"; omitted or None, the inbox never expires "safety": {"accept_from": "anyone"}, }) millionsend.Inboxes.create({"email_address": "[email protected]", "name": "Support", "kind": "human"}) millionsend.Inboxes.create({"domain_id": domain.id, "username": "billing"}) # billing@ one of your domains millionsend.Inboxes.list(kind="agent") millionsend.Inboxes.get("[email protected]") # by id or address millionsend.Inboxes.update(inbox.id, {"from_name": "Acme", "expires_in": "7d"}) millionsend.Inboxes.remove(inbox.id) # also revokes its inbox-scoped keys # Codes and sign-in links, without parsing HTML (wait long-polls up to 50 s) code = millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30) code.status # "found" | "none" | "ambiguous" | "timeout"; code.code, code.url # A second sign-in: only mail received after the first code counts millionsend.Inboxes.Codes.latest(inbox.id, {"from": "example.com"}, wait=30, since=code.received_at) # Safety rules: who can write in, what the inbox may send millionsend.Inboxes.Safety.get(inbox.id) millionsend.Inboxes.Safety.update(inbox.id, {"accept_from": "allowlist", "allowlist": ["*@acme.dev"]}) # Quarantine: held mail, kept until a person releases it millionsend.Inboxes.Quarantine.list(inbox.id) millionsend.Inboxes.Quarantine.release(inbox.id, email_id) # Threads and their messages millionsend.Inboxes.Threads.list(inbox.id, folders=["inbox", "archive"], read=False) millionsend.Inboxes.Threads.search(inbox.id, {"query": "invoice", "from": ["example.com"]}) millionsend.Inboxes.Threads.get(inbox.id, thread_id) millionsend.Inboxes.Threads.update(inbox.id, thread_id, {"read": True, "folder": "archive", "label_id": label_id}) millionsend.Inboxes.Threads.remove(inbox.id, thread_id) # to trash, purged after 30 days millionsend.Inboxes.Threads.Emails.list(inbox.id, thread_id) # oldest first; include_quarantined=True adds held mail millionsend.Inboxes.Threads.Emails.get(inbox.id, thread_id, email_id) millionsend.Inboxes.Threads.Emails.reply(inbox.id, thread_id, email_id, {"text": "Done."}, idempotency_key=key) millionsend.Inboxes.Threads.Emails.forward(inbox.id, thread_id, email_id, {"to": "[email protected]"}) # Labels (up to 100 per inbox) millionsend.Inboxes.Labels.create(inbox.id, {"name": "Billing", "color": "#FF8800"}) millionsend.Inboxes.Labels.list(inbox.id) millionsend.Inboxes.Labels.update(inbox.id, label_id, {"name": "Invoices"}) millionsend.Inboxes.Labels.remove(inbox.id, label_id) # Drafts, and the ones that wait for approval millionsend.Inboxes.Drafts.create(inbox.id, {"to": "[email protected]", "subject": "Hi", "text": "..."}) millionsend.Inboxes.Drafts.list(inbox.id) millionsend.Inboxes.Drafts.get(inbox.id, draft_id) millionsend.Inboxes.Drafts.update(inbox.id, draft_id, {"cc": None}) # None clears millionsend.Inboxes.Drafts.send(inbox.id, draft_id, idempotency_key=key) # {"kind": "sent" | "draft", ...} millionsend.Inboxes.Drafts.reject(inbox.id, draft_id) millionsend.Inboxes.Drafts.remove(inbox.id, draft_id)
Replies, forwards and Drafts.send return {"kind": "sent", "email": ...} or, when the inbox's rules hold the message for a person (HTTP 202), {"kind": "draft", "draft": ...}. A key restricted to the inbox cannot send a draft that waits for approval (ApprovalRequiredError); a person in the dashboard or a full-access team key approves it. An agent's reply to a Reply-To at another organization is held the same way, with "reply_to_redirect" among approval_reasons. Hosted agent settings (/inboxes/{id}/agent) are not exposed: MillionSend runs no agent of its own.
With include_quarantined=True (full-access team keys), the items of Threads.list and Threads.search carry quarantined: their held messages not yet released.
On an agent inbox with screening on, mail held before screening ran (not_allowlisted, unauthenticated_sender, failed_auth) is screened at release when screening can run: if it finds prompt injection or phishing, the email stays held as injection or phishing (409 held_by_screening). trust_sender=True also adds the sender to the allowlist, only for mail held as not_allowlisted from a sender that authenticated (sender_trust "external"); anything else is refused with 403 trust_not_allowed.
Without email_address, an inbox gets an address on the instance's shared domain, <username>@<team>.<shared domain>. username is trimmed and lowercased: 1-64 of a-z 0-9 . _ -, starting and ending with a letter or digit. Without it the address is random (inbox-…). domain_id takes a row of Domains.list() instead: the shared row works like omitting email_address, and one of your own domains needs username, the part before the @, and receiving on (or "forwarding": True). A shared inbox's domain_id is the shared row's id (see Domains).
- Names are reviewed: a reserved (
postmaster,admin,noreply, …),fwd-or brand look-alikeusername, or a brand look-alikefrom_name, raisesValidationError(422; 409 when the address is taken).NameReviewUnavailableError(503) means the review can't run right now, andRateLimitExceededError(429) that the day's review budget is spent; both carryretry_after. - An inbox there with no mail in or out and no API call for 90 days is deleted (
inbox.expired, theninbox.deleted). - Sending follows the shared row's
sending_mode:"reply_only"lets through only in-thread replies to the thread's own correspondents, and"receive_only"nothing. A reply, forward or draft it forbids raisesInboxSendPolicyError(403),InboxSendCapReachedError(429) means the team reached today's limit, and more than 10 recipients raiseValidationError(422). A person or a team key sending a draft skips the inbox's rules, not the shared domain's. - In
Safety,send_policycan't be"allowlist", and is"open"only whilesending_modeis"open".owner_addressesdon't make senders trusted there, andforward_tocopies go only to team members' sign-in addresses.
Contacts are team-global — one record per email address, no audiences to manage.
millionsend.Contacts.create({ "email": "[email protected]", "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="[email protected]") # 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": "[email protected]", "properties": {"plan": None}}) # None removes the key millionsend.Contacts.remove(email="[email protected]") # the contact's emails stay in the send log millionsend.Contacts.remove(email="[email protected]", erase=True) # ?erase=true — also scrubs the address from email history, event payloads and API logs (GDPR/LGPD erasure) millionsend.Contacts.preferences_link(email="[email protected]").url # hosted preference page (MillionSend extension); no expiry, hand it only to the contact millionsend.Contacts.list(limit=50) millionsend.Contacts.list(segment_id=segment.id) # GET /segments/{id}/contacts # Bulk read (MillionSend extension): attach properties and topic subscriptions to every item, # so an audience reads in one request per 100 contacts instead of one per contact millionsend.Contacts.list(limit=100, include=["properties", "topics"]) # ?include=properties,topics # Bulk create (MillionSend extension) — up to 1000 per call result = millionsend.Contacts.Batch.create( [{"email": "[email protected]"}, {"email": "[email protected]", "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, statusCode, name, message}] # Bulk lookup (MillionSend extension) — up to 1000 contacts by id or email in one request, in request order; # unknown entries are listed under `missing`, not errors — one request against the rate limit result = millionsend.Contacts.Batch.get(["contact-id", {"email": "[email protected]"}], include=["topics"]) result.data # [{object: "contact", id, email, ..., topics}] — the contacts found result.missing # [{index, email}] — request entries that matched nobody # Bulk delete (MillionSend extension) — {"ids": [...]} or {"emails": [...]}, up to 1000; data lists only the rows deleted. # Emails stay in the send log; erase=True also scrubs each address from email history, event payloads and API logs millionsend.Contacts.Batch.remove({"emails": ["[email protected]", "[email protected]"]}) millionsend.Contacts.Batch.remove({"emails": ["[email protected]"]}, erase=True) # body {"emails": [...], "erase": true} # Segment membership — mirrors resend's contacts.segments millionsend.Contacts.Segments.add({"contact_id": "contact-id", "segment_id": segment.id}) millionsend.Contacts.Segments.remove({"email": "[email protected]", "segment_id": segment.id}) # Topic subscriptions (granular unsubscribe) — mirrors resend's contacts.topics millionsend.Contacts.Topics.update({ "email": "[email protected]", "topics": [{"id": "topic-id", "subscription": "opt_out"}], }) topics = millionsend.Contacts.Topics.list(email="[email protected]") # GET /contacts/{idOrEmail}/topics for t in topics.data: print(t.name, t.subscription, t.explicit, t.visibility) # subscription is the effective choice; explicit=False means the topic default applies; the preference page lists public topics only
Creating a contact whose email already exists on the team (case-insensitive) answers 409 and raises ValidationError.
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)
type is "string" or "number", as on Resend, or "date" (MillionSend extension). A date property's values and fallback_value must read as a date, or the write raises ValidationError (422):
- an ISO 8601 date, such as
2026-01-08, read as midnight UTC; - a date-time with
Tor a space, such as2026-01-08T10:30:00Zor2026-01-08 10:30, read as UTC when it names no zone; - Unix time in seconds or milliseconds between 2000 and 2100, such as
1767225600.
Contacts.get returns each property as {type, value}; a date's value is the string as written, as in {"type": "date", "value": "2026-01-08"}.
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)
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 <[email protected]>", "reply_to": "[email protected]", "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
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)
created_at and any property take four date ops: before and after with an ISO 8601 date or date-time, and in_last_days and not_in_last_days with a whole number of days as a string, from "1" to "36500", as in {"field": "property:last_login", "op": "not_in_last_days", "value": "90"}. A property's value reads as a date the way a date property's values do (see Contact properties), whatever its type, and one that isn't a date matches none of them.
Addresses the API refuses to send to. Addressable by id or email.
millionsend.Suppressions.add({"email": "[email protected]", "origin": "manual"}) # origin: bounce | complaint | manual | unsubscribe millionsend.Suppressions.get("[email protected]") millionsend.Suppressions.list(origin="bounce", limit=50) millionsend.Suppressions.remove("[email protected]") millionsend.Suppressions.Batch.add({"emails": ["[email protected]", "[email protected]"], "origin": "unsubscribe"}) # up to 1000 millionsend.Suppressions.Batch.remove({"emails": ["[email protected]"]}) # or {"ids": [...]}
Suppressions.create is an alias of add.
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.update({"id": domain.id, "receiving_catch_all": True}) # accept mail for addresses without an inbox millionsend.Domains.remove(domain.id)
Domains.list() ends with the team's subdomain of the shared domain, after all its own domains (alone on the next page when they fill a page exactly): the row with shared: True, whose id is the domain_id of the team's shared inboxes. It's read-only (update, verify and remove raise ForbiddenError) and only sends from an inbox's own address (see Emails). Only that row carries sending_mode: "open" starts conversations, "reply_only" replies inside threads, and "receive_only" sends nothing. sending_mode_reason says what holds it below "open", "plan" or "instance" (None when open). The row is missing when the instance has no shared domain, the server predates v0.14.0, or a new team's subdomain still waits for its name review.
To pick a sender domain, skip the shared row. Servers before v0.14.0 send no shared key, so read it with get:
sender_domains = [d for d in millionsend.Domains.list().data if d.status == "verified" and not d.get("shared")]
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) # Rotate the signing secret (MillionSend extension). For overlap_hours (default 24, max 72) deliveries # carry both signatures, so the receiver can switch without a gap; 0 drops the old secret at once. rotated = millionsend.Webhooks.rotate(webhook.id, {"overlap_hours": 24}) # or {"signing_secret": "whsec_..."} rotated.signing_secret rotated.previous_secret_expires_at # None once the old secret stops signing; also on get()
Events include email.scheduled, email.failed, email.suppressed, email.prefetched, email.received and the inbox.* events (inbox.created, inbox.updated, inbox.deleted, inbox.expired, inbox.thread.created, inbox.thread.folder.updated, inbox.thread.labels.updated, inbox.email.received, inbox.email.sent, inbox.email.quarantined, inbox.draft.created, inbox.draft.updated, inbox.draft.sent, inbox.draft.deleted). email.prefetched and inbox events reach only endpoints that list them by name. inbox_ids limits an endpoint's inbox events and email.received to those inboxes (None = every inbox). A shared-domain inbox deleted after 90 idle days fires inbox.expired, then inbox.deleted, and inbox events from a send in the dashboard carry data.source: "dashboard".
email.failed says why in data.failed.reason, and email.suppressed in data.suppressed (type, message and reason); new values can appear, so don't treat them as a closed list. On servers before v0.15.1, a webhook that names email.scheduled, email.failed or email.suppressed raises ValidationError (422).
key = millionsend.ApiKeys.create({"name": "ci", "permission": "sending_access", "domain_id": domain.id}) key.token # shown once millionsend.ApiKeys.create({"name": "signup-agent", "permission": "full_access", "inbox_id": inbox.id}) millionsend.ApiKeys.list() millionsend.ApiKeys.remove(key.id)
domain_id must be one of the team's own domains: the shared row's id raises ValidationError.
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.Usage.get() usage.plan # None when self-hosted usage.limits.emails_per_day # None = unlimited usage.today.emails_sent
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
Self-hosting? Each feature needs at least this MillionSend version:
| Feature | Server |
|---|---|
Emails.Receiving list, get and attachments |
v0.8.0 |
Inboxes; Emails.Receiving reply, forward, remove, wait and the quarantine filters; inbox_ids on webhooks, inbox_id on API keys, receiving_catch_all on domains |
v0.10.0 |
Recipients that can never receive mail refused; a batch idempotency_key replayed before validation |
v0.11.0 |
username; inboxes that never expire by default, expires_in "1h" to "365d"; sending from the shared domain; name reviews (NameReviewUnavailableError); statusCode and name on contacts-batch errors |
v0.12.0 |
Newest-first lists; a JSON 404 for unknown routes; since on Codes.latest; quarantined on thread items; inbox names up to 320 characters; NotFoundError at once for an unknown inbox_id; unread with an inbox-scoped key |
v0.13.0 |
The shared row in Domains.list() (shared, sending_mode, sending_mode_reason); domain_id on Inboxes.create; subject up to 998 characters |
v0.14.0 |
The email.scheduled, email.failed and email.suppressed webhook events; a suppressed email kept on AllRecipientsSuppressedError |
v0.15.1 |
Release rules: trust_sender only for a not_allowlisted hold from an authenticated sender (trust_not_allowed); screening at release (held_by_screening) |
v0.16.0 |
date contact properties; segment date ops on properties, and in_last_days / not_in_last_days on created_at |
v0.22.0 |
Older servers ignore options they don't know: before v0.12.0 username is dropped and the address is random, before v0.14.0 domain_id is dropped and the inbox lands on the shared domain, and before v0.13.0 since is dropped, so a code you already used can come back. If you must support them, check the created inbox's email_address, and treat a code whose received_at isn't after since as none.
- import resend - resend.api_key = "re_123" + import millionsend + millionsend.api_key = "ms_123" + millionsend.base_url = "https://mail.acme.dev" # self-hosted only - 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
Audiencesresource and noaudience_idparams. The API's/audiences/...routes are a compatibility shim for raw HTTP callers and are deliberately not exposed here. Resend'sSegmentsis an alias of audiences; MillionSend'sSegmentsis the distinct dynamic-filter feature. - MillionSend extensions (no Resend equivalent):
Segments,Contacts.Batch,Contacts.list(segment_id=...),Contacts.preferences_link,Webhooks.rotate,Usage,Deliverability,Emails.get_insights,Emails.Receiving.reply/forward/remove, thedatecontact property type, andInboxes(Resend's Inboxes are not in resend-python yet). - Not available: Resend's
ApiKeys.update,Webhooksevent history/replay/verify,Emails.share/Emails.metrics,Broadcasts.recipients/clicked_links,Contacts.Segments.list,DomainClaims,ContactImports,Automations,Events,Logs,OAuthGrants, and the*_asyncvariants. - MillionSend raises on API errors just like
resend; the exception carries.code/.status_code/.message.
MIT