Inboxes
Addresses that keep their mail in threads and reply from the same address, for an AI agent or for people, with drafts that wait for approval, quarantine and screening, through a Resend-compatible API. Beta.
An inbox is one email address that receives mail into threads and sends from the same address. Give one to an AI agent, which reads it through the API, webhooks or the MCP server, or to people, who read it in the dashboard. Inboxes build on receiving: an inbox is an address on one of your receiving domains, or an address on the instance's shared domain, named by you or random, ready with no DNS.
The API mirrors Resend's Inboxes, so Resend's preview SDK works against MillionSend. What MillionSend adds comes as extra fields and extra endpoints; see Resend compatibility.
Inboxes are in beta, under Inboxes in the dashboard, and exist on any instance that receives mail. Resend's Inboxes are a private beta whose shapes may still change; this mirror follows Resend's latest preview.
Agent or people
Every inbox has a kind, chosen when it is created and never changed. The
kind sets the defaults of the inbox's safety settings:
| Setting | Agent, own domain | Agent, shared domain | People, own domain | People, shared domain |
|---|---|---|---|---|
accept_from | verified | verified | anyone | anyone |
screening | on | on | off | off |
extract_codes | on | on | on | on |
send_policy | reply_only | reply_only | open | reply_only |
require_approval | off | off | off | off |
private_codes | on | on | off | off |
send_cap | 200 | 50 | none | 50 |
agent, the default whenkindis left out (a Resend SDK can't send it). Mail from senders whose domain doesn't vouch for them waits in quarantine, mail from other outside senders is screened for prompt injection, and the agent replies inside existing threads: a new conversation it starts becomes a draft that waits for a person. See Agent safety.human, for people reading in the dashboard: everyone's mail is delivered (a failed authentication lands in thespamfolder), nothing is screened, and replies carry noAuto-Submittedheader.
Mail carrying a virus is quarantined in every inbox, whatever its kind.
Create an inbox
curl -X POST "https://api.millionsend.com/inboxes" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-d '{
"email_address": "orders@inbound.example.com",
"name": "Orders agent",
"kind": "agent"
}'The answer is the whole inbox, with 201:
{
"object": "inbox",
"id": "7eeb9517-aed7-40d7-ad65-500be42ed147",
"name": "Orders agent",
"email_address": "orders@inbound.example.com",
"domain_id": "af8ad875-319f-4df5-a359-4f2430f3e766",
"receiving_address": null,
"from_name": null,
"unread": 0,
"drafts": 0,
"last_received": null,
"created_at": "2026-10-12T14:03:11.229Z",
"kind": "agent",
"shared": false,
"expires_at": null,
"quarantined": 0,
"pending_approval": 0
}unread counts threads with an unread message, drafts the unsent drafts,
last_received when a thread was last active, quarantined the messages
held and not released, and pending_approval the drafts waiting for a
person. The last five fields are MillionSend's.
| Field | Rules |
|---|---|
email_address | An address on a verified domain of your team that receives here (see Addresses). It is trimmed and lowercased; the part before @ takes up to 64 letters, digits and . _ % + -, with no dot at either end and no ... It can't change later. Leave it out for an address on the shared domain, or on the domain domain_id names. |
username | With email_address left out, the part before @ of the shared-domain address: trimmed and lowercased, 1–64 letters, digits and . _ -, starting and ending with a letter or digit, without .., never cut short. Left out, the address is random. With a domain_id of one of your own domains, it is the part before @ there, required, under email_address's rules. It can't change later. |
domain_id | The id of a GET /domains row, instead of email_address: your team's subdomain of the shared domain (the row with shared: true), which works like leaving email_address out, or one of your verified domains, where username names the address and email_address's rules apply. |
name | 1–320 characters, so any address fits; the address when left out. |
from_name | The display name of the mail the inbox sends, up to 78 characters: a plain name, without @, :, <, >, line breaks, links or phone numbers. On the shared domain it is reviewed first. |
kind | agent (the default) or human. |
expires_in | When the inbox expires, such as 1h, 24h or 7d; left out, never. |
safety | Any of the safety settings; the rest take the kind's defaults. |
forwarding | true adds a receiving_address on the shared domain, for forwarding a mailbox you keep elsewhere into the inbox (see Forwarding into an inbox). |
| Answer | When |
|---|---|
409 validation_error | Another inbox already uses the address, a chosen username included: no other name is picked for you. |
403 validation_error | The plan's inbox limit is reached: "You have reached the inbox limit of your plan. Upgrade to add more." |
429 rate_limit_exceeded | Too many inboxes were created in the last hour, or the shared domain's name review reached today's limit for your team (20 refused names or 200 reviews); retry-after says when to try again. Deleting inboxes doesn't give that room back. |
422 validation_error | The domain isn't a verified domain of your team, it doesn't receive here, the instance has no shared domain, the instance doesn't receive mail at all, a username breaks a rule, is reserved or can't be used on the shared domain, username or domain_id comes with email_address, domain_id isn't a verified domain of your team or names one of your own domains without username, or a setting breaks a rule. |
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 still waits for its name review, and until then no address can be created on it, random and forwarding ones included (retry-after: 300). Try again shortly. |
The other calls:
GET /inboxeslists inboxes, newest first;?kind=agentor?kind=humannarrows it.GET /inboxes/{id}reads one, by id or by address.PATCH /inboxes/{id}changesname,from_nameorexpires_inand answers the whole inbox. On the shared domain a newfrom_nameis reviewed first, with the same answers as at creation.DELETE /inboxes/{id}deletes the inbox with its threads, drafts and labels. Messages sent only to the inbox's address go with it; a message that also reached another inbox, or a catch-all address, stays there. The inbox's API keys are revoked first.
With Resend's preview SDK:
import { Resend } from "resend"; // resend@6.32.1-preview-inboxes.6
// RESEND_API_KEY=ms_... RESEND_BASE_URL=https://api.millionsend.com
const resend = new Resend(process.env.RESEND_API_KEY);
const { data } = await resend.inboxes.create({
emailAddress: "orders@inbound.example.com",
name: "Orders agent",
});Addresses
Your own domain
An inbox on your own domain needs the domain to receive here: verified, with receiving on and its MX record published. Each message then goes by the address the sending server delivered to:
- to the inbox with exactly that address;
- otherwise, for
name+tag@, to the inbox ofname@, whilereceived_forkeeps the address as written; - otherwise, when the domain's catch-all is on, to All received,
without an inbox. With catch-all off, the address is refused during the
SMTP session (
550 5.1.1), so the sender's own server returns the message to its sender.
Catch-all starts off, and is on for domains that already received mail before inboxes existed. On MillionSend Cloud it needs a paid plan. See Which addresses receive.
Mail sent from an inbox's address follows the inbox's rules on every
surface, POST /emails, the batch endpoint, the SMTP relay and MCP
included, and an agent inbox starts as reply_only. Give an inbox an
address your app doesn't already send from.
Shared domain
An instance can offer a shared domain for inboxes that need no DNS at all
(its operator sets SHARED_INBOX_DOMAIN; see
Self-hosting). Each team gets its own
subdomain there when the team is created, named after it, such as
acme.agents.example, or team- and six random characters when the name
looks like a well-known brand or a reserved name, or the review below
doesn't pass it. Until its first address, the subdomain follows the team's
renames. Once an address has been on it, it never changes and is never
given to another team, even after its team is deleted; one that never had
an address is freed by a rename or by the team's deletion. A subdomain that
already existed before v0.14.0 never changes either, address or not.
GET /domains lists the subdomain as its last row, with shared: true and
its sending_mode (see Domains); its
id is the domain_id of your shared inboxes. Leave email_address out, or
send that id as domain_id, and username names the address:
curl -X POST "https://api.millionsend.com/inboxes" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-d '{ "username": "orders", "name": "Orders agent" }'That inbox is orders@acme.agents.example. Without username the address
is random, such as inbox-7f3k9q2m@acme.agents.example. In the dashboard's
create dialog, type the username, or leave the field empty for a random
address.
A chosen username answers 422 when it is reserved (postmaster, abuse,
admin, security, support, noreply, no-reply, billing, hostmaster, webmaster,
mailer-daemon and root, compared without ., _ and -, so no.reply
counts), starts with fwd- (the shape of forwarding addresses) or looks
like a well-known brand. One that's taken answers 409: another name is
never picked for you.
Name review. Where the instance runs a content judge, a chosen username,
a from_name (at creation and on every change) and the team's subdomain are
reviewed before they're published, for posing as a brand, a bank, a
government or an official role. A refused name answers
422 validation_error, such as "username can't be used on the shared
domain: pick another", never with the judge's reasoning. While the review
can't answer, a username or from_name answers
503 name_review_unavailable with retry-after: 60. A new team waits for
its subdomain instead of taking a random one at once: the review is tried
again after 5 minutes, then hourly, and after 24 hours the team takes
team- and six random characters. Until then, creating an address on the
shared domain answers 503 name_review_unavailable with retry-after: 300:
try again shortly. A renamed team keeps its subdomain until the review
answers. After 20 refused names, or 200 reviews, in a UTC day, the answer is
429 rate_limit_exceeded until midnight UTC. A random address with no
from_name is never refused over the review, though it waits with the
503 above while the team's subdomain does; an instance without a judge
applies only the rules above.
Mail to postmaster@ and abuse@ at the shared domain, or at any team's
subdomain, goes to the instance's operator, never to an inbox.
Shared addresses follow rules of their own, since the domain is every team's:
-
They last until deleted, unless
expires_insays otherwise, and count toward your plan's inbox limit until then. One nobody used for 90 days is deleted (see Expiry). -
What they send depends on your plan and the instance:
- On a paid plan, where the instance allows it, people start
conversations from them, and an agent does when the inbox's
send_policyisopenand your team is past its first sends; otherwise its new conversations wait for a person as drafts. Each message to someone outside your team who never wrote to the inbox is judged by the instance's content judge before it goes out, and held for review when it reads as phishing. - On the Free plan, they only reply inside their own threads ("On the Free
plan, addresses on the shared domain only reply inside their threads").
So does every team's while the instance limits shared addresses to
replies, and an inbox whose
from_name, address or team subdomain looks like a well-known brand, or whose subdomain is a reserved name ("Addresses on the shared domain only reply inside their threads"). Replies go to senders whose domain vouched for them and to your team members' sign-in addresses. - While the instance pauses shared sending, nothing goes out, replies included ("Sending from addresses on the shared domain is paused; mail to them still arrives").
Each of these refusals answers
403 inbox_send_policy, approved or not. A storedsend_policyis never rewritten: it applies again once the plan or the instance allows it. - On a paid plan, where the instance allows it, people start
conversations from them, and an agent does when the inbox's
-
Owner addresses don't count here, for mail coming in or going out. The allowlist counts for incoming mail only: it lets its senders in, but as a recipient an address on it is like any other (
send_policycan't beallowlist), and aReply-Toat another organization never counts.forward_totakes members' sign-in addresses only: the shared domain is every team's, so it never writes to an address nobody proved. -
They send to 10 recipients per email at most (
422 validation_error) and 50 a day per inbox (send_capfrom 1 to 50). All your team's shared addresses together reach 1,000 recipients per UTC day, unless the instance sets another limit; past it, approved sends included, they answer429 inbox_send_cap_reached("Your team reached today's limit for mail from the shared domain"). -
A
forward_tocopy waits instead of being lost: while the instance pauses shared sending, and, once your team is past today's limit, until the next UTC day. If it still can't go out 2 days after the message was delivered, it isn't sent; the message stays in the inbox. -
They go through
POST /emailsand the MCP toolsend_emailonly where they may start conversations, under the inbox's rules.frommust be the inbox's address exactly, without a+tag, with no display name or the inbox'sfrom_name, and the email can't carryscheduled_at,reply_to, attachments or custom headers: each answers422 validation_errornaming the rule. A key restricted to a domain gets403 restricted_api_key. ElsewherePOST /emailsanswers403 inbox_send_policy, andPOST /emails/batch, broadcasts and the SMTP relay always refuse a shared address.
On an instance without a shared domain, POST /inboxes without
email_address answers 422.
Forwarding into an inbox
forwarding: true, Resend's option, is for an address whose mail lives
elsewhere, such as a Google Workspace mailbox. The inbox gets a
receiving_address, a random address on your team's shared subdomain:
forward the mailbox there, and the inbox receives it while replying from its
own address. The domain then only needs to be verified for sending, not to
receive here. It needs the instance's shared domain.
Expiry
expires_in is a number followed by h or d, from 1h to 365d; left
out, the inbox never expires. A PATCH with a new expires_in counts from
that moment, and null removes the expiry. expires_at shows when it ends.
A shared inbox from before addresses there became permanent keeps its
expires_at until a PATCH changes it.
At expires_at the inbox stops: mail to it is refused (550 5.1.1), or, on
a domain with catch-all on, kept in All received like mail to any other
address; reads answer 404 Inbox not found. and its API keys stop working.
Within the hour it is deleted as a DELETE would, with an inbox.expired
webhook followed by inbox.deleted, and its API keys are revoked. Making an
inbox expire sooner than it would takes the owner or admin role, as deleting
it does; any member can extend it.
A shared-domain inbox nobody used for 90 days goes the same way within the
hour, whatever its expires_at. Use is mail delivered to it (spam doesn't
count), anything its Activity records from a person, an API key or an
app, such as a send or a code read, and any API or MCP call under
/inboxes/{id}/.
Threads
Every message of an inbox belongs to a thread, and a thread holds both directions: what the inbox received and what it sent.
How replies find their thread. Mail the inbox sends carries a thread
token in its References header. A reply lands in the same thread when it
carries that token, when its In-Reply-To names a message the inbox sent,
or when it refers to an earlier message of the thread by its Message-ID.
Subjects never join threads. The first two cases also prove that the reply
belongs to the conversation, which can make its sender a
known thread.
Folders. A thread sits in one folder: inbox, archive, spam,
sent (threads with only mail the inbox sent; set automatically) or
trash. New mail moves an archived or sent thread back to inbox.
DELETE moves a thread to trash (with deleted: false when it was there
already), and a trashed thread is deleted after 30 days unless it is moved
out first.
Read state. Every received message is read or unread. A PATCH with
read marks the whole thread. A thread is read when all its messages are,
and the inbox's unread counts the threads with an unread message. Mail the
inbox sent is always read. Held mail stays out of read state until it's
released: it never makes a thread unread, can't be marked read, and arrives
unread when released. So a thread holding only held mail, listed with
include_quarantined=true, has read: true, and its quarantined counts
the messages in quarantine; mail still being screened isn't counted. In the
dashboard, opening a thread marks it read in a people inbox. In an agent
inbox, read state belongs to your agent: it marks a thread read once it's
handled, and the threads listed with read=false are what's left. In either
kind, a person can mark a thread read or unread from its menu.
Labels. Up to 100 per inbox. Names take 1–64 characters and are unique
regardless of letter case; colors are #RRGGBB, in uppercase, and random
when left out. A PATCH with label_id puts one label on a thread at a
time. Labels come off a thread in the dashboard, and deleting a label takes
it off every thread.
| Call | Does |
|---|---|
GET /inboxes/{id}/threads | Threads, latest activity first. folders (inbox by default, inbox,archive,sent with labels), labels (up to 50 ids; any of them matches), read (true or false), include_quarantined=true (a full-access key that isn't restricted to an inbox) to list held mail too, with each thread's quarantined count. |
GET /inboxes/{id}/threads/search | The same filters, plus query (up to 256 characters and 32 words; "exact phrase" and -excluded work), from, to, cc and bcc (up to 20 values each, matching part of an address or name), has_attachment, start_date and end_date. Each result adds matched_email_id and highlights. |
GET /inboxes/{id}/threads/{thread_id} | One thread: subject, folder, labels and read state. |
PATCH /inboxes/{id}/threads/{thread_id} | read, folder (inbox, archive, spam or trash) or label_id. |
DELETE /inboxes/{id}/threads/{thread_id} | Moves the thread to trash. |
GET /inboxes/{id}/threads/{thread_id}/emails | Its emails, oldest first; after pages toward newer ones. |
GET /inboxes/{id}/threads/{thread_id}/emails/{email_id} | One email. |
Search reads subjects, senders (address and display name) and recipients, not message bodies or attachment names: those are encrypted at rest. It covers the 10,000 threads with the newest matches.
A thread email has Resend's fields: direction, from, to, cc, bcc,
reply_to, subject, message_id, html, text, attachments, read
and received_at. Received mail adds extracted_text (the reply, cleaned of
hidden text; see
Agent safety),
screening, spam_verdict and virus_verdict. Mail the inbox sent adds
status, its latest delivery event as on GET /emails/{id}. text comes
without invisible characters, and html exactly as received.
"screening": {
"verdict": "clean",
"sender_trust": "external",
"quarantine_reason": null,
"reasons": [],
"injection_score": 3,
"phishing_score": 1,
"hidden_text_removed": false
}Reply and forward
curl -X POST "https://api.millionsend.com/inboxes/{id}/threads/{thread_id}/emails/{email_id}/reply" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-reply" \
-d '{ "text": "Thanks, your order ships today." }'A reply goes to the message's Reply-To, or else to its sender; replying to
a message the inbox sent goes to that message's recipients. reply_all adds
the message's other to and cc addresses as cc. bcc is never copied,
and one send takes 50 recipients at most, 10 from a shared address. The
subject defaults to Re: and
the thread's subject. The reply carries In-Reply-To, References with the
thread token and, from an agent inbox, Auto-Submitted: auto-replied.
…/forward needs to, takes a note in text or html, and quotes the
original under a ---------- Forwarded message --------- line with its
From, Date, Subject and To. The original's attachments go along, up
to 10 MiB in all; past that, 422. The subject defaults to Fwd: and the
thread's subject.
Both take requires_approval: true to have a person approve the send, and
answer:
| Answer | Means |
|---|---|
200 | Sent: the email, with email_id, thread_id and inbox_id. |
202 | Held for a person: the draft, with status: "pending_approval" and its approval_reasons. |
403 inbox_send_policy | The inbox's rules refuse the send outright, such as a shared address starting a conversation where it may only reply. |
403 email_quarantined | The email is held in quarantine, or carries a virus (forwarding). |
403 restricted_api_key | An inbox-scoped key reaching another inbox. |
429 inbox_send_cap_reached | The inbox's daily cap is used, or your team's daily limit for the shared domain; retry-after counts to midnight UTC. |
To reply to any received email, in an inbox or not, use
POST /emails/receiving/{id}/reply.
Drafts and approval
Drafts are Resend's: standalone, for a new conversation, or replies, with
thread_id and reply_to_email_id together. MillionSend adds status:
status | Means |
|---|---|
draft | Being written; nothing sends it until …/send. |
pending_approval | A send was held and waits for a person; approval_reasons says why. It expires after 7 days. |
sent | Sent; email_id names the email. |
rejected | A person turned it down. |
expired | Nobody approved it within 7 days. |
Drafts also carry forward (a held forward), decided_by, decided_at and
expires_at.
| Call | Does |
|---|---|
POST /inboxes/{id}/drafts | Creates a draft: to, cc, bcc, subject, text and html, plus thread_id and reply_to_email_id for a reply. An email has one unsent reply draft at most: asking again answers 200 with the existing one. A reply draft doesn't copy recipients, so set to before sending. |
GET /inboxes/{id}/drafts | Unsent drafts, most recently updated first. |
GET /inboxes/{id}/drafts/{draft_id} | One draft, also once sent. |
PATCH /inboxes/{id}/drafts/{draft_id} | Changes fields. Setting text or html replaces the whole body: the part left out becomes null. 409 once sent, or when another request changed it meanwhile. An agent's edit sends a pending draft back to draft. |
DELETE /inboxes/{id}/drafts/{draft_id} | Deletes it; 409 once sent. |
POST /inboxes/{id}/drafts/{draft_id}/send | Sends it: no body, Idempotency-Key accepted. 200 with email_id, 202 when held, 409 once sent, 422 without a recipient. |
POST /inboxes/{id}/drafts/{draft_id}/reject | Turns a draft down (approvers only). |
Drafts are stored without checking their recipients: a draft with a
recipient whose domain can never receive mail, such as x@example.com, is
refused with 422 validation_error when it is sent or approved (see
Errors).
Who approves. Sending a draft runs the inbox's rules for whoever sends
it. A person in the dashboard, or a full-access API key of the team that
isn't restricted to an inbox, approves by sending: the send skips the rules
that hold agent mail. It still counts toward the daily cap (without being
refused by it), a shared address still follows the
shared domain's rules, and your team's usual sending
limits apply. An agent can't approve: an
inbox-scoped key or an MCP client sending a pending draft gets
403 approval_required, and one sending a plain draft that the rules hold
gets 202, with the draft now pending.
In the dashboard, any member approves, edits or discards drafts under the inbox's Needs review tab, and owners get one email a day while there is something to review.
The reasons a send was held:
| Reason | Held because |
|---|---|
approval_required | The request asked for a person (requires_approval: true). |
require_approval | The inbox has every send approved. |
send_policy | A recipient isn't allowed by the inbox's send policy. |
ramp | Too many new recipients today for a young inbox. |
private_code | It contains a code or sign-in link the inbox received. |
loop_guard:<reason> | It replies to an automatic message (an out-of-office, a mailing list, a bounce…). |
loop_burst | Too many automatic replies to one address within an hour. |
reply_to_redirect | The message's Reply-To points to another organization. |
Safety settings
GET /inboxes/{id}/safety reads an inbox's rules, and PATCH changes any of
them and answers the whole set:
curl -X PATCH "https://api.millionsend.com/inboxes/{id}/safety" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-d '{ "accept_from": "allowlist", "allowlist": ["*@billing.example.net", "maria@example.org"] }'| Setting | Does |
|---|---|
accept_from | Whose mail reaches the inbox: anyone; verified, senders whose domain vouches for the From address, plus owners, the allowlist and known threads; or allowlist, owners, the allowlist and known threads only. The rest is quarantined. |
allowlist | Up to 500 entries: name@domain, exact and as written, or *@domain for a domain and its subdomains. An entry counts only for mail whose domain vouches for it. On the shared domain it counts for incoming mail only. |
owner_addresses | Up to 50 more addresses treated as the inbox's owners, besides your team members' sign-in addresses. Ignored on the shared domain. |
screening | Score mail from outside senders for prompt injection and phishing (agent inboxes). |
extract_codes | Pull verification codes and sign-in links out of mail (extracted.code, extracted.link, codes/latest). |
send_policy | reply_only; allowlist, which adds new conversations with owners and the allowlist (not on the shared domain); or open, anyone, once the team is past its first sends, and on the shared domain only where it may start conversations. |
require_approval | Every send from the inbox waits for a person. |
private_codes | A code or sign-in link the inbox received can't be sent to anyone but owners and the allowlist. |
send_cap | Recipients the inbox may send to per UTC day without a person: 1–100,000, or null for no cap; 1–50 on the shared domain. |
forward_to | Up to 5 addresses that get a copy of every delivered message except spam: team members' sign-in addresses or owner addresses (on the shared domain, sign-in addresses only). |
A setting that breaks a rule answers 422 with the reason: send_policy
allowlist on the shared domain, open on a shared address that may only
reply or for a team still new to sending, screening on a human inbox, or
a forward_to address that is neither a member's nor an owner address. An
inbox-scoped key can't change these settings (403 restricted_api_key), the
MCP server has no tool for them, and in the dashboard only owners and admins
can. Agent safety explains what each one does.
Quarantine
Mail an inbox's rules don't let in is quarantined: kept, listed and
releasable, never dropped and never returned to its sender. It fires no
email.received or inbox.email.received until it is released, and agents
never see it (see Agent safety).
GET /inboxes/{id}/quarantine lists the held messages, newest first, with
id, thread_id, from, subject, received_at, quarantine_reason,
sender_trust, injection_score, phishing_score and virus_signature,
and no content. POST /inboxes/{id}/quarantine/{email_id}/release delivers
one, and { "trust_sender": true } also adds its sender to the allowlist:
{
"object": "inbox_release",
"inbox_id": "7eeb9517-aed7-40d7-ad65-500be42ed147",
"email_id": "155252fb-e93e-45ee-8c19-5080a929c28b",
"released_at": "2026-10-12T14:20:05.112Z",
"trusted_sender": "maria@example.org"
}Both take a full-access key that isn't restricted to an inbox (inbox-scoped
keys get 403). A release can also answer:
| Status, name | When |
|---|---|
404 not_found | The message isn't quarantined in the inbox. |
409 held_by_screening | Screening at release found prompt injection or phishing at 80 or more: the message stays in quarantine under that reason, with its scores. |
403 trust_not_allowed | trust_sender on anything but a message held as not_allowlisted from a verified (external) sender. |
403 forbidden | An MCP client releasing mail held for injection, phishing, virus or failed_auth. |
503 screening_unavailable | An MCP client releasing while screening can't run; retry-after says when to try again. |
422 validation_error | trust_sender while the allowlist is full. |
People release from the dashboard, and MCP clients with receiving:manage
with release_email. Agent safety
explains who may release what, and the screening a release runs first.
Verification codes
GET /inboxes/{id}/codes/latest answers the newest verification code or
sign-in link the inbox received, so an agent never has to read HTML:
curl "https://api.millionsend.com/inboxes/{id}/codes/latest?from=example.org&wait=30" \
-H "Authorization: Bearer ms_..."{
"object": "verification_code",
"status": "found",
"kind": "link",
"code": null,
"url": "https://app.example.org/verify?token=Qm9vdHN0cmFwLTQ4MjkxMw",
"link_domain_matches_sender": true,
"email_id": "155252fb-e93e-45ee-8c19-5080a929c28b",
"from": "no-reply@example.org",
"received_at": "2026-10-12T14:03:11.229Z",
"sender_trust": "external"
}kindisotp, with the code incode, orlink, with the address inurland whether its domain is the sender's inlink_domain_matches_sender.fromnarrows it to a sender: an address, or a domain with its subdomains.wait, from 0 to 50 seconds, waits for one to arrive;max_age, from 60 to 86,400 seconds (600 by default), skips codes delivered to the inbox longer ago than that; a code released from quarantine counts from its release.since, an ISO 8601 time, skips mail received at or before it. To wait for the code of a second sign-in, pass thereceived_atof the code you already used.statusisfound;none;ambiguous, when two different codes or links are equally likely, so none is answered; ortimeout, whenwaitran out with nothing new. The answer is always200.- Codes never come from quarantined mail, from mail still being screened,
from senders who failed authentication, or from an inbox with
extract_codesoff. Every read is recorded in the inbox's activity.
GET /emails/receiving/{id} also carries the code or link of one email, in
extracted.
Waiting for mail
GET /emails/receiving?inbox_id={id}&wait=30 holds the request until a
message is delivered to the inbox, for up to 50 seconds; see
Waiting for new mail. Webhooks
push the same news instead.
Inbox-scoped API keys
Give each agent a key that reaches only its own inbox:
curl -X POST "https://api.millionsend.com/api-keys" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-d '{ "name": "orders agent", "permission": "full_access", "inbox_id": "7eeb9517-aed7-40d7-ad65-500be42ed147" }'- It needs
permission: "full_access"and can't take adomain_idtoo. An inbox holds 5 active keys at most, outside the team's limit of 25. - The key reads its inbox (threads, emails, drafts, labels, codes, and
/emails/receivinglimited to mail delivered into it), replies, forwards, writes drafts and sends them, and sends from the inbox's address only: throughPOST /emailsandPOST /emails/batch, anotherfromanswers403 restricted_api_key, and through the SMTP relay550 5.7.1. - Everything else answers
403 restricted_api_key: it can't change safety settings, list or release quarantine, approve drafts, create keys or reach any other inbox. - It stops working when its inbox expires, and deleting the inbox revokes it.
A key scoped to a domain (domain_id) reaches only the inboxes on that
domain, as it reads only mail received there: others, shared-domain inboxes
included, read as not found, and it creates inboxes on its own domain only.
In the dashboard, owners and admins pick the inbox under Restrict to an inbox when creating a key.
Webhooks
Inbox events (inbox.created, inbox.thread.created,
inbox.email.received, inbox.email.sent, inbox.email.quarantined, the
inbox.draft.* events and more) go only to endpoints that name them, never
to an endpoint subscribed to all events, and an endpoint can be limited to
some inboxes with inbox_ids. email.received still fires for inbox mail,
once it is delivered. Payloads and caveats:
Webhooks.
Activity
Everything done in an inbox is recorded: sends and the rules' decisions, refusals, drafts, approvals and rejections, releases, code reads, safety changes, deletions and keys. The inbox's Activity tab lists it with who acted: a person, an API key or a connected app. Decisions keep reason codes only, never content, codes or addresses. Owner and admin actions in the dashboard also go to the team's audit log.
Limits
| Limit | Value |
|---|---|
| Inboxes | 3 on Free, 25 on Starter, unlimited on Pro and Scale. Self-hosted instances have no limit. |
| Inboxes created per hour | 10 on Free, 60 on Starter, 600 on Pro and Scale; no limit self-hosted. |
| Mail per inbox | 200 accepted messages per rolling hour for an agent inbox, 2,000 for a people inbox. Past that, senders get 452 4.2.2 and try again later. |
| Sends | send_cap recipients per UTC day. A new inbox reaches new recipients gradually: 10 on its first day of sending, then 20, 40, 80, 160 and so on, up to the cap. |
| Recipients per send | 50: to, cc and bcc together; 10 from a shared address. |
| Shared domain | 1,000 recipients per UTC day for all your team's shared addresses, unless the instance sets another limit; 20 refused names and 200 name reviews per UTC day; a shared inbox unused for 90 days is deleted. |
| Labels | 100 per inbox. |
| Safety lists | 500 allowlist entries, 50 owner addresses, 5 forward_to addresses. |
| Inbox-scoped keys | 5 active per inbox. |
| Screening | 200 messages per inbox per day; past it, mail that needs screening is quarantined (screening_cap). |
| Waiting | 50 seconds per request; 20 waits at once per team on each API process. |
| Drafts and trash | A pending draft expires after 7 days; a trashed thread is deleted after 30. |
| Retention | Inbox mail follows received mail's retention: 30 days on Cloud. |
Inboxes themselves cost nothing: received mail counts once per message, as in Receiving → Pricing.
Dashboard
Inboxes in the sidebar lists your inboxes under the All, Agent and People tabs, with what needs attention: drafts to approve, unread threads (in people inboxes) and messages in quarantine. A pinned All received row opens every email your domains received, in an inbox or not, and each catch-all domain gets a row for the mail at its other addresses. An inbox's page has four tabs: Threads, where you read, reply and approve drafts in place, Needs review (drafts to approve and quarantine), Safety & rules and Activity.
Any member can create inboxes, work their threads, approve drafts and release quarantine. Owners and admins can also delete inboxes, change safety rules, trust the sender of a message they release, create inbox-scoped keys, delete received mail and switch catch-all.
MCP
MCP clients work with inboxes under three permissions:
receiving:read (read mail, wait for it, read codes, list inboxes, threads,
labels and drafts), receiving:reply (reply, forward and organize threads)
and receiving:manage (create, change and delete inboxes, list and release
quarantine). An MCP client always sees mail the way an agent does, without
held mail and with message text fenced as untrusted. It can't change safety
settings, beyond picking accept_from when it creates an inbox; it never
approves drafts, never trusts a sender, never releases mail held for its
content or a failed authentication, and releases other held mail only on
screening's verdict.
Resend compatibility
The 26 endpoints of Resend's Inboxes preview are served at the same paths
and with the same shapes, so resend.inboxes.* from
resend@6.32.1-preview-inboxes.6 works against MillionSend, and list orders
follow Resend's (see Pagination). Where Resend
leaves room, or MillionSend differs:
- Creating an inbox or a draft answers the whole object; Resend's
{ object, id }is part of it. - Resend requires
email_address; here it is optional: left out, with nodomain_id, the inbox gets an address on the shared domain, random through a Resend SDK, which can't sendusername, and permanent, since it can't sendexpires_ineither. kinddefaults toagent, since a Resend SDK can't send it, so inboxes created through one get an agent's protections. Createhumaninboxes in the dashboard, with a MillionSend SDK or over plain HTTP.- MillionSend runs no hosted agent:
GET /inboxes/{id}/agentanswers{ "object": "inbox_agent", "instructions": null, "tone": null, "enabled_actions": [] }, andPATCH /inboxes/{id}/agentanswers422. - Thread search reads subjects, senders and recipients, not bodies or attachment names.
- A reply draft doesn't copy recipients; setting
textorhtmlon a draft replaces the whole body; a forward carries the original's attachments, up to 10 MiB; sending a draft needs at least one recipient. - A reply, forward or draft send held by the inbox's rules answers
202with the draft instead of200with the email: checkobject, which readsinbox_draft, before treating the result as sent. - New errors:
403 inbox_send_policy,429 inbox_send_cap_reached,403 approval_required,403 email_quarantinedand503 name_review_unavailable(see Errors). An address already in use, or a draft edited after it was sent or while another request changed it, answers409 validation_error. - MillionSend's additions are extra fields (
usernameanddomain_idon create,kind,shared,expires_at,screening,extracted_textand others) and extra endpoints (/safety,/quarantine,/codes/latest,/drafts/{id}/reject). Resend's enums never gain a value: quarantined mail is hidden from agents rather than put in a new folder, and a webhook'ssourceisapi,dashboardorsystem, neveragent. - Label colors are hex only, as in Resend's latest preview.
from_namekeeps Resend's 78 characters but takes a plain name only: no@, links or phone numbers, which answer422.inbox.*webhook events are opt-in;inbox.expiredandinbox.email.quarantinedare MillionSend's own, andinbox.thread.assignedandinbox.thread.unassignedaren't sent.