reference · v1
MailIndia API
16 paths, two bearer formats, no accounts. This page is generated from /v1/openapi.json, so it describes the API you are talking to rather than a snapshot someone forgot to update.
how you prove it is your mailbox
Authentication
A mailbox is addressed by its email address, so every scoped call must present the bearer token returned by POST /v1/mailboxes. The token is HMAC-SHA256(MAILINDIA_TOKEN_SECRET, address) rendered in a lower-case base32 alphabet and truncated to 32 characters with a mi_ prefix. It is deterministic: the API keeps no session state, and re-minting a known address returns the same token, so losing it is recoverable.
A mik_… API key works on the same header and reads any mailbox — it belongs on a server, never in a browser. Keys need MONGODB_URI; only their SHA-256 hash is stored.
Query-string credentials (?token=, ?api_key=) are accepted where a browser cannot set headers, such as attachment download links.
Authorization: Bearer mi_7kqm2m3xnz4v8prt0w1ab2cd3ef456gh
# mailbox token · one inbox
Authorization: Bearer mik_4f2a9c1b8e6d3a0f557799bd2c1e4a6b8c0d2f4a
# api key · every inbox
stable shapes
Conventions
Successful payloads are wrapped in data; lists add meta. Errors always carry a stable code:
{ "error": { "code": "message_not_found",
"message": "no message m0k3...", "status": 404 } }Timestamps are epoch milliseconds, not ISO strings. Pagination is a cursor, never an offset: pass the previous meta.nextCursor as ?cursor=.
16 paths · grouped by tag
Endpoints
meta
Discovery, health, schema
4mailboxes
Mint, inspect, purge
3messages
Read, delete, download
6streaming
SSE and polling
2keys
Server-to-server credentials (admin)
3dev
Synthetic delivery for demos
1examples use shell variables — set API=http://localhost:8790, ADDR and TOK from your mint, then MSG from a message id.
every code the api can return
Error codes
| http | code | when |
|---|---|---|
| 400 | invalid_address | local part or host fails validation |
| 400 | unknown_domain | domain is not in MAILINDIA_DOMAINS |
| 401 | unauthorized | missing bearer token, or a token for a different mailbox |
| 403 | forbidden | origin not allow-listed, or wrong admin token |
| 404 | message_not_found · attachment_not_found · key_not_found · unknown_route | resource absent |
| 413 | message_too_large | raw message exceeds 2 MB (Worker) |
| 422 | validation_failed | body or query rejected by Zod, with details[] |
| 429 | rate_limited | budget exhausted, see retry-after |
| 502 | mailbox_unreachable · mailbox_error | the Durable Object did not answer |
| 503 | keys_unavailable · admin_unconfigured | MongoDB or admin token not configured |
| 500 | internal_error | bug; message is masked in production |
what to design around
Limits
| what | anonymous | with key |
|---|---|---|
| Requests | 60 / min | 600 / min |
| Mailboxes minted | 10 / min / IP | 10 / min / IP |
| Messages per mailbox | 100 (oldest evicted) | same |
| Raw message size | 2 MB | same |
| Message TTL | 60 s – 24 h, default 1 h | same |
Rate limits key on the client IP, so behind a proxy make sure trust proxy and X-Forwarded-For are honest. Exceeding a budget returns 429 with retry-after.
Each mailbox holds 100 messages; the oldest is evicted first. A sender that trips the 2 MB raw cap gets a permanent reject so their MTA bounces cleanly instead of retrying forever.
Message TTL is per mailbox, set at mint. The Durable Object that owns the address carries an alarm and sweeps its own rows, so expiry does not depend on anything calling the API.
copy and run
Client recipes
TypeScript · one-shot inbox
tsconst API = process.env.MAILINDIA_API_URL ?? "http://localhost:8790";
export async function createMailbox() {
const res = await fetch(`${API}/v1/mailboxes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ttlSeconds: 900 }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
return (await res.json()).data as { address: string; token: string };
}
export async function waitForCode(box: { address: string; token: string }, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/v1/mailboxes/${box.address}/messages`, {
headers: { authorization: `Bearer ${box.token}` },
});
const { data } = await res.json();
const hit = data.find((m: { subject: string }) => /code/i.test(m.subject));
if (hit) return hit;
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error("no message arrived");
}Python · fetch a verification code
pythonimport requests, time
API = "http://localhost:8790"
box = requests.post(f"{API}/v1/mailboxes", json={"ttlSeconds": 600}).json()["data"]
headers = {"Authorization": f"Bearer {box['token']}"}
for _ in range(30):
msgs = requests.get(f"{API}/v1/mailboxes/{box['address']}/messages", headers=headers).json()["data"]
if msgs:
print(requests.get(msgs[0]["url"], headers=headers).json()["data"]["body"])
break
time.sleep(2)Server-Sent Events
jsconst stream = new EventSource(
`${API}/v1/mailboxes/${address}/events?token=${token}`,
);
stream.addEventListener("message", (event) => {
const summary = JSON.parse(event.data);
console.log("arrived:", summary.subject);
});
stream.addEventListener("close", () => console.log("mailbox expired"));cURL · download an attachment
bashcurl -L -o invoice.pdf \ "$API/v1/mailboxes/$ADDR/messages/$MSG/attachments/att-0?token=$TOK"
where each piece runs
Deploy targets
workers/mx
Cloudflare Workers + Durable Objects
the only place SMTP can land without running a server
apps/api
Render / Fly / a container, or Netlify Functions via src/lambda.ts
SSE needs a persistent process; the lambda path trades it for /events/poll
apps/web
Netlify / Vercel / Cloudflare Pages
static + server components
MongoDB
Atlas free tier, optional
API keys and mailbox metadata only — never message bodies