Skip to content
API referencev2.0.031 endpoints

Mail, addresses and messages over one REST surface

Every envelope is JSON. Success carries ok: true plus data; failure carries ok: false and a typed error with a request_id you can quote in a ticket.

https://tmail.ml/api/v2Get a key

Quickstart

Create an address, read its inbox, pull the code. Three calls.

shell
curl -s https://tmail.ml/api/v2/addresses \
  -H "x-api-key: $MML_KEY" \
  -H "content-type: application/json" \
  -d '{"domain":"mailmil.eu.cc"}'
poll the inbox
curl -s "https://tmail.ml/api/v2/[email protected]&unread=true&per_page=5" \
  -H "x-api-key: $MML_KEY"

Authentication

Pass the key as x-api-key, or as Authorization: Bearer. Keys start with mml_v2_, are shown once at creation, optionally bound to an IP allowlist, and scoped to read and/or write.

Mint keys from Dashboard → API keys or with POST /keys. Only a SHA-256 hash of the secret is stored, so a lost key is rotated, never recovered.

Keep the key server-side. Anyone holding it spends your plan quota, mints addresses and reads your mail.

Envelopes

200 success
{
  "ok": true,
  "data": { "...": "endpoint specific" },
  "meta": { "request_id": "e7c87866405f3959", "timestamp": "2026-10-23T07:27:21.527Z" }
}
4xx failure
{
  "ok": false,
  "error": {
    "code": "address_quota_exceeded",
    "message": "Address quota is 2 for this key",
    "details": { "limit": 2, "used": 2, "remaining": 0, "unlimited": false }
  },
  "meta": { "request_id": "b19baeba5fc1020b" }
}

Meta

Version discovery, key introspection, capacity and traffic.

GET
/metapublic

Describe the API

Version, base URL, auth header, key prefix and the full endpoint list. The only route that answers without a key, so a client can version-check before it holds credentials.

200 response
{
  "ok": true,
  "data": {
    "version": "2.0.0",
    "base_url": "https://milbox.dev/api/v2",
    "auth": "x-api-key",
    "key_prefix": "mml_v2_",
    "endpoints": [
      "GET /meta",
      "GET /account",
      "…"
    ]
  },
  "meta": {
    "request_id": "9a1c44f0d21e77b3"
  }
}
GET
/accountread

Read the current key

The key itself plus its owner and plan: id, label, prefix, scopes, per-minute budget, IP allowlist, timestamps and live address capacity.

200 response
{
  "ok": true,
  "data": {
    "key": {
      "id": 4,
      "label": "ci-pipeline",
      "prefix": "mml_v2_a91Kd2",
      "scopes": [
        "read",
        "write"
      ],
      "rate_limit_per_minute": 600,
      "ip_allowlist": [
        "203.0.113.4"
      ],
      "created_at": "2026-10-23T15:20:59.000Z",
      "last_used_at": "2026-10-23T15:44:02.000Z"
    },
    "user": {
      "id": 1,
      "email": "[email protected]",
      "plan": "pro"
    },
    "plan": {
      "slug": "pro",
      "name": "Pro"
    },
    "addresses": {
      "limit": 500,
      "used": 37,
      "remaining": 463,
      "unlimited": false
    }
  },
  "meta": {
    "request_id": "e7c87866405f3959"
  }
}
GET
/account/quotaread

Read address capacity

A live count, not a daily one. The web inbox and the API spend from the same counter, so deleting an address frees the slot for either path.

200 response
{
  "ok": true,
  "data": {
    "limit": 500,
    "used": 37,
    "remaining": 463,
    "unlimited": false,
    "plan": {
      "slug": "pro",
      "name": "Pro"
    },
    "user": {
      "id": 1,
      "email": "[email protected]",
      "plan": "pro"
    }
  },
  "meta": {
    "request_id": "88eb579226d94ca4"
  }
}
GET
/account/usageread

Read request counters

Totals for all time, 24h and 7d, plus denied requests in the last day and the twelve busiest paths with their error counts.

200 response
{
  "ok": true,
  "data": {
    "key": {
      "id": 4,
      "label": "ci-pipeline",
      "prefix": "mml_v2_a91Kd2"
    },
    "requests": {
      "total": 18422,
      "last_24h": 902,
      "last_7d": 6118,
      "denied_24h": 3
    },
    "endpoints": [
      {
        "path": "/api/v2/inbox",
        "hits": 8031,
        "errors": 4
      },
      {
        "path": "/api/v2/addresses",
        "hits": 2104,
        "errors": 0
      }
    ],
    "addresses": {
      "limit": 500,
      "used": 37,
      "remaining": 463,
      "unlimited": false
    },
    "base_url": "https://milbox.dev/api/v2"
  },
  "meta": {
    "request_id": "b19baeba5fc1020b"
  }
}

Keys

Self-serve credential management. A key may mint, rotate and revoke its siblings.

GET
/keysread

List keys

Every key on the account, revoked ones included: prefix, scopes, budget, allowlist and last use. The secret itself is never returned.

200 response
{
  "ok": true,
  "data": [
    {
      "id": 4,
      "label": "ci-pipeline",
      "prefix": "mml_v2_a91Kd2",
      "scopes": [
        "read",
        "write"
      ],
      "rate_limit_per_minute": 600,
      "ip_allowlist": [
        "203.0.113.4"
      ],
      "last_used_at": "2026-10-23T15:44:02.000Z",
      "revoked_at": null,
      "created_at": "2026-10-23T15:20:59.000Z"
    }
  ],
  "meta": {
    "request_id": "2eb988efbfb54c81"
  }
}
POST
/keyswrite

Mint a key

Creates a credential and returns the secret exactly once. Only a SHA-256 hash is stored, so a lost key is rotated, never recovered. Requires a plan with api_calls above zero.

Errors402 plan_required
Parameters
NameTypeInDescription
labelstringbodyHuman label, 80 characters max.
scopesread | writebodySubset of read and write. Defaults to both.
rate_limit_per_minuteintegerbodyPer-key budget. 0 disables the limiter.
ip_allowliststringbodyComma separated IPs. Empty means any source.
Request body
{
  "label": "ci-pipeline",
  "scopes": [
    "read",
    "write"
  ],
  "rate_limit_per_minute": 600,
  "ip_allowlist": "203.0.113.4"
}
200 response
{
  "ok": true,
  "data": {
    "id": 5,
    "label": "ci-pipeline",
    "key": "mml_v2_3r5JOxhJTW9MaWlHfT59A6J2QC4ZjWCw",
    "prefix": "mml_v2_3r5JOx",
    "scopes": [
      "read",
      "write"
    ],
    "note": "store this key now, it is not shown again"
  },
  "meta": {
    "request_id": "b740f9b2cf3d4576"
  }
}
POST
/keys/{id}/rotatewrite

Rotate the secret

Replaces the secret in place. The previous secret stops working on the very next request, and the new one is shown once.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredintegerpathKey id from the list call.
200 response
{
  "ok": true,
  "data": {
    "id": 5,
    "key": "mml_v2_l0Pqc7_nIALblKrxbzPlbZeDS5XkrtZD",
    "prefix": "mml_v2_l0Pqc7",
    "note": "the previous secret is dead"
  },
  "meta": {
    "request_id": "8cd6032edcd9477d"
  }
}
DELETE
/keys/{id}write

Revoke a key

Marks the credential dead. Requests still carrying it fail with 401 unauthorized. Revoked rows stay listed so the audit trail survives.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredintegerpathKey id to revoke.
200 response
{
  "ok": true,
  "data": {
    "revoked": true,
    "id": 5
  },
  "meta": {
    "request_id": "9ffc7ab7298a41dd"
  }
}

Domains

The routable inventory. An address must live on a domain that is active with mail enabled.

GET
/domainsread

List active domains

Every domain that can currently receive mail: name, type, premium flag and status. Only resolved rows come back, so a pending verification never appears here.

200 response
{
  "ok": true,
  "data": [
    {
      "name": "mailmil.eu.cc",
      "type": "platform_pool",
      "premium": false,
      "status": "active"
    }
  ],
  "meta": {
    "total": 1,
    "request_id": "066acaa177cc4a0a"
  }
}

Addresses

One mailbox, one local part. Every route is scoped to an address your account owns.

GET
/addressesread

List addresses

Live addresses, newest first. The list is reconciled against the mail provider on read, so an address that expired upstream disappears here too.

200 response
{
  "ok": true,
  "data": [
    {
      "id": "0qs2n2vzopun",
      "address": "[email protected]",
      "label": "burner-a",
      "status": "active",
      "expires_at": "2026-10-24T07:33:47.000Z",
      "created_at": "2026-10-23T07:33:47.000Z"
    }
  ],
  "meta": {
    "total": 1,
    "request_id": "b1a48a57fc1e4d3d"
  }
}
POST
/addresseswrite

Create an address

Mints a mailbox and spends the same plan counter the web inbox uses. A 429 arrives with plan_address_quota_exceeded at the ceiling; delete an address to free a slot immediately.

Errors429 plan_address_quota_exceeded429 address_quota_exceeded
Parameters
NameTypeInDescription
domainstringbodyMust be an active routable domain. Falls back to the account default.
local_partstringbodyDesired local part. Generated when omitted.
labelstringbodyFree-form note shown in the dashboard.
ttl_minutesintegerbodyLifetime in minutes, defaulting to 15 on Free and 1440 on Pro. Requests above the plan ceiling are clamped, never rejected.
Request body
{
  "domain": "mailmil.eu.cc",
  "local_part": "north.cinder",
  "label": "burner-a",
  "ttl_minutes": 15
}
200 response
{
  "ok": true,
  "data": {
    "id": "0qs2n2vzopun",
    "address": "[email protected]",
    "label": "burner-a",
    "expires_at": "2026-10-24T07:33:47.000Z",
    "created_at": "2026-10-23T07:33:47.000Z"
  },
  "meta": {
    "request_id": "6255ec292c01489a"
  }
}
GET
/addresses/{id}read

Read one address

Accepts the public id or the full email. Returns status, expiry and the alias group attached to the mailbox.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathPublic id or full email address.
200 response
{
  "ok": true,
  "data": {
    "id": "0qs2n2vzopun",
    "address": "[email protected]",
    "label": "burner-a",
    "status": "active",
    "expires_at": "2026-10-24T07:33:47.000Z",
    "aliases": [
      "[email protected]"
    ]
  },
  "meta": {
    "request_id": "4d1f0ac9e2b7715c"
  }
}
PATCH
/addresses/{id}write

Update an address

Rename the label, disable delivery without deleting the mailbox, or extend the lifetime. Disabled addresses stop accepting mail but keep their stored messages.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathPublic id or full email address.
Request body
{
  "label": "burner-a",
  "status": "disabled",
  "ttl_minutes": 1440
}
200 response
{
  "ok": true,
  "data": {
    "id": "0qs2n2vzopun",
    "address": "[email protected]",
    "label": "burner-a",
    "status": "disabled",
    "expires_at": "2026-10-24T07:33:47.000Z"
  },
  "meta": {
    "request_id": "77b13f5d0c8a9e21"
  }
}
GET
/addresses/{id}/messagesread

Messages on one address

The same filter grammar as /inbox, pinned to one address. Every row carries an extracted otp when the body or subject holds a code.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathPublic id or full email address.
unreadbooleanqueryOnly unseen messages.
per_pageintegerqueryRows per page, 100 max.
200 response
{
  "ok": true,
  "data": [
    {
      "id": "b81f2c40a7",
      "from": {
        "name": "GitHub",
        "address": "[email protected]"
      },
      "subject": "Your verification code is 481920",
      "preview": "Enter 481920 to finish signing in.",
      "received_at": "2026-10-23T07:41:02.000Z",
      "unread": true,
      "otp": "481920"
    }
  ],
  "meta": {
    "total": 1,
    "address": "[email protected]",
    "request_id": "1c9de40b6fa8c352"
  }
}
DELETE
/addresses/{id}write

Delete an address

Removes the mailbox and frees the plan slot immediately for either the web inbox or the API. Stored messages go with it.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathPublic id or full email address.
200 response
{
  "ok": true,
  "data": {
    "deleted": true,
    "id": "0qs2n2vzopun",
    "address": "[email protected]"
  },
  "meta": {
    "request_id": "5b0e77a1cd93f206"
  }
}

Inbox

Poll or filter. Every inbox route accepts the same filter grammar.

GET
/inboxread

List messages

Newest first across every address, or pinned to one. Prefer since_id over page offsets: it stays stable while mail is arriving.

Errors404 not_found
Parameters
NameTypeInDescription
addressstringqueryPin to one owned address.
since_idstringqueryReturn only rows newer than this id. Use for polling.
cursorstringqueryOpaque cursor from meta.next_cursor.
unreadbooleanqueryOnly unseen messages.
starredbooleanqueryOnly starred messages.
has_attachmentsbooleanqueryOnly messages carrying attachments.
include_deletedbooleanqueryInclude soft-deleted rows.
qstringqueryFree-text match across subject, body and sender.
fromstringquerySender address match.
from_namestringquerySender display name match.
subjectstringquerySubject substring match.
tostringqueryRecipient match.
message_idstringqueryRFC 5322 Message-ID.
idsstringqueryComma separated message ids.
sincestringqueryISO 8601 lower bound on received_at.
untilstringqueryISO 8601 upper bound on received_at.
min_sizeintegerqueryMinimum size in bytes.
max_sizeintegerqueryMaximum size in bytes.
priorityhigh | normal | lowqueryClassification filter.
spamclean | spam | unknownquerySpam classification filter.
sortnewest | oldest | largestqueryOrdering.
pageintegerqueryPage number, 1 based.
per_pageintegerqueryRows per page, 100 max.
200 response
{
  "ok": true,
  "data": [
    {
      "id": "b81f2c40a7",
      "address": "[email protected]",
      "from": "[email protected]",
      "from_name": "GitHub",
      "subject": "Your verification code is 481920",
      "preview": "Enter 481920 to finish signing in.",
      "size_bytes": 18422,
      "attachment_count": 0,
      "has_html": true,
      "has_attachments": false,
      "spam_status": "clean",
      "priority": null,
      "received_at": "2026-10-23T07:41:02.000Z",
      "read_at": null,
      "starred_at": null,
      "expires_at": "2026-10-24T07:41:02.000Z",
      "to": [
        {
          "name": null,
          "address": "[email protected]"
        }
      ],
      "cc": [],
      "otp": "481920"
    }
  ],
  "meta": {
    "request_id": "ab96f9ddabec488a",
    "total": 1,
    "page": 1,
    "per_page": 25,
    "pages": 1,
    "next_cursor": null,
    "sort": "newest"
  }
}
GET
/inbox/countsread

Counter snapshot

Counts for the filtered scope: total, unread, read, deleted, attachments and starred. Cheaper than listing when a badge is all you need.

Parameters
NameTypeInDescription
addressstringqueryPin to one owned address.
200 response
{
  "ok": true,
  "data": {
    "address": null,
    "total": 42,
    "unread": 6,
    "read": 36,
    "deleted": 0,
    "attachments": 9,
    "starred": 2
  },
  "meta": {
    "request_id": "344a8f14460c4664"
  }
}
GET
/inbox/attachmentsread

Attachment index

Every attachment across the mailbox as a flat list, each with a checksum and the download path that streams it.

Parameters
NameTypeInDescription
addressstringqueryPin to one owned address.
pageintegerqueryPage number, 1 based.
per_pageintegerqueryRows per page, 100 max.
200 response
{
  "ok": true,
  "data": [
    {
      "id": "2",
      "message_id": "b81f2c40a7",
      "filename": "invoice-2026-10.pdf",
      "content_type": "application/pdf",
      "size": 184320,
      "checksum_sha256": "9f2c…a71b",
      "download_path": "/messages/b81f2c40a7/attachment/2"
    }
  ],
  "meta": {
    "total": 1,
    "request_id": "8a3f11cd40ee9b02"
  }
}
POST
/inbox/readwrite

Mark read

Bulk mark read. Pass an ids array to touch specific rows, or a filter to sweep the whole scope. Returns the affected count and the fresh counters.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7",
    "c02d9e71f4"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 2,
    "counts": {
      "total": 42,
      "unread": 4,
      "read": 38
    }
  },
  "meta": {
    "request_id": "d41c07be9a2f5531"
  }
}
POST
/inbox/unreadwrite

Mark unread

The inverse of read, identical contract. Useful when a poller consumes a message but the user has not seen it yet.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 1,
    "counts": {
      "total": 42,
      "unread": 5,
      "read": 37
    }
  },
  "meta": {
    "request_id": "a90f2cbf1e77043d"
  }
}
POST
/inbox/starwrite

Star

Bulk star. Same body and filter contract as read, so a single sweep can flag everything matching a query.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 1,
    "counts": {
      "total": 42,
      "starred": 3
    }
  },
  "meta": {
    "request_id": "3f0b9d2ac7e1560d"
  }
}
POST
/inbox/unstarwrite

Unstar

Remove the star. Identical contract to star.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 1,
    "counts": {
      "total": 42,
      "starred": 2
    }
  },
  "meta": {
    "request_id": "6c1a8ef02b47d559"
  }
}
POST
/inbox/deletewrite

Soft delete

Moves matching messages into the deleted state. They stay restorable for the retention window and drop out of the default listing.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 1,
    "counts": {
      "total": 41,
      "deleted": 1
    }
  },
  "meta": {
    "request_id": "0b7e14cd93af2e68"
  }
}
POST
/inbox/restorewrite

Restore

Brings soft-deleted messages back into the inbox. Fails cleanly once the retention window has passed.

Request body
{
  "address": "[email protected]",
  "ids": [
    "b81f2c40a7"
  ]
}
200 response
{
  "ok": true,
  "data": {
    "affected": 1,
    "counts": {
      "total": 42,
      "deleted": 0
    }
  },
  "meta": {
    "request_id": "e21b70fa4cd85993"
  }
}

Messages

The single-message router: full RFC 5322 fidelity in, flags out.

GET
/messages/searchread

Full-text search

Search across every address on the account. Supports +required, -excluded and "exact phrase" terms in the query string.

Errors422 validation_failed
Parameters
NameTypeInDescription
qrequiredstringquerySearch expression. Supports +required -excluded "exact phrase".
addressstringqueryPin the search to one owned address.
sincestringqueryISO 8601 lower bound.
per_pageintegerqueryRows per page, 100 max.
200 response
{
  "ok": true,
  "data": [
    {
      "id": "b81f2c40a7",
      "address": "[email protected]",
      "from": "[email protected]",
      "from_name": "GitHub",
      "subject": "Your verification code is 481920",
      "preview": "Enter 481920 to finish signing in.",
      "size_bytes": 18422,
      "attachment_count": 0,
      "received_at": "2026-10-23T07:41:02.000Z",
      "read_at": null,
      "starred_at": null,
      "otp": "481920"
    }
  ],
  "meta": {
    "total": 1,
    "query": "verification code",
    "request_id": "c7f20a9e51dd8406"
  }
}
GET
/messages/{id}read

Read one message

Decoded headers, text, sanitised html, attachment checksums, DKIM and Authentication-Results, the Received chain and an extracted otp when one is present. Marks the message read.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathMessage id from any inbox listing.
200 response
{
  "ok": true,
  "data": {
    "id": "b81f2c40a7",
    "address": "[email protected]",
    "from": "[email protected]",
    "from_name": "GitHub",
    "subject": "Your verification code is 481920",
    "message_id": "<[email protected]>",
    "received_at": "2026-10-23T07:41:02.000Z",
    "read_at": "2026-10-23T07:41:19.000Z",
    "starred_at": null,
    "expires_at": "2026-10-24T07:41:02.000Z",
    "to": [
      {
        "name": null,
        "address": "[email protected]"
      }
    ],
    "cc": [],
    "text": "Enter 481920 to finish signing in.",
    "html": "<p>Enter <b>481920</b> to finish signing in.</p>",
    "otp": "481920",
    "priority": "normal",
    "spam_status": "clean",
    "size_bytes": 18422,
    "delivery_ms": 84,
    "attachments_detail": [
      {
        "filename": "invoice-2026-10.pdf",
        "content_type": "application/pdf",
        "size": 184320,
        "disposition": "attachment",
        "inline": false,
        "checksum_sha256": "9f2c…a71b"
      }
    ],
    "headers_date": "2026-10-23T07:40:58.000Z",
    "headers_date_valid": true,
    "received_chain": [
      "mail.github.com",
      "mx.milbox.dev"
    ],
    "dkim_signatures": [
      "github.com"
    ],
    "authentication_results": "mx.milbox.dev; spf=pass; dkim=pass; dmarc=pass",
    "raw_available": true
  },
  "meta": {
    "request_id": "f0a91c7e2b8d4365"
  }
}
PATCH
/messages/{id}write

Update flags

Set seen and/or is_deleted on a single message. Cheaper than the bulk routes when you already hold the id.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathMessage id.
Request body
{
  "seen": true,
  "is_deleted": false
}
200 response
{
  "ok": true,
  "data": {
    "id": "b81f2c40a7",
    "seen": true,
    "is_deleted": false
  },
  "meta": {
    "request_id": "4d88b0e1a7c39f52"
  }
}
DELETE
/messages/{id}write

Hard delete

Removes the message for good. Unlike /inbox/delete there is no restore window; the row and its attachments are gone.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathMessage id.
200 response
{
  "ok": true,
  "data": {
    "deleted": true,
    "id": "b81f2c40a7"
  },
  "meta": {
    "request_id": "2ac70e94b1df6830"
  }
}
POST
/messages/bulkwrite

Bulk by filter

Applies one action across everything matching a filter. Actions: read, unread, star, unstar, delete, restore, spam, not_spam.

Errors422 validation_failed
Parameters
NameTypeInDescription
actionrequiredread | unread | star | unstar | delete | restore | spam | not_spambodyOperation to apply.
filterobjectbodyAny /inbox query parameter, as a JSON object.
Request body
{
  "action": "read",
  "filter": {
    "address": "[email protected]",
    "unread": true,
    "from": "[email protected]"
  }
}
200 response
{
  "ok": true,
  "data": {
    "action": "read",
    "affected": 7
  },
  "meta": {
    "request_id": "7be1f0c48d92a365"
  }
}
GET
/messages/{id}/downloadread

Download raw .eml

Streams the whole untouched message as message/rfc822, headers and all. The bytes you would have received over SMTP.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathMessage id.
200 response
{
  "note": "binary stream",
  "content-type": "message/rfc822",
  "content-disposition": "attachment; filename=\"b81f2c40a7.eml\""
}
GET
/messages/{id}/attachment/{attachmentId}read

Download an attachment

Streams one attachment by id, preserving the original Content-Disposition and content type.

Errors404 not_found
Parameters
NameTypeInDescription
idrequiredstringpathMessage id.
attachmentIdrequiredstringpathAttachment id from the message payload.
200 response
{
  "note": "binary stream",
  "content-type": "application/pdf",
  "content-disposition": "attachment; filename=\"invoice-2026-10.pdf\"",
  "content-length": "184320"
}

Working example

Create, poll, extract. Node 20+ with the built-in fetch. The key lives in the environment, never the bundle.

node
const base = 'https://tmail.ml/api/v2';
const call = async (path, init = {}) => {
  const response = await fetch(base + path, {
    ...init,
    headers: { 'x-api-key': process.env.MML_KEY, 'content-type': 'application/json', ...init.headers },
  });
  const payload = await response.json();
  if (!payload.ok) throw Object.assign(new Error(payload.error.code), payload.error, { status: response.status });
  return payload;
};

const { data: created } = await call('/addresses', {
  method: 'POST',
  body: JSON.stringify({ domain: 'mailmil.eu.cc' }),
});

for (;;) {
  const { data: rows } = await call('/inbox?address=' + encodeURIComponent(created.address) + '&unread=true');
  const hit = rows.find((row) => row.otp);
  if (hit) {
    console.log('otp:', hit.otp);
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 3000));
}

Incremental polling with since_id: pass the last id you saw and only newer rows come back. Stable while mail arrives, unlike page offsets.

Errors

StatusCodeDescription
401unauthorizedNo x-api-key header, or the key is revoked, unknown, or outside its IP allowlist.
402plan_requiredThe plan has api_calls = 0, so no key can be minted. Free is web-inbox only.
403insufficient_scopeThe key is read-only and the route writes. details carries required and granted.
404not_foundAddress, message, key, or route does not exist.
422validation_failedA required parameter is missing or malformed.
429rate_limitedPer-minute budget exhausted. Back off for retry_after seconds.
429address_limit_reachedNetwork mint cap hit for anonymous callers. retry-after header is set.
429plan_address_quota_exceededPlan ceiling hit. Delete an address or upgrade; no retry-after, the slot frees immediately.
429address_quota_exceededUpstream provider quota on the shared key is exhausted.
500internal_errorUnexpected failure. Quote request_id in a support ticket.

Rate limits

  • Rate budget is per key. Read rate_limit_per_minute from /account; 0 means unlimited. Exceeding it returns 429 rate_limited with a retry-after header.
  • Address quota is the plan quota. Read it from /account/quota. A live count, not a daily one: 2 on Free, 25 on Pro, 500 on Agency. The web inbox and the API spend from the same counter, so deleting an address frees the slot for either path.
  • No key on Free — api_calls is 0, so there is no REST key and POST /keys answers 402. Free is the web inbox plus its 10/1h mint cap; a key starts on Pro.
  • Message size caps at 10 MB including attachments.
  • Polling should never be tighter than 2s. Prefer since_id over page offsets; the web inbox polls at 12s.
  • Anonymous minting is capped per network at 10 addresses per 1h, resolved server-side from x-forwarded-for / cf-connecting-ip. IPv6 is bucketed by /64 so rotating the interface does not reset the count. Exceeding it returns 429 address_limit_reached with a retry-after header.