MillionSend Docs

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:

SettingAgent, own domainAgent, shared domainPeople, own domainPeople, shared domain
accept_fromverifiedverifiedanyoneanyone
screeningononoffoff
extract_codesonononon
send_policyreply_onlyreply_onlyopenreply_only
require_approvaloffoffoffoff
private_codesononoffoff
send_cap20050none50
  • agent, the default when kind is 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 the spam folder), nothing is screened, and replies carry no Auto-Submitted header.

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.

FieldRules
email_addressAn 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.
usernameWith 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_idThe 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.
name1–320 characters, so any address fits; the address when left out.
from_nameThe 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.
kindagent (the default) or human.
expires_inWhen the inbox expires, such as 1h, 24h or 7d; left out, never.
safetyAny of the safety settings; the rest take the kind's defaults.
forwardingtrue adds a receiving_address on the shared domain, for forwarding a mailbox you keep elsewhere into the inbox (see Forwarding into an inbox).
AnswerWhen
409 validation_errorAnother inbox already uses the address, a chosen username included: no other name is picked for you.
403 validation_errorThe plan's inbox limit is reached: "You have reached the inbox limit of your plan. Upgrade to add more."
429 rate_limit_exceededToo 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_errorThe 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_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 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 /inboxes lists inboxes, newest first; ?kind=agent or ?kind=human narrows it.
  • GET /inboxes/{id} reads one, by id or by address.
  • PATCH /inboxes/{id} changes name, from_name or expires_in and answers the whole inbox. On the shared domain a new from_name is 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:

  1. to the inbox with exactly that address;
  2. otherwise, for name+tag@, to the inbox of name@, while received_for keeps the address as written;
  3. 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_in says 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_policy is open and 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 stored send_policy is never rewritten: it applies again once the plan or the instance allows it.

  • 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_policy can't be allowlist), and a Reply-To at another organization never counts. forward_to takes 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_cap from 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 answer 429 inbox_send_cap_reached ("Your team reached today's limit for mail from the shared domain").

  • A forward_to copy 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 /emails and the MCP tool send_email only where they may start conversations, under the inbox's rules. from must be the inbox's address exactly, without a +tag, with no display name or the inbox's from_name, and the email can't carry scheduled_at, reply_to, attachments or custom headers: each answers 422 validation_error naming the rule. A key restricted to a domain gets 403 restricted_api_key. Elsewhere POST /emails answers 403 inbox_send_policy, and POST /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.

CallDoes
GET /inboxes/{id}/threadsThreads, 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/searchThe 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}/emailsIts 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:

AnswerMeans
200Sent: the email, with email_id, thread_id and inbox_id.
202Held for a person: the draft, with status: "pending_approval" and its approval_reasons.
403 inbox_send_policyThe inbox's rules refuse the send outright, such as a shared address starting a conversation where it may only reply.
403 email_quarantinedThe email is held in quarantine, or carries a virus (forwarding).
403 restricted_api_keyAn inbox-scoped key reaching another inbox.
429 inbox_send_cap_reachedThe 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:

statusMeans
draftBeing written; nothing sends it until …/send.
pending_approvalA send was held and waits for a person; approval_reasons says why. It expires after 7 days.
sentSent; email_id names the email.
rejectedA person turned it down.
expiredNobody approved it within 7 days.

Drafts also carry forward (a held forward), decided_by, decided_at and expires_at.

CallDoes
POST /inboxes/{id}/draftsCreates 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}/draftsUnsent 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}/sendSends 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}/rejectTurns 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:

ReasonHeld because
approval_requiredThe request asked for a person (requires_approval: true).
require_approvalThe inbox has every send approved.
send_policyA recipient isn't allowed by the inbox's send policy.
rampToo many new recipients today for a young inbox.
private_codeIt 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_burstToo many automatic replies to one address within an hour.
reply_to_redirectThe 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"] }'
SettingDoes
accept_fromWhose 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.
allowlistUp 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_addressesUp to 50 more addresses treated as the inbox's owners, besides your team members' sign-in addresses. Ignored on the shared domain.
screeningScore mail from outside senders for prompt injection and phishing (agent inboxes).
extract_codesPull verification codes and sign-in links out of mail (extracted.code, extracted.link, codes/latest).
send_policyreply_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_approvalEvery send from the inbox waits for a person.
private_codesA code or sign-in link the inbox received can't be sent to anyone but owners and the allowlist.
send_capRecipients 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_toUp 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, nameWhen
404 not_foundThe message isn't quarantined in the inbox.
409 held_by_screeningScreening 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_allowedtrust_sender on anything but a message held as not_allowlisted from a verified (external) sender.
403 forbiddenAn MCP client releasing mail held for injection, phishing, virus or failed_auth.
503 screening_unavailableAn MCP client releasing while screening can't run; retry-after says when to try again.
422 validation_errortrust_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"
}
  • kind is otp, with the code in code, or link, with the address in url and whether its domain is the sender's in link_domain_matches_sender.
  • from narrows 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 the received_at of the code you already used.
  • status is found; none; ambiguous, when two different codes or links are equally likely, so none is answered; or timeout, when wait ran out with nothing new. The answer is always 200.
  • Codes never come from quarantined mail, from mail still being screened, from senders who failed authentication, or from an inbox with extract_codes off. 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 a domain_id too. 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/receiving limited to mail delivered into it), replies, forwards, writes drafts and sends them, and sends from the inbox's address only: through POST /emails and POST /emails/batch, another from answers 403 restricted_api_key, and through the SMTP relay 550 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

LimitValue
Inboxes3 on Free, 25 on Starter, unlimited on Pro and Scale. Self-hosted instances have no limit.
Inboxes created per hour10 on Free, 60 on Starter, 600 on Pro and Scale; no limit self-hosted.
Mail per inbox200 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.
Sendssend_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 send50: to, cc and bcc together; 10 from a shared address.
Shared domain1,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.
Labels100 per inbox.
Safety lists500 allowlist entries, 50 owner addresses, 5 forward_to addresses.
Inbox-scoped keys5 active per inbox.
Screening200 messages per inbox per day; past it, mail that needs screening is quarantined (screening_cap).
Waiting50 seconds per request; 20 waits at once per team on each API process.
Drafts and trashA pending draft expires after 7 days; a trashed thread is deleted after 30.
RetentionInbox 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 no domain_id, the inbox gets an address on the shared domain, random through a Resend SDK, which can't send username, and permanent, since it can't send expires_in either.
  • kind defaults to agent, since a Resend SDK can't send it, so inboxes created through one get an agent's protections. Create human inboxes in the dashboard, with a MillionSend SDK or over plain HTTP.
  • MillionSend runs no hosted agent: GET /inboxes/{id}/agent answers { "object": "inbox_agent", "instructions": null, "tone": null, "enabled_actions": [] }, and PATCH /inboxes/{id}/agent answers 422.
  • Thread search reads subjects, senders and recipients, not bodies or attachment names.
  • A reply draft doesn't copy recipients; setting text or html on 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 202 with the draft instead of 200 with the email: check object, which reads inbox_draft, before treating the result as sent.
  • New errors: 403 inbox_send_policy, 429 inbox_send_cap_reached, 403 approval_required, 403 email_quarantined and 503 name_review_unavailable (see Errors). An address already in use, or a draft edited after it was sent or while another request changed it, answers 409 validation_error.
  • MillionSend's additions are extra fields (username and domain_id on create, kind, shared, expires_at, screening, extracted_text and 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's source is api, dashboard or system, never agent.
  • Label colors are hex only, as in Resend's latest preview.
  • from_name keeps Resend's 78 characters but takes a plain name only: no @, links or phone numbers, which answer 422.
  • inbox.* webhook events are opt-in; inbox.expired and inbox.email.quarantined are MillionSend's own, and inbox.thread.assigned and inbox.thread.unassigned aren't sent.

On this page