Mailman API
A REST API to send, receive, search, and organize email programmatically. Authenticate with an mm_ key, call JSON endpoints, and (optionally) receive signed webhooks for inbound mail and delivery events.
Introduction
Every endpoint lives under /v1, speaks JSON, and is authenticated with a bearer API key. A single key belongs to one owner and is granted a set of scopes and access to one or all of the owner's mailboxes (accounts). Use it to build transactional email, inbound-mail automations, inbox tooling, or a full mail client.
Base URL
https://mail.baliyang.com/v1All paths in this reference are relative to that base. Requests are server-to-server (send the key from your backend, never from a browser). All requests must be HTTPS.
Quickstart
Send your first email in three steps.
- Create a key in the Mailman app (Settings → API keys). Pick scopes (at least
send) and either a specific mailbox or all mailboxes. The full key is shown once — copy it. - Store it as a secret in your service (e.g.
MAILMAN_KEY). It looks likemm_ab12cd34_…. - Send:
curl -X POST https://mail.baliyang.com/v1/emails \
-H "Authorization: Bearer $MAILMAN_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-user-9f3a" \
-d '{
"to": [{ "address": "user@example.com", "name": "New User" }],
"subject": "Welcome to Acme",
"text": "Thanks for signing up!",
"html": "<p>Thanks for signing up!</p>"
}'{
"messageId": "<9f3a…@baliyang.com>",
"threadId": 1024,
"dbId": 50231,
"sentAt": 1737700000000,
"tag": null
}Authentication
Pass your key as a bearer token on every request:
Authorization: Bearer mm_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys have the shape mm_<prefix>_<secret>. The 8-character prefix is safe to log or display for identification; the secret is only shown once at creation and is stored SHA-256-hashed — Mailman can never show it again. Rotate by creating a new key and revoking the old one.
invalid_key.Scopes
Each key carries a set of scopes. A request that needs a scope the key lacks returns 403 insufficient_scope with the required and granted fields.
| Scope | Grants |
|---|---|
send | Send email (POST /v1/emails) |
inbox:read | Read threads, messages, labels, filters, key context |
inbox:modify | Archive / trash / read / snooze threads, manage labels & filters |
search | Full-text search (GET /v1/search) |
attachments:read | Download attachments |
attachments:write | Upload attachments to send |
Grant the minimum a service needs. A transactional-email service usually needs only send (plus attachments:write if it sends files).
Accounts (mailboxes)
An owner can have several mailboxes ("accounts"). How a request picks one:
- All-mailboxes key — pass any
accountIdyou own; if omitted, the key'sdefaultAccountIdis used. - Single-mailbox key — you may omit
accountId(the bound mailbox is used) or pass that same id. A different id returns 403account_not_authorized_by_key. - If a key has no default and you omit
accountId, you get 400account_required.
Read the current key's owner, scopes, default account, and mailbox list from GET /v1/me.
Rate limits & quotas
- Daily send quota: up to 1000 messages per mailbox per rolling 24h. Exceeding returns 429
send_daily_limit_exceeded(with alimitfield). - Per-key request quota: a key may carry an optional daily request cap; exceeding returns 429
key_daily_limit_exceeded. - Only successful (2xx/3xx) requests count against quotas. Windows are hourly/daily buckets, not per-second throttling.
Idempotency
Send POST /v1/emails with an Idempotency-Key header (any unique string, ≤ 255 chars) so retries never send twice.
- Replaying the same key within 24 hours returns the original response — no duplicate message is created.
- A concurrent request with the same key while the first is still in flight returns 409
idempotency_in_progress— retry in a few seconds. - Best practice: derive the key from your own event id (e.g.
invoice-4821-receipt), so a retry of the same logical action reuses it.
Errors
Errors are JSON with a machine-readable error code and an optional human detail. Validation errors add field; scope errors add required/granted; rate limits add limit.
{
"error": "insufficient_scope",
"required": "send",
"granted": ["inbox:read", "search"]
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json / missing_fields / invalid_input | Bad body, missing required field, or a malformed value (see field). |
| 400 | account_required | No accountId and the key has no default. |
| 401 | unauthorized / invalid_key | Missing/malformed header, or a revoked/unknown/expired key. |
| 403 | insufficient_scope | Key lacks the required scope. |
| 403 | account_not_authorized_by_key / forbidden | The key isn't allowed on that mailbox. |
| 404 | not_found | Thread / message / label / filter / attachment doesn't exist. |
| 409 | idempotency_in_progress | A send with the same Idempotency-Key is still processing. |
| 429 | send_daily_limit_exceeded / key_daily_limit_exceeded | Quota hit — back off and retry later. |
| 502 | send_failed | Transient delivery failure — safe to retry (use an Idempotency-Key). |
| 503 | sending_not_enabled | The sender domain isn't onboarded for sending. |
Send email
sendSend a message from one of your mailboxes. Supports plain text and/or HTML, CC/BCC, reply threading, attachments, and a free-form tag.
| Field | Type | Notes | |
|---|---|---|---|
to | Address[] | required | 1–50 recipients. Each is { "address": string, "name"?: string }. |
subject | string | required | Up to 998 bytes. |
text | string | required | Plain-text body (≤ 1 MB). Always send this, even with HTML. |
html | string | optional | HTML body (≤ 1 MB). |
accountId | number | optional | Sending mailbox; defaults to the key's default. |
cc, bcc | Address[] | optional | Same shape as to. |
replyTo | string | optional | Reply-To address. |
inReplyTo | string | optional | A Message-ID to reply to (RFC 5322). |
references | string | optional | Space-separated Message-IDs for threading. |
tag | string | optional | Your label for the send (echoed back & stored). |
attachments | Attachment[] | optional | Up to 20. Each is { key, filename, mime, size } from the upload endpoint. |
curl -X POST https://mail.baliyang.com/v1/emails \
-H "Authorization: Bearer $MAILMAN_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-shipped" \
-d '{
"accountId": 5,
"to": [{ "address": "buyer@example.com", "name": "Buyer" }],
"cc": [{ "address": "ops@acme.co" }],
"subject": "Your order shipped",
"text": "Tracking: 1Z999…",
"html": "<p>Tracking: <b>1Z999…</b></p>",
"tag": "transactional"
}'{
"messageId": "<a1b2…@baliyang.com>",
"threadId": 2048,
"dbId": 99123,
"sentAt": 1737700123000,
"tag": "transactional"
}Get a message
inbox:read:id may be a numeric message id or a Message-ID header (with or without the angle brackets). Returns metadata + delivery status; fetch the full thread for bodies & attachments.
{
"id": 99123,
"threadId": 2048,
"messageId": "<a1b2…@baliyang.com>",
"subject": "Your order shipped",
"direction": "out",
"from": { "address": "shop@acme.co" },
"to": [{ "address": "buyer@example.com" }],
"sentAt": 1737700123000,
"hasAttachments": false,
"deliveryStatus": "delivered",
"deliveryError": null,
"tag": "transactional"
}List threads
inbox:readCursor-paginated list of threads in a mailbox folder.
| Query | Notes | |
|---|---|---|
accountId | optional | Defaults to the key's default mailbox. |
folder | optional | inbox (default), sent, archive, trash, all. |
limit | optional | Default 50, max 200. |
cursor | optional | Pass nextCursor from the previous page. |
curl -H "Authorization: Bearer $MAILMAN_KEY" \
"https://mail.baliyang.com/v1/threads?accountId=5&folder=inbox&limit=25"{
"threads": [
{
"id": 2048,
"subject": "Project sync",
"snippet": "Let's line up Q3…",
"participants": [{ "name": "Alice", "address": "alice@x.com" }],
"lastMessageAt": 1737700000000,
"messageCount": 6,
"unreadCount": 1
}
],
"nextCursor": "1737699000000"
}Get a thread
inbox:readThe thread plus every message (with plain-text bodies and attachment counts).
{
"thread": { "id": 2048, "subject": "Project sync", "unreadCount": 1, "archived": false, "trashed": false },
"messages": [
{
"id": 99120,
"direction": "in",
"from": { "name": "Alice", "address": "alice@x.com" },
"to": [{ "address": "you@acme.co" }],
"subject": "Project sync",
"text": "Full plain-text body…",
"sentAt": 1737699000000,
"read": true,
"attachmentCount": 1
}
]
}Archive · Trash · Read · Snooze
inbox:modifyEach takes a small JSON body and returns { "ok": true }.
| Endpoint | Body |
|---|---|
…/archive | { "archived": true } (or false to un-archive) |
…/trash | { "trashed": true } (or false to restore) |
…/read | { "read": true } (marks all messages read/unread) |
…/snooze | { "until": 1737800000000 } (ms timestamp, or null to un-snooze) |
Thread labels
inbox:readinbox:modifyinbox:modifyApply with { "labelId": 3 } (the label must belong to the thread's mailbox). All return { "ok": true }; GET returns { "labels": [{ "id", "name", "color" }] }.
Labels
inbox:readinbox:modifyinbox:modifyCreate with { "name": "Receipts", "color": "#34d399", "accountId": 5 }. List with ?accountId=5 (or ?accountId=all for a multi-mailbox key).
{
"labels": [
{ "id": 3, "accountId": 5, "name": "Receipts", "color": "#34d399", "threadCount": 42 }
]
}Search
searchFull-text search with Gmail-style operators. q is required; accountId (or all) and limit (max 100) are optional.
| Operator | Matches |
|---|---|
from:alice@x.com | sender |
to:me@acme.co | recipient |
subject:invoice | subject contains |
has:attachment | has an attachment |
is:unread · is:read · is:sent | state |
before:2026-01-01 · after:2025-12-01 | date range |
curl -H "Authorization: Bearer $MAILMAN_KEY" \
"https://mail.baliyang.com/v1/search?q=from:stripe%20is:unread&accountId=all"Filters
inbox:readinbox:modifyinbox:modifyinbox:modifyFilters run on inbound mail: a match (substring on from/to/subject/body, or hasAttachment) triggers an ordered list of actions. POST creates (omit id) or updates (include id); run-on applies a filter to one thread for testing.
{
"accountId": 5,
"name": "Auto-file receipts",
"enabled": true,
"stopOnMatch": false,
"match": { "subject": "receipt", "hasAttachment": true },
"actions": [
{ "type": "apply_label", "label_id": 3 },
{ "type": "mark_read" },
{ "type": "archive" }
]
}Action types: apply_label (with label_id), archive, mark_read, add_to_contacts.
Attachments
attachments:writeattachments:readSending a file is two steps: upload the raw bytes to get a key, then reference that key in POST /v1/emails. Up to 20 attachments per message.
curl -X POST "https://mail.baliyang.com/v1/attachments/upload?accountId=5" \
-H "Authorization: Bearer $MAILMAN_KEY" \
-H "Content-Type: application/pdf" \
-H "X-Filename: invoice.pdf" \
--data-binary @invoice.pdf{
"key": "5/uploads/9f3a…-invoice.pdf",
"filename": "invoice.pdf",
"mime": "application/pdf",
"size": 102400
}{
"to": [{ "address": "buyer@example.com" }],
"subject": "Your invoice",
"text": "Attached.",
"attachments": [
{ "key": "5/uploads/9f3a…-invoice.pdf", "filename": "invoice.pdf", "mime": "application/pdf", "size": 102400 }
]
}Download an inbound attachment by its id (found on a message): GET /v1/attachments/:id returns the raw bytes with a Content-Disposition filename.
Key context
Introspect the current key — owner, scopes, default mailbox, and the mailboxes it can reach. Useful at startup to validate a key and discover account ids.
{
"owner": { "id": 1, "email": "you@acme.co", "displayName": "Acme" },
"key": { "id": 42, "scopes": ["send", "inbox:read"], "allAccounts": false, "defaultAccountId": 5, "dailyLimit": null },
"accounts": [
{ "id": 5, "address": "shop@acme.co", "displayName": "Shop" }
]
}Webhooks
Register an HTTPS endpoint in the Mailman app to receive events for inbound mail and outbound delivery. Every delivery is signed so you can verify it came from Mailman.
Events
| Type | Fires when |
|---|---|
mail.received | An inbound message arrives |
email.queued · email.delivered | Outbound send accepted / delivered |
email.bounced · email.complaint | Delivery failed / spam complaint |
thread.archived · thread.trashed · thread.read_changed | Thread state changed |
message.read | A message was marked read |
Delivery
Mailman POSTs JSON to your URL with these headers:
Content-Type: application/json
X-Mailman-Event-Id: 7f1c… # unique per event
X-Mailman-Event-Type: email.delivered
X-Mailman-Attempt: 1 # 1–5
X-Mailman-Signature: t=1737700000,v1=<hex-hmac-sha256>{
"id": "7f1c…",
"type": "email.delivered",
"createdAt": 1737700000000,
"data": { "messageId": "<a1b2…>", "threadId": 2048, "accountId": 5, "deliveryStatus": "delivered" }
}Verify the signature
The signature is HMAC-SHA256("<t>.<raw-body>") using the endpoint's signing secret (a hex string shown once when you create the endpoint). Compute it and constant-time-compare to the v1= value. Reject if t is older than ~5 minutes.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return ok && fresh;
}X-Mailman-Event-Id.Wire up a service
A minimal transactional-email helper in Node — the pattern most services need:
const BASE = "https://mail.baliyang.com/v1";
const KEY = process.env.MAILMAN_KEY; // mm_… kept server-side
export async function sendEmail({ to, subject, text, html, idempotencyKey }) {
const res = await fetch(`${BASE}/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
},
body: JSON.stringify({ to, subject, text, html }),
});
const body = await res.json();
if (!res.ok) throw new Error(`mailman ${res.status}: ${body.error}`);
return body; // { messageId, threadId, sentAt, … }
}
// usage — idempotent per business event
await sendEmail({
to: [{ address: user.email, name: user.name }],
subject: "Password reset",
text: `Reset: ${link}`,
idempotencyKey: `pwreset-${resetToken}`,
});Mailman API · v1 · base https://mail.baliyang.com/v1