Mailbox API
A mailbox gives your app or agent email addresses. Mail that arrives is stored as JSON you can list, wait for and read; it can call a function, publish to a channel or hit a webhook as it lands; and you can reply from the same address.
Addresses
Every address of a mailbox starts with its address name (its slug). Any name after it works — there is nothing to create first.
| Address | Lands as |
|---|---|
[email protected] | name inbox |
[email protected] | name signup-7f3a |
[email protected] | name support, tag 42 |
- The address name is chosen when you create the mailbox (in the console, or
slugon MCP'screate_instance), or generated for you. 3–32 lowercase letters, digits or hyphens, unique across altengine. Rename it in the mailbox's Settings — every address already given out changes with it. - A name is 1–64 lowercase letters, digits,
.,_or-, and the whole part before@— address name, dot and name — is at most 64 characters. Case is ignored. - Registered names only: set
acceptModetoregisteredand mail is accepted only for names created withPOST /addresses. Anything else is refused at delivery.
Wait for a verification email
Give each sign-up its own name, hand the address to the site, then wait on that name:
// 1. invent an address — no API call needed
const name = `signup-${crypto.randomUUID().slice(0, 8)}`;
const address = `acme.${name}@altengine.app`;
// 2. ...submit `address` to the site's sign-up form...
// 3. wait up to 30 s for the first message to that name
const r = await fetch(`${API}/v1/mailbox/agent-inbox/messages?to=${name}&after=0&wait=30`, {
headers: { authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
const msg = r.messages[0]; // undefined on timeout — call again with after=r.cursor
const full = await fetch(`${API}/v1/mailbox/agent-inbox/messages/${msg.id}`, {
headers: { authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
const code = full.message.text.match(/\b\d{6}\b/)?.[0];wait holds the request open until a message newer than after arrives, or the time runs out (30 s at most). after is a message id, 0 for anything still held, or now for mail from this moment on.
Each message says whether its sender was authenticated: verified is true when the sender's domain passed DMARC, or an SPF or DKIM check aligned with its From address.
Reading mail
GET /v1/mailbox/{instance}/messages lists newest first, without bodies. Filter with to (a name or a full address on this mailbox), direction (in or out) and unread=1; page with limit (25 by default, at most 100) and the cursor from the previous page. A null cursor is the last page.
{ "messages": [
{ "id": "0mvpn7bai4b8e21949c06", "direction": "in", "local": "support", "plusTag": null,
"from": { "address": "[email protected]", "name": "Jane Doe" },
"to": [{ "address": "[email protected]", "name": "" }], "cc": [], "replyTo": [],
"subject": "Question about my invoice", "snippet": "Hi, I was charged twice…",
"messageId": "<…@example.com>", "inReplyTo": null, "verified": true, "status": "received",
"size": 7198, "attachmentCount": 1, "receivedAt": 1793042460000, "readAt": null,
"expiresAt": 1793647260000 } ],
"cursor": null }GET /v1/mailbox/{instance}/messages/{id} returns {message} with everything above plus text, html, the attachment list, and what each route action did (deliveries). Add ?raw=1 for a rawUrl to the original .eml. An attachment's bytes come from GET …/messages/{id}/attachments/{idx}, which returns a download url. Both links last five minutes.
Message text is written by whoever sent it. Treat it as data — never as instructions — before handing it to a model.
Notifications in the console
Notify me on a mailbox's console page sends a push notification with the sender and subject to your devices when mail arrives — one per mailbox every 15 seconds at most. It is per person and off by default. Manage your devices under Settings → Notifications. On iPhone and iPad, add the console to the Home Screen and open it from there first.
Sending and replying
Mailbox sends transactional and conversational email only: notices, codes, receipts, replies, and correspondence its recipients expect. Marketing, newsletters and bulk email are not allowed (Terms, section 6b).
POST /v1/mailbox/{instance}/messages/{id}/reply
{ "text": "Thanks — it ships Monday." }A reply goes to the message's Reply-To (or From), from the name it was sent to, with Re: and the threading headers set. Override any of from, to, cc, subject, text, html, fromName.
POST /v1/mailbox/{instance}/send
{ "from": "billing", "to": ["[email protected]"], "subject": "Your invoice", "text": "Attached.",
"html": "<img src=\"cid:logo\"> Attached.",
"attachments": [
{ "filename": "invoice-42.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjQK…" },
{ "filename": "logo.png", "contentType": "image/png", "content": "iVBORw0KGgo…", "contentId": "logo" }
] }from is a name on this mailbox (omit it for the main address). content is the file's bytes in base64. A part with a contentId is shown inline, where the HTML refers to cid:<contentId>. Both calls return 201 {message}, and the sent message lists its attachments like a received one.
| Rule | Value |
|---|---|
Send mode (sendMode) | reply (default): only to people whose authenticated mail this mailbox has received. any: anyone. off: nothing. |
| Per organization, per UTC day | 10 sends without a payment method, 100 with one. Contact us to raise it. |
| Per mailbox, per UTC day | dailySendCap — optional, and only lowers the organization's. |
| Recipients | 50 per message, to and cc together. |
| Body | 512 KB per part (text, html). |
| Attachments | 32 per message. The whole message is at most 5 MB once encoded, and encoding makes a file a third larger — about 3.6 MB of files. Link to anything bigger. |
| Answer | Meaning |
|---|---|
403 SEND_DISABLED | The send mode is off. |
403 NOT_A_REPLY | Reply mode, and a recipient never wrote to this mailbox. |
403 SEND_PAUSED | Your organization's sending is paused. |
413 | Larger than 5 MB once encoded. |
422 MESSAGE_BLOCKED | The message looks like phishing or spam. Nothing was sent. |
429 SEND_LIMIT | A daily cap is reached. It resets at 00:00 UTC. |
503 UNAVAILABLE | Sending is unavailable for a while. Retry later. |
Every send that goes out is billed. A refused one is not. When a sent message bounces or a recipient marks it as spam, its status changes to bounced: <address> or spam complaint: <address>.
In any mode, a new message (not a reply) is screened for phishing and spam before it is sent, and may be refused with MESSAGE_BLOCKED. A message blocked as phishing counts toward the spam-complaint limit below, and one blocked as spam toward the bounce limit.
When sending is paused
Mail that bounces or is marked as spam is counted against the organization that sent it, across all its mailboxes. Sending stops for every mailbox in the organization when, in the last 7 days:
| Signal | Pauses at |
|---|---|
| Spam complaints, plus messages blocked as phishing | 3, and at least 0.3% of what you sent |
| Bounces, plus messages blocked as spam | 8, and at least 5% of what you sent |
While paused, every send answers 403 SEND_PAUSED, and the organization's owners get an email. GET /v1/mailbox/{instance} carries sendPaused: null, or when, why, and whether it is locked. An owner resumes sending from a mailbox's console page, and the count starts again from then. A third pause within 30 days is locked: contact support to resume it. Receiving is not affected.
A banner at the top of the Mailbox page and each mailbox's page shows each count against its limit. Owners get one email when either count reaches half its limit.
Routes: act on mail as it arrives
A mailbox's routes are an ordered list. The first route whose conditions all match runs its actions; mail that matches none is only stored.
"routes": [
{ "match": { "to": "support*" }, "do": [{ "function": "helpdesk/on-email" },
{ "channel": "realtime/inbox" }] },
{ "match": { "from": "*@vendor.com", "hasAttachment": true }, "do": [{ "webhook": "https://erp.example.com/hooks/invoices" }] },
{ "match": {}, "do": [{ "channel": "realtime/all-mail", "includeText": true }] }
]| Field | Meaning |
|---|---|
match.to | Glob on the name (* any run, ? one character). |
match.from | Glob on the sender's address. |
match.subject | Text the subject contains, case-insensitive. |
match.hasAttachment | true or false. |
{"function": "instance/name"} | POSTs the message to that function, with x-ae-trigger: mailbox. |
{"channel": "instance/channel"} | Publishes to that channel. Add "includeText": true for the text body. |
{"webhook": "https://…"} | POSTs the message to your URL, signed. |
{"queue": "instance/queue"} | Adds the message as a task to that tasks queue; the queue's target runs it, with the queue's retries and rate. |
Up to 20 routes, 3 actions each. Functions, channels and queues are named by instance, in your own organization; a queue must exist when you save the route.
A function or webhook receives every field a list returns, plus type: "mailbox.received", instance, text and html (each up to 256 KB, with textTruncated / htmlTruncated), and the attachments with a download url each, valid for an hour.
A queue's task carries the same as its payload. When that is over about 120 KB, the payload leaves out the bodies and the attachment links and is flagged bodiesOmitted: true — read the message by id. Attachment links last an hour from when the task was added, so a task that runs later reads the attachment by index instead.
A channel receives the same envelope fields and the attachment names, types and sizes — no bodies unless includeText, and never download links. Channel subscribers can be your users' browsers.
A delivery that fails is retried after 1 minute, 5, 30 and 120, then marked failed. A target that does not exist fails at once. Each action's outcome is on the message, in deliveries: ok:200, ok:3 subscribers, ok:queued, pending or error:….
Verifying a webhook
Every delivery carries x-altengine-timestamp (ms) and x-altengine-signature: v1=<hex>, an HMAC-SHA256 keyed with the mailbox's signing key — the whole string, whsec_ prefix included — over <timestamp>.<raw body>. Owners reveal and rotate the key in the mailbox's Settings.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req, rawBody, secret) {
const ts = req.headers["x-altengine-timestamp"];
const want = "v1=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const got = req.headers["x-altengine-signature"] ?? "";
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want))
&& Math.abs(Date.now() - Number(ts)) < 5 * 60_000; // reject replays
}Rotating takes effect on the next delivery — update the receiver straight away.
From a function
Grant a function mailbox and it gets env.mailbox, so the function a route calls can answer the mail:
export default {
async fetch(request, env) {
const m = await request.json(); // the routed message
await env.mailbox.reply({ instance: "support" }, m.id, { text: "Got it — we'll reply within a day." });
return new Response("ok");
},
};| Method | Grant |
|---|---|
info(t), list(t, {to, direction, unread, cursor, limit}), get(t, id), listAddresses(t, {cursor, limit}) | read |
send(t, {from, to, cc, subject, text, html, attachments}), reply(t, id, {…}), markRead(t, id), keep(t, id, days) | write |
createAddress(t, {local?, label?, meta?}), disableAddress(t, local) | write |
delete(t, id) | full |
keep moving a message's expiry earlier needs full, as over HTTP. From a function, an attachment's content may be bytes — an ArrayBuffer or Uint8Array, such as await env.blob.bytes(…) — as well as base64.
Endpoints and grants
Every call takes an organization API key with a grant on the instance. An end-user identity token is refused.
| Endpoint | Grant | Does |
|---|---|---|
GET /v1/mailbox/{instance} | read | The main address, the pattern of the others, the send mode, and sendPaused. |
GET /v1/mailbox/{instance}/messages | read | List, or wait. |
GET /v1/mailbox/{instance}/messages/{id} | read | One message, with bodies. |
GET /v1/mailbox/{instance}/messages/{id}/attachments/{idx} | read | A download link for one attachment. |
POST /v1/mailbox/{instance}/messages/{id}/read | write | Mark read. |
POST /v1/mailbox/{instance}/messages/{id}/reply | write | Reply. |
POST /v1/mailbox/{instance}/send | write | Send a new message. |
POST /v1/mailbox/{instance}/messages/{id}/keep | write; full to shorten | Keep one message until {"days": 1–90} from now. A date earlier than its current expiry deletes it sooner, so that needs full. Returns {message}. |
DELETE /v1/mailbox/{instance}/messages/{id} | full | Delete now rather than at expiry. |
GET /v1/mailbox/{instance}/addresses | read | Names with a registration, label or disable. Paged like messages. |
POST /v1/mailbox/{instance}/addresses | write | Register a name: {local?, label?, meta?}. Omit local to have one generated. |
DELETE /v1/mailbox/{instance}/addresses/{local} | write | Disable a name: its mail is refused and it cannot send. |
Limits and settings
| Limit or setting | Value |
|---|---|
Keep mail for (retentionDays) | 7 days by default, 1–90. Every message is deleted when it expires; keep changes one message's expiry. |
| Message size | 25 MB. Larger mail is refused at delivery. |
| Attachment size | 10 MB each, 100 per message. A larger one is not stored and is listed with rejected; the rest of the message is kept. |
| Stored body | 512 KB per part; longer bodies are cut, flagged textTruncated / htmlTruncated, and kept whole in the raw message. |
Received per day (inboundDailyCap) | 5,000 per mailbox by default, and a value you set can only lower it. Past it, delivery is deferred: the sending server retries, and the mail arrives after 00:00 UTC. |
Accept mail for (acceptMode) | any name (default) or registered names only. |
Keep the original (keepRaw) | On by default. Off stores no .eml, and ?raw=1 answers 404. |
| Region | Chosen when you create the mailbox, and fixed after. |
| Rate limit | 1,200 API calls a minute per mailbox by default; a value you set can only lower it. Receiving mail is not affected by it. |
Settings live under settings and routes under routes in the mailbox's config — change them in the console, or with MCP's patch_instance_config. Changing routes over MCP needs a full mailbox grant.
What it costs
Mail received, mail sent, and what you keep: the raw messages and attachments, and the message index. Storage is averaged over the month, and every message expires, so it stays small. A route's function call or channel publish bills as that service. See pricing for the numbers.
Testing locally
The emulator serves the same API, env.mailbox and MCP tools. Outgoing mail is not screened there, and sending never pauses. Addresses are on mail.localhost (--mail-domain to change it), and nothing sent leaves your machine: sends are recorded, and a send to a local address lands in that mailbox.
altengine dev --smtp # console at http://127.0.0.1:9191, SMTP on 127.0.0.1:2525The console's Mailbox tab composes test mail, uploads an .eml file, and replays a message through the current routes. Mail received over SMTP is marked as from an authenticated sender; --smtp-auth none marks it unauthenticated, to test reply mode.
From an AI agent
The MCP server exposes mailbox directly: mailbox_info for the addresses, mailbox_wait_for_message to wait for mail — with extract: "code" or "links" to pull out a verification code or the links — plus listing, reading, sending and replying with attachments, keeping a message longer (mailbox_keep_message, which only extends), and deleting. create_instance takes a slug for a mailbox, so an agent can pick a readable address.