SupaMail/API reference

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

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.

TokenWhat it is
smk_…Use this for development, scripts, servers, and MCP clients. It is shown once.
OAuth JWTIssued for a specific AI client and the exact MCP resource after user consent. Ordinary browser-session JWTs cannot call MCP.
Authorization header
Authorization: Bearer smk_…
OAuth discovery

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.

Note

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.

quickstart.sh
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 idIdentifies the connected mailbox. Send and draft calls need it because they must choose an SMTP identity. Get it from GET /v1/accounts.
Message idUse result.identity.id from search results anywhere a message id is required.
Mirror readsSearch and read calls return what has synced into the mirror, not a live IMAP fetch.
ErrorsBranch on the HTTP status and stable error.code. Show error.message to humans.
Rate limitAbout 120 requests per minute per mailbox. On 429, wait for Retry-After before retrying.
accountsRead 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.

400bad input
401no / invalid token
403not entitled
404not found
429rate-limited · Retry-After
503service unavailable

Examples below assume B=…/v1 and A="Authorization: Bearer smk_…".

Search messages

GET/messagesall params optional

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.

ParamMeaning
qfree-text + operators: from: to: cc: subject: body: in:
folder from to after beforestructured filters
is_unread is_flagged has_attachmenttrue / false
accountlimit to one mailbox
group_by_threadfalse returns every message instead of one result per thread
sort limit offsetpaging; offset reaches the first 10,000 matches
cursorpage.next_cursor from the previous page, for a deeper scan with group_by_thread=false and sort recent, oldest, size, or sender
search.sh
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.

EndpointDoes
GET /dashboard/mailboxesmailbox status, progress, and message counts
GET /dashboard/sync-runsrecent 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

GET/messages/{id}

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

GET/messages/{id}/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

GET/foldersfolders + counts

Call this first for a new mailbox. It returns folder paths, message counts, unread counts, and account ids.

GET/sync-statussync state per mailbox

Use it when results seem to be missing. It returns each mailbox's sync state, progress, and a one-line summary.

GET/draftsalso /drafts/{id}

List drafts with ?account= and ?limit=, or fetch one by id.

Send

POST/send

Use the HTTP API to send only from a trusted server or local script. It sends immediately when the bearer has Send access.

Request body
{
  "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": "…"
}
Recipient shape

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}/replythreaded reply
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

EndpointDoes
POST /draftscreate
PUT /drafts/{id}update
POST /drafts/{id}/sendsend a draft
DELETE /drafts/{id}?hard=truedelete

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

EndpointDoes
GET /messages/{id}/attachmentslist metadata + account_id
GET /attachments/{attachmentId}/metadataone attachment's metadata
GET /attachments/{attachmentId}/downloaddecoded 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.

attachments.sh
# 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

Tags

Tags are SupaMail state stored in the Tenant-scoped control plane and shared by authorized clients in one Tenant. The last successful write to one message and key wins. Managed Hosting encrypts Tag keys and values with application-layer encryption. BYO Tag keys and values remain readable to the SupaMail runtime. Tags do not create provider labels or modify the mailbox.

A Tag remains until an authorized User or agent deletes it or its message is deleted. Tag reads exclude missing and provider-deleted messages. A daily bounded check deletes those stored Tag rows. Tenant deletion deletes all Tags. Settings shows each active message's Tags with the message subject and sender. A User can open the message, delete one Tag, or delete all Tenant Tags. See the Privacy Notice.

EndpointDoes
POST /messages/{id}/tags{key, value?}: set / update (idempotent)
GET /messages/{id}/tagslist a message's Tags; pass next_cursor as ?cursor= for the next page
DELETE /messages/{id}/tags/{key}remove (idempotent)
GET /tagslist the Tenant's Tags; pass next_cursor as ?cursor=
DELETE /tagsdelete every Tag in the Tenant
GET /tags/{key}/messagesfind by Tag; ?value= matches bare Tags; pass next_cursor as ?cursor=
tags.sh
curl -s -X POST "$B/messages/$ID/tags" -H "$A" \
  -H 'content-type: application/json' -d '{"key":"status","value":"triaged"}'

curl -s "$B/tags/status/messages?value=needs-reply" -H "$A"

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.

MCPhttps://mcp.supamail.dev/mcpStreamable HTTP · Bearer token
AppsChatGPT or Claude: add the endpoint as a connector. The dashboard has copy-ready setup steps in the AI access section of Settings.
CLI / agentClaude Code or your own agent: send an smk_ token as the bearer credential.

Tools

Read

ToolDoes
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

ToolDoes
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.

Permissions

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.

MCP action result
// 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.

register.sh
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_…" }
Save the secret

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/webhookslist 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.

Event payload
{
  "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>.

verify.js
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.

SupaMail · API v0.4.2 · HTTP API, MCP, webhooksHome · Get an API key