Developer reference
This page is for wiring an app, script, or agent to a synced mailbox. Reads come from the mirror; sends go through the mailbox SMTP account.
Start with accounts and folders, then search for message ids. Add sends, tags, MCP, or webhooks after the read path works.
Base URLs
# HTTP API is for app and script calls. HTTP API https://mcp.supamail.dev/v1 # MCP is for agent clients that speak Streamable HTTP. MCP https://mcp.supamail.dev/mcp
Authentication
Scripts and custom clients can create an smk_ token in the AI access section of Settings, then send it as a Bearer token. ChatGPT and Claude connectors use SupaMail OAuth 2.1 instead: the client discovers Supabase Auth, opens SupaMail's consent screen, and refreshes its access token without storing OAuth state in the MCP process.
| Token | What it is |
|---|---|
| smk_… | Use this for development, scripts, servers, and MCP clients. It is shown once. |
| OAuth JWT | Issued for a specific AI client and the exact MCP resource after user consent. Ordinary browser-session JWTs cannot call MCP. |
Authorization: Bearer smk_…
The protected-resource document is published at https://mcp.supamail.dev/.well-known/oauth-protected-resource. SupaMail requests openid email profile; clients may request offline_access from Supabase Auth so refresh tokens survive normal MCP deployments. Access tokens are accepted only when their issuer and audience match the published MCP resource.
Missing or invalid credentials return 401. Expired trials and canceled subscriptions return 403. Signed attachment download URLs returned by MCP are the one exception: they carry their own short-lived HMAC proof and need no bearer header.
Quickstart
Run these in order. /folders gives you the account id needed for sends and drafts. Search returns the message ids used by read, reply, tags, and move calls.
B=https://mcp.supamail.dev/v1
A="Authorization: Bearer smk_…" # from the AI access section in Settings
# 1. Get account ids and folder paths.
curl -s "$B/accounts" -H "$A"
curl -s "$B/folders" -H "$A"
# 2. Search the mirrored mailbox.
curl -s "$B/messages?q=from:stripe&limit=5" -H "$A"
# 3. Send with the account id from /accounts.
curl -s -X POST "$B/send" -H "$A" -H 'Idempotency-Key: quickstart-send-1' \
-H 'content-type: application/json' -d '{
"accountId": "<your account id>",
"to": [{"email": "someone@example.com"}],
"subject": "Hello",
"body": {"format": "plain", "text": "Sent from the SupaMail API."}
}'Core concepts
| Account id | Identifies the connected mailbox. Send and draft calls need it because they must choose an SMTP identity. Get it from GET /v1/accounts. |
| Message id | Use result.identity.id from search results anywhere a message id is required. |
| Mirror reads | Search and read calls return what has synced into the mirror, not a live IMAP fetch. |
| Errors | Branch on the HTTP status and stable error.code. Show error.message to humans. |
| Rate limit | About 120 requests per minute per mailbox. On 429, wait for Retry-After before retrying. |
| accounts | Read responses name each mailbox they came from. A notice appears only when that mailbox cannot give a complete answer: first_sync_in_progress, sync_stopped, or sync_paused. For sync detail, call GET /sync-status. |
HTTP API
Errors & status codes
Handle failures the same way for every HTTP API endpoint: the HTTP status gives the class of failure, and error.code gives your stable branch key.
Examples below assume B=…/v1 and A="Authorization: Bearer smk_…".
Search messages
Use search as the entrypoint for reads. With no filters it returns recent mirrored messages; add free text, search operators, or structured filters to narrow the result set.
| Param | Meaning |
|---|---|
| q | free-text + operators: from: to: cc: subject: body: in: |
| folder from to after before | structured filters |
| is_unread is_flagged has_attachment | true / false |
| account | limit to one mailbox |
| group_by_thread | false returns every message instead of one result per thread |
| sort limit offset | paging; offset reaches the first 10,000 matches |
| cursor | page.next_cursor from the previous page, for a deeper scan with group_by_thread=false and sort recent, oldest, size, or sender |
curl -s "$B/messages?folder=INBOX&is_unread=true&limit=10" -H "$A" curl -s "$B/messages?q=from:stripe%20subject:invoice" -H "$A"
Returns results, pagination, the accounts searched, and the parsed query. Message ids live at result.identity.id; each result also includes subject, participants, date, flags, snippet, attachment count, and thread metadata.
First-party dashboard transport
The SupaMail web dashboard uses two authenticated read routes. Both require mail.read.
| Endpoint | Does |
|---|---|
| GET /dashboard/mailboxes | mailbox status, progress, and message counts |
| GET /dashboard/sync-runs | recent sync runs; limit defaults to 100 and is capped at 500; errors_only=true filters failures; account=<uuid> limits results to one Mailbox Account |
These routes are first-party dashboard transport. External clients should use /accounts and /messages.
Read a message
This returns participants, subject, date, flags, and the full available normalized plain text. Use ?body_offset=N and a positive ?max_body_chars=N to request a specific range. There is no product character ceiling. body_content_status and body_omissions explicitly identify partial text. Add ?include_headers=true for raw headers or ?include_quoted=true for quoted text.
Read a thread
This returns messages in the conversation, oldest first. Replies contain newly authored text by default. Use ?max_messages=N to cap the window and ?include_quoted=true to keep quoted text. thread_content_status, thread_omissions, and omitted_message_count explicitly identify missing older messages.
Folders & drafts
Call this first for a new mailbox. It returns folder paths, message counts, unread counts, and account ids.
Use it when results seem to be missing. It returns each mailbox's sync state, progress, and a one-line summary.
List drafts with ?account= and ?limit=, or fetch one by id.
Send
Use the HTTP API to send only from a trusted server or local script. It sends immediately when the bearer has Send access.
{
"accountId": "<id>",
"to": [{"email": "a@b.com", "name": "A B"}],
"cc": [{"email": "c@d.com"}],
"subject": "Hi",
"body": {"format": "plain", "text": "…"},
"inReplyTo": "<message-id>", "references": "…"
}to, cc, and bcc are lists of addresses or {email, name} objects. Use body.format "plain" or "html". Send a unique Idempotency-Key header. Reuse it only when retrying the same send.
Returns delivery status, the RFC message id, the Sent folder path, and any warnings.
Reply, flags & move
// POST /messages/{id}/reply
// Header: Idempotency-Key: reply-1
{ "body": "…", "reply_all": false }
// POST /messages/{id}/flags
{ "read": true, "starred": false }
// POST /messages/{id}/move
{ "destination": "Archive" }Flags accept one or both booleans: read and starred. Custom IMAP flags are not part of the HTTP API.
A moved message keeps its id and its Tags (idKept: true). If idKept is false, search for the message again after a short wait.
Drafts
| Endpoint | Does |
|---|---|
| POST /drafts | create |
| PUT /drafts/{id} | update |
| POST /drafts/{id}/send | send a draft |
| DELETE /drafts/{id}?hard=true | delete |
Draft create/update bodies use the same compose shape as /send. Draft send also requires an Idempotency-Key header. Send a draft only after you have shown or generated the final body you want delivered.
Attachments
| Endpoint | Does |
|---|---|
| GET /messages/{id}/attachments | list metadata + account_id |
| GET /attachments/{attachmentId}/metadata | one attachment's metadata |
| GET /attachments/{attachmentId}/download | decoded bytes (binary) |
Metadata (attachmentId, filename, content type, size, content id, inline status, account_id) comes from the mirror; the accounts list maps each mailbox id to its email. File bytes are never stored by SupaMail.
Download fetches the IMAP part on demand and streams the decoded bytes. Responses are binary, private, and not cached. It returns 409 mailbox_conflict if the mailbox changed mid-fetch, or 422 content_unfetchable for a file above the pass-through size limit — never a partial file.
MCP agents should call create_attachment_download_url instead of fetching bytes through tools. It returns metadata plus a bearer-by-possession URL scoped to one attachment for 5 minutes, so ChatGPT, Claude, a browser, or curl can fetch the file without seeing your API token.
# list a message's attachment metadata curl -s "$B/messages/$ID/attachments" -H "$A" # download one attachment's decoded bytes (on-demand IMAP fetch) curl -L "$B/attachments/$ATTACHMENT_ID/download" -H "$A" -o file.bin
MCP
Connect a client
Use MCP for agent clients. If you are writing normal app code, use the HTTP API instead; MCP is useful when the caller expects tools rather than HTTP routes.
| Apps | ChatGPT or Claude: add the endpoint as a connector. The dashboard has copy-ready setup steps in the AI access section of Settings. |
| CLI / agent | Claude Code or your own agent: send an smk_ token as the bearer credential. |
Tools
Read
| Tool | Does |
|---|---|
| list_accounts() | list connected mailboxes and their account ids |
| search_email(q, filters?) | search the mirror; Gmail-style operators (from: subject: in: …) |
| read_message(message_id) | read one message body, headers, participants, and flags; long bodies support bounded continuation |
| read_thread(message_id or message_ids) | read one conversation or up to ten in one call; a batch returns at most 200 messages |
| list_folders(account?) | list folders with message and unread counts |
| get_sync_status(account?) | sync state, progress, and a one-line summary per mailbox |
| create_attachment_download_url(attachment_id) | return metadata + a 5-minute signed download link; no file bytes enter MCP |
Mail actions
| Tool | Does |
|---|---|
| create_outbound_attachment_upload(filename, size_bytes, sha256) | return a one-hour URL for uploading one local file |
| send_email(..., idempotency_key) | send a new email or threaded reply, with mirrored or new-file attachments |
| save_draft(...) | create or update a real provider draft |
| send_draft(draft_id, idempotency_key) | send a saved draft |
| move_messages(ids, folder) | move up to 100 messages |
| update_messages(ids, read?, starred?) | mark up to 100 messages read or starred |
For a new email or draft, omit account_id when only one mailbox is connected. With several mailboxes, call list_accounts and choose one. A send_email attachment can use { attachment_id } for a mirrored file. For a local file, call create_outbound_attachment_upload, upload the raw bytes to its URL, then use { outbound_attachment_id }. New and mirrored files share a 25 MiB limit. Temporary uploads expire after one hour and are deleted after delivery. Use one new idempotency_key for each send request. Reuse it only for a retry.
Read covers mailbox content plus listing and finding Tags. Organize covers drafts, moves, flags, and changing Tags. Send covers delivery. New connections receive all three by default.
Mail action results
Every mail action returns its name plus either result or error. Errors use stable codes, plain messages, and one next step. Provider details are never returned.
// success
{ "action": "send_email", "result": { "sent": true, "rfc_message_id": "…" } }
// failure
{ "action": "send_email", "error": {
"code": "mailbox_busy",
"message": "The mailbox is busy.",
"next_step": "Try again in a few seconds."
} }Tags: durable memory across runs
set_message_tag(message_id, key, value?) · remove_message_tag(message_id, key) · list_message_tags(message_id, limit?, cursor?) · find_messages_by_tag(key, value?, limit?, cursor?) · list_tag_keys(limit?) · list_tag_values(key, limit?). A common pattern is to set status=needs-reply, then find that Tag when an authorized agent resumes. When has_more is true, pass the returned next_cursor unchanged with the same query.
Guarantees
Permission checked
Every MCP and HTTP action checks its mailbox permission before it starts.
Tenant scoped
A token resolves to one tenant. The agent cannot read or mutate another mailbox.
Shared behavior
MCP and the HTTP API call the same underlying mail actions, so results and constraints stay aligned.
Webhooks
Register an endpoint
Add webhooks after your read path works. SupaMail sends a signed POST when sync observes new or changed mail; your handler should enqueue the ids and fetch details separately.
curl -s -X POST https://mcp.supamail.dev/v1/webhooks \
-H "Authorization: Bearer smk_…" -H 'content-type: application/json' \
-d '{"url": "https://your.app/hooks/supamail"}'
# response
{ "id": "…", "url": "https://your.app/hooks/supamail", "active": true,
"created_at": "…", "secret": "whsec_…" }The secret is shown once and is required for signature verification. URLs must be public HTTPS hostnames; localhost and IP literals are rejected. Each mailbox can have up to 10 endpoints.
| GET /v1/webhooks | list endpoints |
| DELETE /v1/webhooks/{id} | remove an endpoint |
The event
Webhook payloads are pointers, not message bodies. mail.new means new messages were mirrored. mail.changed means an existing message changed state, such as flags or folder placement.
{
"type": "mail.new", // or "mail.changed"
"event_id": "…", // dedupe on this
"tenant_id": "…",
"cursor": { "created_at": "…", "id": "…" }, // updated_at for mail.changed
"messages": [
{ "id": "…", "account_id": "…" },
{ "id": "…", "account_id": "…" }
],
"count": 2,
"occurred_at": "2026-06-27T…Z"
}Match on type, dedupe on event_id, then fetch details with GET /v1/messages/{id} or the MCP read tools.
Verify the signature
Verify the signature before parsing or trusting the event. Every delivery includes X-SupaMail-Signature: sha256=<hex> and X-SupaMail-Timestamp: <unix seconds>. The signed value is <timestamp>.<raw body>.
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, timestampHeader, secret) {
const signed = `${timestampHeader}.${rawBody}`;
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(signatureHeader), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Use the raw request body, not a re-serialized JSON object. Reject old timestamps if you want a replay window.
Delivery semantics
At least once
Retries can deliver the same event_id more than once. Store processed ids.
Retries
Failures retry with exponential backoff from 1 minute to 1 hour, then stop after 6 attempts.
Respond fast
Return 2xx within about 10 seconds. Any other status or timeout counts as a failure.
After sync
Events fire after mail is mirrored, usually in seconds and sometimes in minutes for large mailboxes.
No backfill
Registration starts from the current cursor. Historical messages are not replayed.