Base URL: https://mailmatter.dev. All responses carry
X-Robots-Tag: noindex.
This page documents the programmable surface: every endpoint you can call with a token from a test suite, a script, or an agent. Account management — billing, org settings, API keys, webhook configuration — is driven by the signed-in dashboard and is deliberately not a public API. See Managed in the dashboard for where those actions live.
Authentication
Two credentials work programmatically, and they cover the same routes:
| Credential | Format | Scope |
|---|---|---|
| Anonymous secret | Authorization: Bearer <secret> (returned by the mint) |
The one unclaimed namespace it was issued for |
| Org API key | Authorization: Bearer mm_<key> (Settings → API keys, free account) |
Every namespace the key’s org owns |
An anonymous secret is not an API key: it authorizes REST reads on its own
namespace and nothing else, and it never works for
MCP. Browsers additionally carry an mm_ns cookie (set by the
mint) or an mm_sess session cookie — that’s how the dashboard reads the
same inboxes without a token.
Status codes are consistent everywhere: an unknown handle is 404; a known
handle with a missing or wrong credential is 403; a message that arrived
over your plan’s volume cap is served as a withheld envelope with 402 (see
Handles, scenarios, and secrets);
rate-limited requests are 429.
Rate limits
Reads are limited per tenant, not per IP — 600 requests/minute, the number
shown on your plan in Settings. Keying on the tenant is deliberate: a CI fleet
on shared runners, or a team behind one office NAT, should not throttle each
other. A 429 carries Retry-After: 60; wait it out rather than retrying
immediately. Minting an anonymous namespace is separately limited per IP,
since it has no tenant yet.
Quick reference
| Method | Path | Purpose |
|---|---|---|
POST |
/api/namespace |
Mint an anonymous namespace (no credential) |
GET |
/api/namespace/:handle/scenarios |
Scenarios that have received mail |
GET |
/api/mailbox/:handle.:scenario |
List messages, optionally filtered by header |
GET |
/api/mailbox/:handle.:scenario/latest |
Latest message or null |
GET |
/api/mailbox/:handle.:scenario/wait |
Long-poll for the next message |
DELETE |
/api/mailbox/:handle.:scenario |
Purge one scenario |
GET |
/api/message/:id |
Parsed message |
GET |
/api/message/:id/raw |
Byte-exact .eml |
GET |
/api/message/:id/attachment/:index |
Decoded attachment bytes |
Namespaces
POST /api/namespace
No body, no credential — mint an anonymous namespace. Rate-limited per IP.
curl -s -X POST https://mailmatter.dev/api/namespace{ "handle": "qa-d7aafa0b9c73", "secret": "<48-hex-secret>" }Returns 201 and sets the mm_ns cookie. The secret is shown exactly once.
Adding a handle to an org is a dashboard action, not an API call — see Manage handles. The same is true over MCP: a token reads handles, it never creates them.
GET /api/namespace/:handle/scenarios
Scenarios that have actually received mail, newest first — how a UI or agent discovers inboxes that were addressed directly, with no create step.
curl -s https://mailmatter.dev/api/namespace/bluepine/scenarios \
-H "Authorization: Bearer mm_<key>"{ "handle": "bluepine", "scenarios": [ { "scenario": "signup", "count": 3, "latest_at": 1721399999000 } ] }Reading mail
The :handle.:scenario segment is the address’s local part, e.g.
bluepine.signup.
GET /api/mailbox/:handle.:scenario
List message summaries, newest first.
curl -s https://mailmatter.dev/api/mailbox/bluepine.signup \
-H "Authorization: Bearer mm_<key>"{
"handle": "bluepine",
"scenario": "signup",
"messages": [
{
"id": "<message-id>",
"from_addr": "no-reply@app.example",
"subject": "Your code is 481920",
"received_at": 1721399999000,
"size": 4213,
"delivered_to": "bluepine.signup@mailmatter.dev",
"over_quota": false
}
],
"total": 1,
"has_more": false
}The listing is paged: ?limit= sets the page size, defaulting to and
capped at 200. total is how many messages the scenario actually holds
and has_more says whether you are seeing all of them — so a capped page is
never mistakable for a small inbox. Since the order is newest-first, the
default page is what a test almost always wants; give each test case its own
scenario and you will never see the cap.
?header=Key narrows the list to messages carrying that header;
?header=Key:Value requires an exact value — the assertion hook for suites
that tag outgoing mail with X-* metadata:
curl -s "https://mailmatter.dev/api/mailbox/bluepine.signup?header=X-Test-Run:42" \
-H "Authorization: Bearer mm_<key>"When a header filter runs and some candidates are quota-withheld (their
headers can’t be inspected), the response adds
"withheld_unevaluated": <n> so a filtered query never silently reads as
“never came”.
A header filter reads and parses one stored message per candidate, so it
examines the newest 100 of the page. If there was more it could have
looked at, the response adds "header_scan_truncated": true — an unmatched
filter over a deep inbox is reported as incomplete, never as “no such mail”.
GET /api/mailbox/:handle.:scenario/latest
The newest message, fully parsed (same shape as
GET /api/message/:id), or "message": null if the
scenario is empty.
GET /api/mailbox/:handle.:scenario/wait
Long-poll: blocks until a message strictly newer than since arrives,
or until the timeout. One blocked call counts as one request against your
tenant rate limit — waiting is cheaper than polling, by design.
since— watermark, unix milliseconds. Default: now.timeout— seconds to block, max 55. Default: 30.
Returns the parsed message, or "message": null on timeout — a normal
outcome, not an error. See
Testing with MailMatter for the
capture-the-watermark idiom.
DELETE /api/mailbox/:handle.:scenario
Purge one scenario (post-test cleanup). The handle and its other scenarios
survive. Returns
{ "handle": "…", "scenario": "…", "deleted": <n> }.
Messages
GET /api/message/:id
The parsed message. Message ids come from listings, /latest, or /wait.
{
"id": "<message-id>",
"handle": "bluepine",
"scenario": "signup",
"delivered_to": "bluepine.signup@mailmatter.dev",
"received_at": 1721399999000,
"size": 4213,
"from": { "address": "no-reply@app.example", "name": "Example App" },
"subject": "Your code is 481920",
"date": "2026-07-19T12:39:59.000Z",
"text": "Your code is 481920\n",
"html": "<!doctype html>…",
"links": ["https://app.example/verify?t=abc"],
"headers": [ { "key": "dkim-signature", "value": "…" } ],
"attachments": [ { "index": 0, "filename": "invoice.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 18244 } ],
"over_quota": false
}links holds the verbatim extracted href values — nothing is rewritten,
resolved, or visited. A quota-withheld message returns only its envelope
(id, from_addr, subject, timestamps, "over_quota": true, "reason": "over_quota") with status 402.
GET /api/message/:id/raw
The byte-exact RFC 822 message, streamed as message/rfc822.
curl -s https://mailmatter.dev/api/message/<message-id>/raw \
-H "Authorization: Bearer mm_<key>" -o message.emlGET /api/message/:id/attachment/:index
Decoded attachment bytes by zero-based index, always served as
application/octet-stream with nosniff and an attachment disposition —
attacker-controlled content never executes on this origin.
Managed in the dashboard
These actions need a signed-in session at mailmatter.dev/app rather than a token, so they aren’t part of the API contract above:
| Action | Where |
|---|---|
| Create, label, empty, or release a handle | Manage handles |
| Mint and revoke API keys | Settings → API keys (MCP setup) |
| Add, test, and re-enable webhooks | Deliver mail to a webhook |
| Invites, members, billing | Invite your team, pricing |
Need one of these driven programmatically? Tell us what you’re automating at team@mailmatter.dev.