10 · MailMarco API
Inbound
Point an MX record at MailMarco, write routing rules, and read the mail back over the API — or have it POSTed to you.
Inbound domains
An inbound domain is a name whose MX record points at MailMarco. Use a dedicated subdomain — in.example.com — so your existing mailboxes keep working.
/v1/inbound/domainsRegister an inbound domain and get the MX record to publish.
Body
domainstringrequired | Trimmed, lower-cased, same grammar as a sending domain. |
curl -X POST https://api.mailmarco.com/v1/inbound/domains \
-H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{"domain":"in.example.com"}'{
"id": "4d5e6f70-8192-4a3b-b4c5-d6e7f8091a2b",
"domain": "in.example.com",
"status": "pending",
"mxHost": "mail.mailmarco.com",
"mxPriority": 10,
"verifiedAt": null,
"lastCheckedAt": null,
"createdAt": "2026-07-29T12:00:00.000Z",
"dnsRecord": {
"host": "in.example.com",
"recordType": "MX",
"value": "10 mail.mailmarco.com",
"ttl": 3600
}
}in.example.com. 3600 IN MX 10 mail.mailmarco.com.Then verify. Statuses are pending, verified, failed and suspended. As with sending domains, the MX lookup runs outside any database transaction, and DNS failures are indistinguishable from “not published yet”.
| Route | Auth | Status | Does |
|---|---|---|---|
GET /v1/inbound/domains | Session | 200 | List. |
GET /v1/inbound/domains/{id} | Session | 200 | Retrieve one. |
POST /v1/inbound/domains/{id}/verify | Session | 201 | Re-check the MX record and update the status. |
POST /v1/inbound/domains/{id}/suspend | Session | 201 | Stop accepting mail. |
DELETE /v1/inbound/domains/{id} | Session | 200 | Remove it. Answers { "deleted": true }. |
Routes
A route says what happens to mail for a matching recipient. Every route belongs to one inbound domain.
/v1/inbound/routesCreate a routing rule.
Body
inboundDomainIdstring (uuid)required | Must be an inbound domain in your organization, or the request is a 404. |
matchType"exact" | "pattern" | "catch_all"required | How matchValue is interpreted. |
matchValuestringoptionaldefault "" | Required unless matchType is catch_all (where it is ignored and stored empty). For exact it must be a full address; for pattern it is a * glob over the full address. Lower-cased, at most 320 characters. |
action"webhook" | "forward_email" | "store"required | What to do with a matching message. |
destinationstringoptionaldefault "" | An http(s) URL for webhook, an email address for forward_email, and empty for store. At most 2048 characters. |
enabledbooleanoptionaldefault true | Disabled routes never match. |
priorityintegeroptionaldefault 100 | 0–10000. Lower wins ties. It does not override match specificity. |
curl -X POST https://api.mailmarco.com/v1/inbound/routes \
-H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{
"inboundDomainId": "4d5e6f70-8192-4a3b-b4c5-d6e7f8091a2b",
"matchType": "pattern",
"matchValue": "support-*@in.example.com",
"action": "webhook",
"destination": "https://example.com/hooks/inbound",
"priority": 50
}'Errors
400—matchValuemissing for a non-catch-all rule, anexactvalue that is not a full address, or adestinationthat does not fit the action.404— The inbound domain is not yours.
| Action | Effect |
|---|---|
store | Keep the message for retrieval via /v1/received-emails. destination must be empty. |
webhook | POST the parsed message to your URL. http is accepted at creation so a self-hosted receiver works, but the SSRF guard at delivery time blocks private targets — the same rules as outbound webhook endpoints. |
forward_email | Re-send the message to another mailbox. |
Update with PATCH /v1/inbound/routes/:id (only enabled, priority and destination are patchable — match rules are immutable), delete with DELETE /v1/inbound/routes/:id, and list with GET /v1/inbound/routes?inboundDomainId=….
Route precedence
Exactly one route wins per message. The order is a total order, so the winner never depends on which order the rules happen to come back in:
- Match type.
exactbeatspatternbeatscatch_all, outright. A catch-all can never shadow a specific rule. - Specificity, within the pattern tier. The pattern with more literal (non-
*) characters wins —support-billing-*@…beatssupport-*@…. - Lower
priority. - Older
createdAt, then lowerid.
Disabled routes are skipped entirely. If nothing matches, the message is dead-lettered rather than delivered anywhere.
Previewing a route
/v1/inbound/routes/resolveAsk which rule would win for an address, using the same selection logic the receiver runs.
Query parameters
addressstringrequired | A full recipient address. |
curl "https://api.mailmarco.com/v1/inbound/routes/[email protected]" \
-H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN"{
"address": "[email protected]",
"route": {
"id": "7a8b9c0d-1e2f-4304-a5b6-c7d8e9f0a1b2",
"inboundDomainId": "4d5e6f70-8192-4a3b-b4c5-d6e7f8091a2b",
"matchType": "pattern",
"matchValue": "support-*@in.example.com",
"action": "webhook",
"destination": "https://example.com/hooks/inbound",
"enabled": true,
"priority": 50,
"createdAt": "2026-07-29T12:05:00.000Z"
}
}route is null when the address's domain is not one of your inbound domains, or when no rule matches. Use this before publishing DNS to confirm a rule set does what you think.
Retrieving received mail
/v1/received-emailsList received messages, newest first.
Query parameters
limitintegeroptionaldefault 25 | Between 1 and 100. |
offsetintegeroptionaldefault 0 | Zero-based. |
{
"data": [
{
"id": "8b9c0d1e-2f30-4415-a6b7-c8d9e0f1a2b3",
"to": "[email protected]",
"from": "[email protected]",
"headerFrom": "[email protected]",
"headerFromName": "A Customer",
"toAddresses": ["[email protected]"],
"ccAddresses": [],
"subject": "Cannot log in",
"messageId": "<CAF…@mail.example.org>",
"inReplyTo": null,
"references": null,
"sizeBytes": 14822,
"spamStatus": "ham",
"scanStatus": "clean",
"status": "delivered",
"routeId": "7a8b9c0d-1e2f-4304-a5b6-c7d8e9f0a1b2",
"routeAction": "store",
"forwardStatus": null,
"receivedAt": "2026-07-29T12:11:03.000Z"
}
],
"limit": 25,
"offset": 0
}to and from are the envelope addresses — what the sending server actually said in RCPT TO and MAIL FROM. The header addresses are reported separately as headerFrom, headerFromName, toAddresses and ccAddresses. Route on the envelope; display the headers.
/v1/received-emails/{id}The full message: bodies, headers and attachment metadata.
Path parameters
idstring (uuid)required | Message id. |
{
"id": "8b9c0d1e-2f30-4415-a6b7-c8d9e0f1a2b3",
"subject": "Cannot log in",
"text": "I get a 500 when…",
"html": "<p>I get a 500 when…</p>",
"headers": { "received": ["…"], "x-mailer": "…" },
"attachments": [
{
"id": "9c0d1e2f-3041-4526-b7c8-d9e0f1a2b3c4",
"filename": "screenshot.png",
"contentType": "image/png",
"size": 88214,
"inline": false,
"contentId": null,
"scanStatus": "clean",
"scanSignature": null,
"scannedAt": "2026-07-29T12:11:05.000Z",
"downloadable": true
}
]
}Errors
404— No message with that id in your organization.
/v1/received-emails/{id}/rawThe raw RFC 5322 source, served as message/rfc822 with a Content-Disposition filename.
Path parameters
idstring (uuid)required | Message id. |
curl https://api.mailmarco.com/v1/received-emails/$MESSAGE_ID/raw \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-o message.emlAttachments and the scan gate
/v1/received-emails/{id}/attachments/{attId}Download one attachment — but only if its malware scan came back clean.
Path parameters
idstring (uuid)required | Message id. |
attIdstring (uuid)required | Attachment id. |
Errors
403— The scan verdict is anything other thanclean. The message names the verdict, e.g.attachment is not downloadable: scan_status=infected.404— No such attachment, or it has no stored object.
Serving the bytes safely
x-content-type-options: nosniffis always set — these are bytes a stranger sent you, and a browser must never sniff them into an executable type.- Content is always served as an attachment, never inline. The filename is carried in RFC 5987
filename*UTF-8 form, with an aggressively stripped ASCII fallback. - The internal storage key is never exposed. The download endpoint is the only sanctioned way to get the bytes, which is what makes the scan gate unbypassable.
The email.received webhook
Arriving mail also raises an email.received event through the ordinary webhook pipeline — same signature scheme, same retry policy, same headers. Subscribe to it to be pushed instead of polling /v1/received-emails.
curl -X POST https://api.mailmarco.com/v1/webhooks \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/hooks/mailmarco",
"eventTypes": ["email.received"]
}'