Skip to content
MailMarcodocs

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.

POST/v1/inbound/domains
auth: Session201 on success

Register an inbound domain and get the MX record to publish.

Body

domain
stringrequired
Trimmed, lower-cased, same grammar as a sending domain.
request
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"}'
response · 201
{
  "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
  }
}
zone
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”.

RouteAuthStatusDoes
GET /v1/inbound/domainsSession200List.
GET /v1/inbound/domains/{id}Session200Retrieve one.
POST /v1/inbound/domains/{id}/verifySession201Re-check the MX record and update the status.
POST /v1/inbound/domains/{id}/suspendSession201Stop accepting mail.
DELETE /v1/inbound/domains/{id}Session200Remove it. Answers { "deleted": true }.

Routes

A route says what happens to mail for a matching recipient. Every route belongs to one inbound domain.

POST/v1/inbound/routes
auth: Session201 on success

Create a routing rule.

Body

inboundDomainId
string (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.
matchValue
stringoptionaldefault ""
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.
destination
stringoptionaldefault ""
An http(s) URL for webhook, an email address for forward_email, and empty for store. At most 2048 characters.
enabled
booleanoptionaldefault true
Disabled routes never match.
priority
integeroptionaldefault 100
0–10000. Lower wins ties. It does not override match specificity.
request
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

  • 400matchValue missing for a non-catch-all rule, an exact value that is not a full address, or a destination that does not fit the action.
  • 404The inbound domain is not yours.
ActionEffect
storeKeep the message for retrieval via /v1/received-emails. destination must be empty.
webhookPOST 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_emailRe-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:

  1. Match type. exact beats pattern beats catch_all, outright. A catch-all can never shadow a specific rule.
  2. Specificity, within the pattern tier. The pattern with more literal (non-*) characters wins — support-billing-*@… beats support-*@….
  3. Lower priority.
  4. Older createdAt, then lower id.

Disabled routes are skipped entirely. If nothing matches, the message is dead-lettered rather than delivered anywhere.

Previewing a route

GET/v1/inbound/routes/resolve
auth: Session200 on success

Ask which rule would win for an address, using the same selection logic the receiver runs.

Query parameters

address
stringrequired
A full recipient address.
request
curl "https://api.mailmarco.com/v1/inbound/routes/[email protected]" \
  -H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN"
response · 200
{
  "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

GET/v1/received-emails
auth: API key200 on success

List received messages, newest first.

Query parameters

limit
integeroptionaldefault 25
Between 1 and 100.
offset
integeroptionaldefault 0
Zero-based.
response · 200
{
  "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.

GET/v1/received-emails/{id}
auth: API key200 on success

The full message: bodies, headers and attachment metadata.

Path parameters

id
string (uuid)required
Message id.
response · 200
{
  "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

  • 404No message with that id in your organization.
GET/v1/received-emails/{id}/raw
auth: API key200 on success

The raw RFC 5322 source, served as message/rfc822 with a Content-Disposition filename.

Path parameters

id
string (uuid)required
Message id.
request
curl https://api.mailmarco.com/v1/received-emails/$MESSAGE_ID/raw \
  -H "authorization: Bearer $MAILMARCO_API_KEY" \
  -o message.eml

Attachments and the scan gate

GET/v1/received-emails/{id}/attachments/{attId}
auth: API key200 on success

Download one attachment — but only if its malware scan came back clean.

Path parameters

id
string (uuid)required
Message id.
attId
string (uuid)required
Attachment id.

Errors

  • 403The scan verdict is anything other than clean. The message names the verdict, e.g. attachment is not downloadable: scan_status=infected.
  • 404No such attachment, or it has no stored object.

Serving the bytes safely

  • x-content-type-options: nosniff is 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
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"]
  }'