MillionSend Docs

API reference

The MillionSend HTTP API — Resend wire-compatible, generated from the server code.

The endpoint pages under this section are generated from the API's own route definitions at build time, so they always reflect the code. The raw spec is at /openapi.json (OpenAPI 3.1).

Base URL

https://api.millionsend.com

Authentication

Every endpoint requires an API key created in the dashboard, except the SES event ingestion webhook and the signed download links of received mail:

Authorization: Bearer ms_...

Keys have a permission level: full access keys can use every endpoint, sending only keys are confined to /emails* (anything else returns 403 restricted_api_key) — and even there, GET /emails, GET /emails/{id} and DELETE /emails/{id} need full access, since reads return stored bodies and the team's whole archive, and so does every /emails/receiving endpoint. A key can additionally be scoped to a single domain, restricting which from addresses it may send and which received mail it reads.

A full-access key can instead be scoped to one inbox (inbox_id): it reads and works that inbox only, sends only from its address, and gets 403 restricted_api_key on every other route, key creation included. See Inbox-scoped API keys.

A key created with POST /api-keys is never broader than the key that created it. A domain-scoped key creates keys scoped to its own domain: an omitted or null domain_id inherits that domain, and any other domain returns 403 restricted_api_key.

Resend compatibility

Request and response shapes match Resend's API, so official Resend SDKs work against MillionSend by pointing their base URL at it. CI runs the official resend npm package against every endpoint as a conformance gate. The few remaining deltas are deliberate and loud:

  • Attachments take inline base64 content only — a remote path URL is rejected with 422 (never fetched). An attachment with a content_id is an inline image the HTML references as cid:<content_id>, and may leave filename out or null. Attachments can total up to 10 MiB per email (Resend allows 40 MB), and the whole email must fit SES's 40 MB as it goes out; a larger one is refused with 422. Program files are refused too; see Attachments.
  • Receiving is supported (beta): GET /emails/receiving, GET /emails/receiving/{id} with html_format, the attachment endpoints, signed one-hour download links, the email.received webhook, and capabilities.receiving with a Receiving record on domains — so resend.emails.receiving.*, forward() included, works unchanged. A domain's capabilities.sending is enabled (asking to disable it is a 422), except on the shared subdomain below while the instance pauses sending from the shared domain. Receiving needs a verified domain, so POST /domains with capabilities.receiving: "enabled" is a 422: verify the domain, then enable it with PATCH /domains/{id}. GET /emails/receiving without limit returns 20 emails rather than all of them, and HTML whose inline images would take it past 40 MB (Resend: 100 MB) is served with its cid: references (html_format: "cid"). Unlike Resend, a receiving domain takes mail only at its inboxes' addresses unless its catch-all is on (receiving_catch_all, a paid feature on Cloud); other addresses are refused during the SMTP session. Received mail gains extension fields (screening, spam_verdict, virus_verdict, extracted_text, extracted and more), text comes without invisible characters, and infected mail is held, with its downloads answering 410 email_blocked. MillionSend adds long-polling (?wait=), server-side POST /emails/receiving/{id}/reply and /forward, and DELETE /emails/receiving/{id}. See Receiving.
  • Inboxes are supported (beta): the 26 endpoints of Resend's Inboxes preview, under /inboxes, with the same paths and shapes, so resend.inboxes.* from resend@6.32.1-preview-inboxes.6 works. Creating an inbox or a draft answers the whole object; email_address may be left out for an address on the shared domain; kind defaults to agent; the hosted-agent settings answer an empty agent on GET and 422 on PATCH; thread search reads subjects, senders and recipients only; and a reply, forward or draft send that the inbox's rules hold answers 202 with the draft instead of 200 with the email. Extensions are extra fields and endpoints only, never new values in Resend's enums. See Inboxes → Resend compatibility.
  • On an instance with a shared inbox domain, GET /domains ends with your team's subdomain of it. Every domain carries shared, and that row has shared: true, plus sending_mode (open, reply_only or receive_only) and sending_mode_reason (plan, instance or null); its capabilities.sending reads disabled while the instance pauses sending from the shared domain. It is read-only: PATCH, verify and DELETE answer 403 forbidden. Since it comes last, a client that takes the first verified domain for its from keeps getting one of the team's own whenever the team has one verified. For a team without a verified domain of its own, the shared row is the first verified one, and only an inbox's own address sends from it, so such a client should skip shared: true rows. POST /inboxes takes its id as domain_id. See Domains → Shared subdomain.
  • Contacts are team-global — audience endpoints are served as aliases of segments, and contact endpoints work with or without an audience id (see Contacts).
  • Contact properties take a third type, date, beside Resend's string and number. Only a team that defines one sees it: its values come back from GET /contacts/{id} as { "type": "date", "value": "2026-01-08" }, a type Resend's SDK types don't list. See Contacts → Property types.
  • POST /domains takes an optional region, which must be one of the SES regions the deployment serves — the values its request schema lists, the first being the default — and is rejected with 422 otherwise. A domain has one region: to move it, delete it and add it again.
  • Broadcasts support canceled, a status outside Resend's union, and broadcast sends, POST /emails and POST /emails/batch can return 403 sending_paused when your bounce or complaint rate crosses the SES enforcement thresholds, and broadcast sends also while the team's sending is paused pending review. Broadcast sends alone can also return 403 broadcasts_paused while the platform's aggregate rate in the sender's SES region is recovering: a bounce rate refuses every team in the region, a complaint rate only the teams whose own complaint rate over the last 30 days is at 0.1% or more. It leaves transactional email untouched, and clears on its own.
  • A broadcast send answers with finishes_at (the estimated instant the last email goes out, or null), estimated: true and, when the audience exceeds the capacity available now, a warning (paced or queued_behind, with days and a message). From a domain whose warm-up is on, the warning is warming_up instead, with days: 0 while the warm-up is paused. From a domain under the new domain limit, the estimate follows the limit's schedule and the warning is new_domain_limit. Broadcast reads carry sent_count and a live finishes_at, and a cancel answers with canceled_remaining. An audience that needs more than 24 days of capacity, or of the domain's warm-up, is refused with 422 broadcast_too_large. The new domain limit alone never refuses one. See Broadcasts.
  • POST /emails and POST /emails/batch return 429 daily_quota_exceeded on a daily plan (Free, Starter) when the day's sending quota is spent and the queued backlog is full — retry after the UTC day rolls over — and 429 monthly_quota_exceeded on a monthly plan (Pro, Scale) at its included volume with overage off, or while a payment is past due (overage pauses until the invoice is paid); turn on overage or pay the invoice in Billing, or wait for the period to renew (the message names the date). A batch is accepted or refused whole. On any plan, a domain still under the new domain limit answers the same 429 daily_quota_exceeded, with a message naming that limit, once a lot of mail is already waiting. Both daily refusals carry a Retry-After header with the seconds until 00:00 UTC.
  • An email the new domain limit holds is still accepted, and POST /emails, or its item of POST /emails/batch, answers with a warning: code new_domain_limit, a message, and sends_after, the earliest it goes out. GET /emails/{id} and GET /emails keep last_event: "queued" for an email that waits, as Resend clients expect, and add queued_reason (new_domain_limit, warming_up, daily_quota, monthly_quota or capacity, and null when it isn't waiting) and sends_after (null when no time is known, as for capacity and warming_up). Clients that don't know these fields ignore them.
  • POST /contacts, POST /contacts/batch and the audience alias return 403 plan_limit_reached when a new contact would take the team past its plan's contact cap (1,000 on Free; paid plans are unlimited). Existing contacts still update; in a batch only the new ones fail.
  • GET /usage exists (Resend has no usage endpoint): the effective plan, its limits (emails_per_day on daily plans, emails_per_month on monthly ones, domains, contacts, inboxes), today's accepted count and, on a monthly plan, a period object — emails_sent, included, overage_enabled, overage_usd_per_1k, starts_at, ends_at. Self-hosted instances report cloud: false with null plan, limits and period.
  • DELETE /emails/{id} exists (Resend has no email deletion).
  • Custom headers are allowlisted: any X-* name (except X-SES-* and X-MillionSend-*) plus In-Reply-To, References, Importance, Priority, Comments, Keywords, Organization, OTP-Token, and the one-click unsubscribe pair — List-Unsubscribe (one or more <https://…> or <mailto:…> targets) with List-Unsubscribe-Post (List-Unsubscribe=One-Click); anything else is a 422. The two must come together, and List-Unsubscribe needs an https target. On a send with a topic_id, a pair you supply replaces the generated one: one-click requests then reach your endpoint, MillionSend records no opt-out for them, and an {{{UNSUBSCRIBE_URL}}} placeholder in the body still resolves to MillionSend's page. MillionSend generates the pair and resolves the placeholder only when the send goes to a single address that is a contact: the link opts out the contact it names, and every to, cc and bcc copy carries the same message. A send to more than one address carries no generated pair, and its placeholders are removed.
  • Header values and subjects in printable ASCII go out as given, double quotes included, so a structured value such as OTP-Token: "123456"; origin="https://example.com" reaches the inbox intact. A value with non-ASCII text, or with a word too long for a 998-character header line, is RFC 2047-encoded, and In-Reply-To and References go out as <msg-id>s. A vetted name may appear only once: the same name twice in different cases is a 422.
  • A send whose every to recipient is on the suppression list or opted out of the topic_id is refused with 422 all_recipients_suppressed (message All recipients are suppressed) instead of Resend's 200; recipients dropped from a send that still has someone left are simply omitted. When the suppression list alone empties to, POST /emails keeps a metadata-only suppressed email and, as Resend does, fires email.suppressed; each retry with the same Idempotency-Key keeps another. A topic opt-out, a batch item and the SMTP relay keep nothing. Unsubscribe-origin entries count only when the send has a topic_id; bounce, complaint and manual entries always.
  • to, cc and bcc together cannot exceed 50 recipients, and each address must be a single mailbox — a display name containing @ is rejected, and accepted addresses read back in canonical Name <user@host> form.
  • subject takes up to 998 characters, the line limit of RFC 5322, on POST /emails, POST /emails/batch, broadcasts and templates: a longer one is refused with 422 validation_error, and the SMTP relay answers 554. Resend documents no limit for these. Inbox replies, forwards and drafts take up to 2,000, as on Resend.
  • A recipient whose domain can never receive mail, such as x@example.com, x@foo.test or x@gmail.con, is refused with 422 validation_error, in sends and new contacts alike; see Errors.
  • The instance's onboarding sender (ONBOARDING_EMAIL_FROM; hello@mail.millionsend.com on the cloud) carries only MillionSend's own onboarding email: as from on POST /emails, POST /emails/batch or a broadcast send it is refused with 422 reserved_sender, in any letter case and with any display name. Send from a domain you verified instead.
  • Tag names starting with millionsend_, in any letter case, are MillionSend's own: a tags entry with one on POST /emails or POST /emails/batch is refused with 422 validation_error.
  • Anything unsupported is rejected with 422 rather than silently dropped (e.g. tls on domain update).

Attachments

Some files are refused for every team, with 422 validation_error and a message that names the file and the reason:

  • A type SES or Gmail refuses. The last extension counts, in any letter case and without trailing dots or spaces, and so does the one inside a compressed file (invoice.exe.gz counts as .exe):

    ade adp apk app appx appxbundle asp bas bat cab cer chm cmd com cpl crt
    csh der diagcab diagcfg diagpkg dll dmg ex ex_ exe fxp gadget hlp hta img
    inf ins iso isp its jar jnlp js jse ksh lib lnk mad maf mag mam maq mar
    mas mat mau mav maw mda mdb mde mdt mdw mdz mjs msc msh msh1 msh1xml msh2
    msh2xml mshxml msi msix msixbundle msp mst nsh ops pcd pif plg prf prg ps1
    ps1xml ps2 ps2xml psc1 psc2 reg scf scr sct shb shs sys tmp url vb vbe vbs
    vhd vps vsmacros vss vst vsw vxd ws wsc wsf wsh xll xnk
  • A program under any name: a Windows, Linux or macOS executable, or a Windows shortcut.

  • A ZIP, Office files included, holding a file with one of those extensions.

  • A content_type that names a program:

    application/x-msdownload application/x-msdos-program application/x-dosexec
    application/x-ms-dos-executable
    application/vnd.microsoft.portable-executable application/x-executable
    application/x-elf application/x-mach-binary application/x-msi
    application/x-ms-installer application/x-ms-shortcut
    application/x-mswinurl application/hta application/x-bat application/x-csh
    application/javascript application/x-javascript text/javascript
    application/ecmascript text/ecmascript text/vbscript
    application/java-archive application/x-java-archive
    application/x-java-jnlp-file application/vnd.android.package-archive
    application/x-apple-diskimage application/x-iso9660-image
    application/vnd.ms-cab-compressed application/x-msaccess
    application/vnd.ms-htmlhelp
  • A file name with a character that changes text direction (U+202A to U+202E, U+2066 to U+2069, U+200E, U+200F, U+061C), such as the right-to-left override that makes .exe display as .pdf. Other invisible characters, like the joiners Persian names and emoji use, are accepted.

While a team is new (inside its first 1,000 sends or 72 hours, or under 10,000 sends within 7 days of its first), an email carrying any of these is accepted but waits for review before it is sent:

  • Web pages and SVG images (htm html shtml xhtml xht mht mhtml svg svgz), by name, content_type or content.
  • Archives (zip zipx rar 7z gz tgz bz2 tbz tbz2 xz txz tar zst lz lzh lha arj ace z cpio), and archive content under any name.
  • vhdx disk images; macro-enabled Office files (docm dotm xlsm xltm xlam xlsb pptm potm ppam ppsm sldm) and any Office file with a VBA project; OneNote (one onepkg), rtf, and attached emails (eml msg).
  • A file whose name and content disagree, such as a .pdf that is really a ZIP or a web page.
  • A PDF that needs a password to open, or that carries scripts, launch actions, embedded files or XFA forms; encrypted archive entries, and archives inside archives.
  • A file that can't be read: damaged, longer than 50 pages, or larger than 50 megapixels.

Older teams send the same files without waiting; their emails are screened more often.

Errors

Errors use Resend's format:

{ "statusCode": 422, "name": "validation_error", "message": "..." }

A body that isn't valid JSON answers 400 invalid_parameter, and a body sent with a Content-Type other than application/json answers 415.

Every 429 carries a Retry-After header with the whole seconds until the request can pass:

RefusalRetry-After counts down to
rate_limit_exceeded, "Too many requests" or "Too many failed authentication attempts"The next minute. An API key makes up to 600 requests a minute by default and a team 3,000; the MCP server allows each person 600; one IP gets 20 failed authentications.
rate_limit_exceeded, "Too many domains created"The next full hour (UTC). A team adds up to 10 domains an hour, or 100 on Scale.
rate_limit_exceeded, "Too many inboxes created in the last hour"The moment the oldest inbox created in the last hour turns an hour old.
rate_limit_exceeded, "Too many names were reviewed today" or "refused today" (POST /inboxes, PATCH /inboxes/{id})00:00 UTC, when the day's shared-domain name reviews reset.
daily_quota_exceeded00:00 UTC, when the daily limit resets.
monthly_quota_exceededThe end of the billing period, when the monthly volume renews.

The inbox refusals in the table below name their own. For a daily limit it looks like this:

HTTP/1.1 429 Too Many Requests
Retry-After: 8880

{
  "statusCode": 429,
  "name": "daily_quota_exceeded",
  "message": "nova.example sends up to 300 emails a day for now, and 5,000 are already waiting. Retry after 00:00 UTC."
}

A path the API doesn't have, or a method its path doesn't take, answers 404 not_found with the message The requested endpoint does not exist. (Resend answers a wrong method with 405 method_not_allowed). A wrong method on a path that also fits a route with an id goes to that route instead: GET /emails/batch is GET /emails/{id} with the id batch, and answers 422 validation_error.

A NUL character (U+0000) in text the database stores, such as a name, a property, a filter or an id in the URL, and a date outside the years 1 to 9999 answer 422 validation_error: the database can't store them. An email's html and text are stored encrypted, so they may contain it.

A recipient whose domain can never receive mail is refused with 422 validation_error, and the message names the address:

{
  "statusCode": 422,
  "name": "validation_error",
  "message": "x@gmail.con can never receive mail: .con is not a top-level domain. To test sending without emailing anyone, use success@simulator.amazonses.com."
}
  • Reserved names: a domain ending in .test, .invalid, .example, .localhost, .local, .onion, .alt or .internal, and home.arpa with its subdomains.
  • Documentation domains: example.com, example.net and example.org, with their subdomains.
  • Top-level domains that don't exist, such as .con, or one made only of digits. MillionSend knows the real ones from the Public Suffix List and looks any other up in the DNS root; when that lookup fails or times out, the address passes, so a DNS problem never refuses a recipient.

Sends through the API, the MCP server, inboxes and the SMTP relay check to, cc and bcc, never reply_to: a batch item fails like any invalid one, an inbox draft is checked when it is sent, and the relay answers 550 5.1.2 at RCPT TO. POST /contacts, POST /contacts/batch and the audience alias refuse the same addresses for a new contact. Nothing already stored changes: contacts, and queued or scheduled emails, stay as they are. A broadcast goes to stored contacts without checking their addresses.

Besides Resend's names, received mail, inboxes and the shared subdomain answer these:

Status, nameWhen
403 restricted_api_keyAn inbox-scoped key outside its inbox, or sending from another address; include_quarantined from an inbox-scoped key or the MCP server.
403 inbox_send_policyAn inbox's rules refuse a send: a shared-domain address starting a conversation where it may only reply (on the Free plan, or by the instance's setting), any send from one while the instance pauses them, a send from an agent inbox's address on POST /emails, POST /emails/batch or the SMTP relay that the rules would hold, or a broadcast from an address on the shared domain. The message names the rule, or lists the reasons a send was held.
429 inbox_send_cap_reachedThe inbox's daily send cap is used, or the team's daily limit for mail from the shared domain; retry-after counts to midnight UTC.
503 name_review_unavailableA chosen username or a from_name on the shared domain couldn't be reviewed right now (retry-after: 60), or your team's subdomain of the shared domain still waits for its name review, and until then no address can be created on it (retry-after: 300).
403 approval_requiredAn agent sending a draft that waits for approval.
403 email_quarantinedAn agent reading, replying to or forwarding held mail, or any forward of infected mail.
409 held_by_screeningA release that screening found to be prompt injection or phishing: it stays in quarantine.
403 trust_not_allowedtrust_sender on a release of anything but mail held as not_allowlisted from a verified sender.
503 screening_unavailableAn MCP client's release while screening can't run: no judge on the instance, a judge that failed or timed out, or the day's screening limit spent. retry-after: 60, or until midnight UTC for a spent limit.
410 email_blockedA download of infected mail.
429 too_many_waitsToo many long-polls at once from one team; retry-after: 1.
403 validation_errorThe plan's inbox limit, with Resend's message.
403 plan_limit_reachedCatch-all on the Free plan.
403 forbiddenA change, verification or deletion of your team's shared subdomain, which is read-only; an MCP client releasing mail held for prompt injection, phishing, a virus or failed authentication.
404 not_foundAn unknown inbox, thread, label, draft or pagination cursor, with Resend's messages (Inbox not found.).

Idempotency

POST /emails and POST /emails/batch accept an Idempotency-Key header, and so do the sends of received mail and inboxes: inbox replies, forwards and draft sends, and POST /emails/receiving/{id}/reply and /forward. Retrying with the same key and payload returns the original response instead of sending again; the same key with a different payload returns 409. A send that was held for approval answers the retry with the same draft, as 202. A refused POST /emails doesn't hold its key: the retry runs again, and when the suppression list alone empties to, it keeps another suppressed email.

Pagination

List endpoints accept limit (1–100, default 20) plus after / before cursors carrying an item id from a previous page. Responses include has_more. Lists run newest first, as on Resend: after takes the last id of a page and pages toward older items, before takes the first id and pages back toward newer ones, and has_more says whether more remain that way. To pick up what was created since your last read, pass the newest id you stored as before, then the first id of each page while has_more is true. With wait, /emails/receiving answers the newest matches instead of the ones right after the cursor; see Waiting for new mail. These lists run in another order:

ListOrder
GET /inboxes/{id}/threads and /threads/searchLatest activity first.
GET /inboxes/{id}/threads/{thread_id}/emailsOldest first: after pages toward newer emails.
GET /inboxes/{id}/draftsMost recently updated first.
GET /inboxes/{id}/labelsOldest first, all at once, without has_more.
GET /topics and /contacts/{id}/topicsOldest first, all at once; has_more is always false.
GET /emails/receiving/{id}/attachmentsIn the email's order, all at once unless you pass limit.

GET /domains puts your team's shared subdomain after every domain of your own, as if it were the oldest, and its id is a cursor like any other: after it is an empty page, and before it reads your oldest domains, the page right above it. Catching up works as above.

A cursor that names no item of the list answers 422 validation_error; under /inboxes it answers 404, as on Resend. That includes a stored id whose item was deleted or revoked since and, on /emails/receiving, one that no longer matches your filters: to catch up, read from the top without a cursor and stop at the items you already have.

Received mail carries download links instead of inline bytes: raw.download_url on GET /emails/receiving/{id}, and download_url on each attachment the attachment endpoints return. They are plain GET URLs on the API origin, /receiving/raw/{email_id} and /receiving/attachments/{email_id}/{attachment_id}, with expires (Unix seconds) and signature in the query. They need no API key, work for one hour (expires_at) for one email and attachment of one team, and answer 403 once expired or altered; ask the API again for fresh ones. A message carrying a virus answers 410 email_blocked instead, for good. See Receiving → Downloads.

On this page