MailFlatDocs
Documentation/API References/Agent API & MCP

Agent API & MCP Server

API endpoints and MCP integrations to easily connect MailFlat to AI agents (GPT, Claude, Cursor).

Endpoints

Every route below authenticates with an account key: X-API-Key: mf_live_… (create one in Agents → API keys).
Method & pathDoesReturns
POST /api/v1/inboxesOpen a new agent inbox (always plain text){ address, retention_hours, … }
GET /api/v1/inboxesList every agent inbox this key can see{ inboxes: [ … ] }
GET /api/v1/inboxes/{addr}/messagesAll messages in the inbox{ emails: [ … ] }
GET /api/v1/inboxes/{addr}/latestNewest message only, the OTP polling call{ email: { … } }
GET /api/v1/inboxes/{addr}/messages/{id}One message by id, and how you check what happened to a send{ email: { … } }
POST /api/v1/inboxes/{addr}/sendSend a DKIM-signed mail from this inbox. Answers 202: accepted, not yet delivered{ ok: true, queued: true, message_id }
DELETE /api/v1/inboxes/{addr}Delete the inbox and its messages{ ok: true }
DELETE /api/v1/inboxes/{addr}/messages/{id}Delete one message{ ok: true }
GET /api/v1/inboxes/{addr}/messages/{id}/attachments/{aid}Download one attachmentthe file itself, or the envelope on an encrypted inbox
Message fields worth knowingattachments · headers · links · spam
FieldTypeMeaning
attachmentsarrayMetadata only: id, filename, content_type, size_bytes, is_encrypted, truncated. The bytes come from the attachment endpoint above; putting them in every listing would make a single 5 MB file weigh down every call.
headersobjectEvery header as it arrived. Repeating ones (notably Received) come back as a list, because the chain is the delivery path. null on an encrypted inbox: headers carry the Subject, so they travel inside the envelope instead.
linksarrayEvery http(s) URL in the message, in order, de-duplicated. Magic links without a regex.
spamobject | nullSpamAssassin result: score, the required threshold it was judged against, is_spam, and every rules[] entry that fired with its own weight. null means the message was never scanned, not that it is clean; 0.0 is a real result. Stays readable on encrypted inboxes: rule names carry no message content. Nothing is filtered or blocked on our side.
Attachment limitssending: 1 file(s) · 512 KB on Free
Sending (depends on your plan)FreeWhat happens past it
Files per message1Rejected with 400 and the limit named. Nothing is dropped silently.
All files in a message512 KBSame. Paid plans raise both, so read them from GET /api/plans.
Receiving (fixed, not plan-based)ValueWhat happens past it
Files per message10Later files are not recorded.
One file5 MBKept as metadata with truncated: true. The entry stays so an oversized file is distinguishable from one that never arrived.
All files in a message10 MBSame: the entry stays, the bytes do not.
Downloading a truncated attachment
Returns 400 with Attachment was too large to store rather than an empty file, so a test fails loudly instead of asserting on zero bytes.
POST /api/v1/inboxes body fieldsevery field is optional
FieldTypeMeaning
prefixstringPart before the @. Omit it and one is generated for you.
subdomainstringNamespace after the @ on mailflat.net. Random when omitted; paid plans only.
domainstringOne of your verified BYOD domains, e.g. acme.comprefix@acme.com.
labelstringA human name for the inbox, shown in the dashboard. Paid plans only. On Free the inbox gets a generated name and the response says so (see below).
retention_hoursintHow long messages are kept. Values above your plan max are capped rather than rejected; 0 and negatives are rejected with 422.
A field your plan does not include is reported, not dropped
label and subdomain need a paid plan. On Free the inbox is still created, but the response carries ignored_fields naming what had no effect and a note explaining why. Nothing is silently swallowed: an unknown field is a 422, and a known field your plan does not cover is applied-or-explained, never quietly ignored.
POST /api/v1/inboxes/{addr}/send body fieldsto is required
FieldTypeMeaning
toemail, requiredRecipient address.
subjectstringDefaults to empty.
bodystringPlain-text body.
htmlstringOptional HTML body. Use it for templated mail; plain body alone is fine.
ccarray of emailsCopied recipients. They appear in the mail's headers, so everyone sees them.
bccarray of emailsBlind-copied recipients. They receive the mail but never appear in its headers, not even in their own copy.
attachmentsarrayFiles to attach: { filename, content_b64, content_type? }. Base64 of the raw file bytes, no data: prefix. The SDKs encode this for you.
in_reply_tostringA Message-ID to answer. Keeps the mail in the same conversation instead of starting a new one.
Attachment limits come from your plan
Total size per message and file count both depend on the plan, and Free is deliberately small. Going over returns 400 with the limit spelled out; nothing is silently dropped. Sizes are measured on the raw bytes, but base64 makes the request itself about a third larger on the wire.
send answers 202, not 200: what that changesaccepted ≠ delivered
Delivery runs on a queue, so the call returns in milliseconds and nothing has been delivered yet. The response carries message_id; the result arrives one of two ways:
Pull: read the message back with GET .../messages/{id} and look at send_status: queued retryingsent | unsigned | failed(with send_error). queued means accepted but not attempted yet; retrying means an attempt was made, did not get through, and is scheduled again, and send_error says what the last attempt reported. The SDKs wrap this as wait_until_sent().
Push: subscribe to the message.delivered /message.failed webhooks. Preferred for anything long-running: polling costs a request even when nothing changed.
A temporary failure is not the end. The queue retries with increasing delays (a greylisting recipient can hold mail for minutes), so a message still sitting at queued is on its way, not lost.
Errors you can actually hit400 · 401 · 403 · 404 · 422
StatusWhenDetail message
400A plan or business rule blocked itFree plan allows at most 3 agent inboxes…
401Missing key, or an inbox key used on a v1 pathA valid API key is required
403The key does not own that inboxNo inbox …@… for this API key…
404That message or attachment is not in that inboxMessage 42 not found in inbox …
422A field is missing, mistyped, or not a field at allInvalid request: 'saison' is not a field POST /api/v1/inboxes accepts
422A text field is too long, or is not storable textInvalid request: 'subject' is too long (201 characters). The limit is 200 characters
Text limits are checked before anything is queued
Every text field has a length limit and must be valid, storable text: no line breaks in header fields, no NUL, no unpaired surrogates. Going over or sending unstorable text is a 422 that names the field and the limit, at the boundary. It is never accepted with 202 and failed later: work that cannot succeed should not enter the queue, and the caller should not have to wait to find out.
detail is always a sentence, never a raw validation dump, safe to show a user or feed back to a model. The 403/404 split is deliberate: an inbox you do not own is always 403, whether or not it exists, so the API cannot be used to discover other people's addresses. Once you are inside an inbox you own, a wrong message id is an honest 404, which is what lets the SDKs distinguish “wrong id” from “wrong key”.
Unknown fields are rejected, not ignored
A field this API does not know is a 422, not a silent drop. This matters most for public_key: sending it used to return 200 and create a plaintext inbox, so you could believe you had opened an end-to-end encrypted one when you had not. Inboxes created through this API are always plaintext by design. That is what lets the API read otp_code and message bodies for you. End-to-end encryption is account-level and is set up in the dashboard.
Reads are not rate limited
Polling /latest every couple of seconds is the intended use, so there is no request ceiling on this API. Your plan quotas are the bound. Rate limiting applies only to the anonymous auth endpoints (sign-up, login, password reset), which an agent never touches.
Encrypted inboxes answer differently
If you point these routes at an end-to-end encrypted inbox, the response carries encrypted: true and a note explaining that body and otp_code are unavailable, because the server genuinely cannot read them. Inboxes created through this API are never encrypted, so this only happens when you target an inbox from the human pool.

The full loop

Create → use → poll → clean up. The same four steps in every language.
# 1) create a short-lived inbox (2h retention)
curl -X POST https://mailflat.net/api/v1/inboxes \
-H "X-API-Key: mf_live_…" -H "Content-Type: application/json" \
-d '{"prefix":"agent-7f3","retention_hours":2}'
# → { "address": "agent-7f3@a7f2c.mailflat.net" }
# 2) poll the latest mail (OTP auto-extracted)
curl https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net/latest \
-H "X-API-Key: mf_live_…"
# → { "email": { "subject": "Your code", "otp_code": "482913" } }
# 3) send a reply from this inbox (DKIM-signed)
curl -X POST https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net/send \
-H "X-API-Key: mf_live_…" -H "Content-Type: application/json" \
-d '{"to":"user@gmail.com","subject":"Re: hi","body":"Hello back!"}'
# 4) clean up when done
curl -X DELETE https://mailflat.net/api/v1/inboxes/agent-7f3@a7f2c.mailflat.net \
-H "X-API-Key: mf_live_…"
Line by linewhat each step of this curl example does
-H "X-API-Key: …"
Account key, the only auth this API accepts.
retention_hours: 2
Short window so abandoned test inboxes clean themselves up.
/latest
Newest message only. Poll it every 2–3s until otp_code is non-null.
DELETE …
Optional. Retention would purge the messages anyway, but this frees the inbox slot immediately.
The SDKs wrap exactly this loop: wait_for_otp / waitForOtp is the polling block above.

MCP server, give an AI agent inbox tools

MailFlat ships a native Model Context Protocol server. Run it inside Claude Desktop, Cursor or your own agent framework and the model gets eleven inbox tools automatically.
# no install step, uvx fetches and runs it
MAILFLAT_API_KEY=mf_live_… uvx mailflat-mcp
Eleven tools exposed
ToolWhat it does
create_inbox(prefix?, label?, retention_hours?)Open an inbox; retention_hours capped by your plan
list_inboxes()All inboxes this key can see
read_messages(address, direction="in")Read messages; received mail by default, out or all on request
wait_for_otp(address, timeout=30)Poll until an OTP arrives, then return it
wait_for_message(address, timeout=30)Poll until a message arrives; ignores mail the agent itself sent
send_email(address, to, subject?, body?, html?, cc?, bcc?)Send a DKIM-signed mail from the inbox. Accepted for delivery, not delivered
reply(address, message_id, body?, html?, cc?, bcc?)Answer a message in the same conversation, filling in recipient, Re: and threading headers
mark_read(address, message_id)Mark one message read so later polls can skip it
burn_inbox(address)Delete every message but keep the address
delete_inbox(address)Delete the inbox and its messages
delete_message(address, message_id)Delete one message; the inbox itself stays
Attachments are not a model tool
send_email and reply take cc and bcc, which are short strings a model can reasonably choose. Files are not offered here: the bytes would have to travel through the model’s context, which costs tokens and is plainly impossible for a multi-megabyte file. Attach files from the SDK instead, where it is code that decides.
Not using MCP?
Agents → Tool spec → Copy spec gives you the six core ones (create_inbox, list_inboxes, read_messages, wait_for_otp, send_email, delete_inbox) as a raw function schema for GPT, Claude or LangChain, with the same names and parameters. The rest of the list above is MCP-only for now.
GPTClaudeLangChain