mailindia

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

4

mailboxes

Mint, inspect, purge

3

messages

Read, delete, download

6

streaming

SSE and polling

2

keys

Server-to-server credentials (admin)

3

dev

Synthetic delivery for demos

1

examples 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

httpcodewhen
400invalid_addresslocal part or host fails validation
400unknown_domaindomain is not in MAILINDIA_DOMAINS
401unauthorizedmissing bearer token, or a token for a different mailbox
403forbiddenorigin not allow-listed, or wrong admin token
404message_not_found · attachment_not_found · key_not_found · unknown_routeresource absent
413message_too_largeraw message exceeds 2 MB (Worker)
422validation_failedbody or query rejected by Zod, with details[]
429rate_limitedbudget exhausted, see retry-after
502mailbox_unreachable · mailbox_errorthe Durable Object did not answer
503keys_unavailable · admin_unconfiguredMongoDB or admin token not configured
500internal_errorbug; message is masked in production

what to design around

Limits

whatanonymouswith key
Requests60 / min600 / min
Mailboxes minted10 / min / IP10 / min / IP
Messages per mailbox100 (oldest evicted)same
Raw message size2 MBsame
Message TTL60 s – 24 h, default 1 hsame

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

ts
const 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

python
import 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

js
const 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

bash
curl -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