Skip to content

REST API

The programmable surface — mint an inbox, wait for mail, read messages — with auth, parameters, status codes, and response shapes.

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.eml

GET /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.