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 (except the SES event ingestion webhook) requires an API key created in the dashboard:
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. A key can additionally be scoped to a single
domain, restricting which from addresses it may send.
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), as iscontent_id(inline images). - Contacts are team-global — audience endpoints are served as aliases of segments, and contact endpoints work with or without an audience id (see Contacts).
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. Broadcast sends alone can also return403 broadcasts_pausedwhile the platform's aggregate rate in the sender's SES region is recovering; it is per region, 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). Broadcast reads carrysent_countand a livefinishes_at; a cancel answers withcanceled_remaining. An audience that needs more than 24 days of capacity is refused with422 broadcast_too_large. 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; turn on overage in Billing or wait for the period to renew (the message names the date). A batch is accepted or refused whole.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), 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, 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. - 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); recipients dropped from a send that still has someone left are simply omitted. 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.- Anything unsupported is rejected with
422rather than silently dropped (e.g.tlson domain update).
Errors
Errors use Resend's format:
{ "statusCode": 422, "name": "validation_error", "message": "..." }Idempotency
POST /emails and POST /emails/batch accept an Idempotency-Key header.
Retrying with the same key and payload returns the original response instead
of sending again; the same key with a different payload returns 409.
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.