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
contentonly — a remotepathURL is rejected with422(never fetched). An attachment with acontent_idis an inline image the HTML references ascid:<content_id>, and may leavefilenameout 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 with422. Program files are refused too; see Attachments. - Receiving is supported (beta):
GET /emails/receiving,GET /emails/receiving/{id}withhtml_format, the attachment endpoints, signed one-hour download links, theemail.receivedwebhook, andcapabilities.receivingwith aReceivingrecord on domains — soresend.emails.receiving.*,forward()included, works unchanged. A domain'scapabilities.sendingisenabled(asking to disable it is a422), except on the shared subdomain below while the instance pauses sending from the shared domain. Receiving needs a verified domain, soPOST /domainswithcapabilities.receiving: "enabled"is a422: verify the domain, then enable it withPATCH /domains/{id}.GET /emails/receivingwithoutlimitreturns 20 emails rather than all of them, and HTML whose inline images would take it past 40 MB (Resend: 100 MB) is served with itscid: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,extractedand more),textcomes without invisible characters, and infected mail is held, with its downloads answering410 email_blocked. MillionSend adds long-polling (?wait=), server-sidePOST /emails/receiving/{id}/replyand/forward, andDELETE /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, soresend.inboxes.*fromresend@6.32.1-preview-inboxes.6works. Creating an inbox or a draft answers the whole object;email_addressmay be left out for an address on the shared domain;kinddefaults toagent; the hosted-agent settings answer an empty agent onGETand422onPATCH; thread search reads subjects, senders and recipients only; and a reply, forward or draft send that the inbox's rules hold answers202with the draft instead of200with 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 /domainsends with your team's subdomain of it. Every domain carriesshared, and that row hasshared: true, plussending_mode(open,reply_onlyorreceive_only) andsending_mode_reason(plan,instanceornull); itscapabilities.sendingreadsdisabledwhile the instance pauses sending from the shared domain. It is read-only:PATCH, verify andDELETEanswer403 forbidden. Since it comes last, a client that takes the first verified domain for itsfromkeeps 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 skipshared: truerows.POST /inboxestakes its id asdomain_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'sstringandnumber. Only a team that defines one sees it: its values come back fromGET /contacts/{id}as{ "type": "date", "value": "2026-01-08" }, a type Resend's SDK types don't list. See Contacts → Property types. POST /domainstakes an optionalregion, 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 with422otherwise. 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 /emailsandPOST /emails/batchcan return403 sending_pausedwhen 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 return403 broadcasts_pausedwhile 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, ornull),estimated: trueand, when the audience exceeds the capacity available now, awarning(pacedorqueued_behind, withdaysand a message). From a domain whose warm-up is on, thewarningiswarming_upinstead, withdays: 0while the warm-up is paused. From a domain under the new domain limit, the estimate follows the limit's schedule and thewarningisnew_domain_limit. Broadcast reads carrysent_countand a livefinishes_at, and a cancel answers withcanceled_remaining. An audience that needs more than 24 days of capacity, or of the domain's warm-up, is refused with422 broadcast_too_large. The new domain limit alone never refuses one. See Broadcasts. POST /emailsandPOST /emails/batchreturn429 daily_quota_exceededon 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 — and429 monthly_quota_exceededon 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 same429 daily_quota_exceeded, with a message naming that limit, once a lot of mail is already waiting. Both daily refusals carry aRetry-Afterheader with the seconds until 00:00 UTC.- An email the new domain limit holds
is still accepted, and
POST /emails, or its item ofPOST /emails/batch, answers with awarning:codenew_domain_limit, amessage, andsends_after, the earliest it goes out.GET /emails/{id}andGET /emailskeeplast_event: "queued"for an email that waits, as Resend clients expect, and addqueued_reason(new_domain_limit,warming_up,daily_quota,monthly_quotaorcapacity, andnullwhen it isn't waiting) andsends_after(nullwhen no time is known, as forcapacityandwarming_up). Clients that don't know these fields ignore them. POST /contacts,POST /contacts/batchand the audience alias return403 plan_limit_reachedwhen 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 /usageexists (Resend has no usage endpoint): the effective plan, its limits (emails_per_dayon daily plans,emails_per_monthon monthly ones,domains,contacts,inboxes), today's accepted count and, on a monthly plan, aperiodobject —emails_sent,included,overage_enabled,overage_usd_per_1k,starts_at,ends_at. Self-hosted instances reportcloud: falsewith null plan, limits and period.DELETE /emails/{id}exists (Resend has no email deletion).- Custom
headersare allowlisted: anyX-*name (exceptX-SES-*andX-MillionSend-*) plusIn-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) withList-Unsubscribe-Post(List-Unsubscribe=One-Click); anything else is a422. The two must come together, andList-Unsubscribeneeds anhttpstarget. On a send with atopic_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 everyto,ccandbcccopy 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, andIn-Reply-ToandReferencesgo out as<msg-id>s. A vetted name may appear only once: the same name twice in different cases is a422. - A send whose every
torecipient is on the suppression list or opted out of thetopic_idis refused with422 all_recipients_suppressed(messageAll recipients are suppressed) instead of Resend's200; recipients dropped from a send that still has someone left are simply omitted. When the suppression list alone emptiesto,POST /emailskeeps a metadata-onlysuppressedemail and, as Resend does, firesemail.suppressed; each retry with the sameIdempotency-Keykeeps another. A topic opt-out, a batch item and the SMTP relay keep nothing. Unsubscribe-origin entries count only when the send has atopic_id; bounce, complaint and manual entries always. to,ccandbcctogether cannot exceed 50 recipients, and each address must be a single mailbox — a display name containing@is rejected, and accepted addresses read back in canonicalName <user@host>form.subjecttakes up to 998 characters, the line limit of RFC 5322, onPOST /emails,POST /emails/batch, broadcasts and templates: a longer one is refused with422 validation_error, and the SMTP relay answers554. 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.testorx@gmail.con, is refused with422 validation_error, in sends and new contacts alike; see Errors. - The instance's onboarding sender (
ONBOARDING_EMAIL_FROM;hello@mail.millionsend.comon the cloud) carries only MillionSend's own onboarding email: asfromonPOST /emails,POST /emails/batchor a broadcast send it is refused with422 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: atagsentry with one onPOST /emailsorPOST /emails/batchis refused with422 validation_error. - Anything unsupported is rejected with
422rather than silently dropped (e.g.tlson 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.gzcounts 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_typethat 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
.exedisplay 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_typeor 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. vhdxdisk 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
.pdfthat 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:
| Refusal | Retry-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_exceeded | 00:00 UTC, when the daily limit resets. |
monthly_quota_exceeded | The 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,.altor.internal, andhome.arpawith its subdomains. - Documentation domains:
example.com,example.netandexample.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, name | When |
|---|---|
403 restricted_api_key | An 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_policy | An 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_reached | The 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_unavailable | A 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_required | An agent sending a draft that waits for approval. |
403 email_quarantined | An agent reading, replying to or forwarding held mail, or any forward of infected mail. |
409 held_by_screening | A release that screening found to be prompt injection or phishing: it stays in quarantine. |
403 trust_not_allowed | trust_sender on a release of anything but mail held as not_allowlisted from a verified sender. |
503 screening_unavailable | An 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_blocked | A download of infected mail. |
429 too_many_waits | Too many long-polls at once from one team; retry-after: 1. |
403 validation_error | The plan's inbox limit, with Resend's message. |
403 plan_limit_reached | Catch-all on the Free plan. |
403 forbidden | A 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_found | An 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:
| List | Order |
|---|---|
GET /inboxes/{id}/threads and /threads/search | Latest activity first. |
GET /inboxes/{id}/threads/{thread_id}/emails | Oldest first: after pages toward newer emails. |
GET /inboxes/{id}/drafts | Most recently updated first. |
GET /inboxes/{id}/labels | Oldest first, all at once, without has_more. |
GET /topics and /contacts/{id}/topics | Oldest first, all at once; has_more is always false. |
GET /emails/receiving/{id}/attachments | In 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.
Signed download links
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.