API guide
Versioned same-origin endpoints for authentication, inbox reads, settings, downloads, and sending.
The browser-facing API is versioned under /api/v1 on the Worker origin. The single Worker
dispatches those requests to the API module in-process; clients cannot call the private mail module
or its internal routes.
Use the generated OpenAPI 3.1 document at /api/v1/openapi.json as the machine-readable contract.
Check /api/v1/capabilities before depending on optional behavior.
Authentication
Browser clients use the HTTP-only session cookie created by magic-link verification. Non-browser integrations send a separately provisioned bearer token:
Authorization: Bearer YOUR_OPAQUE_API_TOKEN
Accept: application/jsonTokens can carry read, send, and settings scopes. A bearer token takes precedence when an
Authorization header is present; do not send an invalid bearer header alongside a valid browser
session.
Session-authenticated mutations must be same-origin. API-token callers are not subject to the cookie origin check, but still need the appropriate scope and mailbox membership.
Endpoint summary
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | Public | API health |
GET | /capabilities | Public | Supported optional behavior |
GET | /openapi.json | Public | OpenAPI 3.1 contract |
POST | /auth/magic-links | Public | Request a one-time sign-in link |
POST | /auth/magic-links/verify | Public | Consume a link and create a session |
GET | /auth/session | Public / optional session | Inspect the current browser session |
POST | /auth/logout | Session | Revoke the session and clear its cookie |
GET | /mailboxes | read | List authorized mailboxes and folder counts |
PATCH | /mailboxes/{mailboxId} | settings | Change sender alias and/or forwarding target |
GET | /threads | read | List, filter, search, and paginate threads |
GET | /threads/{threadId} | read | Fetch a thread and its chronological messages |
POST | /threads/{threadId}/read | settings | Mark inbound messages in a thread read |
POST | /threads/{threadId}/archive | settings | Remove messages from Inbox |
DELETE | /threads/{threadId}/archive | settings | Move messages to Inbox |
POST | /messages | send | Send a new multipart message |
POST | /threads/{threadId}/messages | send | Send a multipart reply |
GET | /messages/{messageId}/raw | read | Download the immutable RFC 822 source |
GET | /messages/{messageId}/attachments/{attachmentId} | read | Download an authorized attachment projection |
Prefix every path in the table with /api/v1 when calling the public web origin.
Request a magic link
curl --request POST 'https://inbox.example.test/api/v1/auth/magic-links' \
--header 'content-type: application/json' \
--data '{"email":"owner@example.test"}'The response is always intentionally generic:
{ "status": "accepted" }The email link points to the browser verification flow. Direct API clients can exchange its token at
POST /api/v1/auth/magic-links/verify with {"token":"..."}. A token can be consumed only once.
List and search threads
curl 'https://inbox.example.test/api/v1/threads?mailboxId=01996f7a-7bcd-7abc-8def-1123456789ab&folder=inbox&unread=1&limit=25' \
--header 'authorization: Bearer YOUR_OPAQUE_API_TOKEN'Supported query fields:
| Field | Values |
|---|---|
mailboxId | Required lower-case UUIDv7 mailbox ID |
folder | inbox, starred, sent, all, spam, trash (legacy archive also accepted); defaults to inbox |
unread | 1 to return unread threads only, or 0 |
q | Trimmed full-text search query, 1–200 characters |
limit | 1–50; defaults to 25 |
cursor | Opaque nextCursor from the previous response |
Cursors are stable continuation tokens, not offsets. Do not decode, edit, or persist them as record identifiers.
Update mailbox settings
Supply at least one field. null clears a value.
curl --request PATCH \
'https://inbox.example.test/api/v1/mailboxes/01996f7a-7bcd-7abc-8def-1123456789ab' \
--header 'authorization: Bearer YOUR_OPAQUE_API_TOKEN' \
--header 'content-type: application/json' \
--data '{"senderAlias":"Example Support","forwardTo":"owner@example.test"}'senderAlias changes the display name, not the envelope mailbox address. forwardTo controls the
optional post-capture forwarding destination.
Send a new message
Send endpoints accept multipart/form-data; let the HTTP client generate its boundary. Repeat to,
cc, bcc, and attachments fields as needed.
curl --request POST 'https://inbox.example.test/api/v1/messages' \
--header 'authorization: Bearer YOUR_OPAQUE_API_TOKEN' \
--header 'idempotency-key: 01996f7a-7bcd-7abc-8def-b123456789ab' \
--form 'mailboxId=01996f7a-7bcd-7abc-8def-1123456789ab' \
--form 'to=Customer <customer@example.test>' \
--form 'cc=Accounts <accounts@example.test>' \
--form 'subject=Example delivery' \
--form 'body=This is a synthetic message.' \
--form 'format=plain' \
--form 'attachments=@./synthetic-note.txt;type=text/plain'A reply uses /api/v1/threads/{threadId}/messages. Omit mailboxId; the API derives it from the
authorized thread. You may add targetMessageId to bind reply headers to a specific inbound message.
The idempotency-key must be 16–128 URL-safe ASCII characters. Repeat the same key only for the same
logical request. A different payload with an existing key returns 409 idempotency_conflict.
Successful submission returns 201 or 202 and a state document:
{
"id": "01996f7a-7bcd-7abc-8def-9123456789ab",
"idempotencyKey": "01996f7a-7bcd-7abc-8def-b123456789ab",
"state": "sent",
"threadId": "01996f7a-7bcd-7abc-8def-2123456789ab",
"messageId": "01996f7a-7bcd-7abc-8def-3123456789ab",
"acceptedAt": "2026-08-01T00:00:00.000Z",
"completedAt": "2026-08-01T00:00:01.000Z",
"safeErrorCode": null
}Possible states are queued, sending, sent, failed, and unknown. Treat unknown as an
ambiguous provider outcome: retain the original idempotency key for reconciliation and never create a
duplicate by switching keys.
Limits
| Limit | Value |
|---|---|
| Combined To/CC/BCC recipients | 50 |
| Attachments per API request | 20 |
| Size of one attachment | 10 MiB |
| Total uploaded attachment bytes | 20 MiB |
| Outbound provider wire budget | Below 5 MiB after safety margin and MIME/base64 overhead |
| Inbound raw message | 25 MiB |
| Threads per page | 50 maximum |
Encoded wire size is larger than the uploaded files. A multipart request can pass upload validation and still exceed the provider budget after headers, Markdown rendering, MIME structure, and base64 encoding are added.
Errors and request IDs
JSON failures use one stable envelope:
{
"error": {
"code": "validation_failed",
"message": "The request was not valid.",
"requestId": "01JZXY8J6VSK8PKSRM3R7S3B2G",
"details": {
"issues": ["subject: String must contain at least 1 character"]
}
}
}The same opaque ID appears in the x-request-id response header. Include it when reporting an
operational problem, but do not attach raw messages, tokens, or attachment contents.
Common statuses are 400 validation, 401 missing or expired authentication, 403 scope or CSRF,
404 absent or unauthorized resource, 409 idempotency conflict, 413 request size, 429 rate
limit, and 503 unavailable dependency.
Raw-message responses use message/rfc822; attachment responses are binary. Do not call .json() on
successful download responses.
HTML preferences and previews
Mailbox settings include boolean forwardHtml and renderHtml flags. forwardHtml defaults to true; renderHtml defaults to false. Update
either with PATCH /api/v1/mailboxes/{mailboxId}; omitted preferences are preserved. Only the mailbox
owner with settings permission can make changes. Forwarding changes apply to future deliveries.
GET /api/v1/messages/{messageId}/html returns an authenticated text/html preview when the mailbox
allows HTML display and the original MIME is retained. The response is private and uncached and must
be embedded in an opaque-origin sandbox. Missing, unauthorized, disabled, or expired resources do not
expose raw mail. The inbox falls back to plain text if a preview is unavailable or exceeds 4 MB.
Alias and message state actions
GET /mailboxes includes whitelisted for each owner-scoped alias. Daily navigation shows only
whitelisted records; GET /threads?mailboxId=other&folder=inbox groups quiet aliases. The same
other scope works with search, pagination, and every folder.
POST /mailboxes accepts { "address": "team@example.test", "forward": true } to create or promote
an inbox. The domain must be the configured domain or one already receiving mail. PATCH mailbox
settings accepts whitelisted; hiding a mailbox suppresses forwarding independently of its stored
destination preference.
PATCH /threads/{threadId}/state accepts optional read, starred, and location fields. At least
one action is required. Locations are inbox, archive, spam, trash, restore, and not_spam.
Optional messageIds (1–80 IDs) restrict the action to messages in that thread. Use explicit IDs to
mark displayed messages read without consuming new arrivals. Without IDs, an action affects the
whole conversation. These mutations require settings scope and same-origin protection for cookies.
GET /spam-rules lists the actor's blacklist. The installation owner can POST /spam-rules with
{ "kind": "recipient", "value": "unused@example.test" }, or kind sender / domain.
DELETE /spam-rules/{ruleId} removes an owned rule idempotently. Domain values include subdomains.
Rule creation and deletion affect future mail only. These endpoints require settings scope.
For an explicit single-message HTML preview while mailbox HTML display is off, request
GET /messages/{messageId}/html?preview=1. This does not update mailbox settings. Authentication,
mailbox access, raw retention, sanitization, and sandbox requirements still apply.
Mailbox creation accepts forwardTo as an email address or null to disable forwarding.
It takes precedence over the legacy forward boolean. Mailbox summaries and settings include
blocked, derived from matching recipient blacklist rules. Blocking does not modify existing messages.
Linked webmail attachments
Webmail uploads file parts through the authenticated Worker API to private R2 and submits linkedAttachmentIds in compose or
reply form data. Binary attachments fields remain available for existing API clients under their
original MIME safety bounds.
POST /api/v1/uploadswith JSONfilename,mediaType, andsizecreates an authenticated upload session and returnsid,partSize, andcomplete(true for empty files).PUT /api/v1/uploads/{id}/parts/{partNumber}streams a binaryapplication/octet-streambody into R2 and returns its receipt in theETagresponse header. Retain that ETag for completion. Parts are numbered from 1. Each PUT requires authentication and ownership; cookie sessions also require the same-origin mutation headers. Retrying the same part replaces it before completion.POST /api/v1/uploads/{id}/completewith orderedparts: [{partNumber, etag}]verifies the completed object. Send may reference only completed files owned by the caller.- Include each ID as a
linkedAttachmentIdsform value when sending. A file belongs to one outbound send; unchanged retries must reuse the same idempotency key and IDs.
The email contains HTML and plain-text links to /api/v1/downloads/{token}. These capability
links require no session, stream the file with attachment disposition, support byte ranges, and
never expire by age. Completed files and raw mail remain retained, including in Spam and Trash.
No product file-size or count quota applies to linked uploads; Cloudflare service limits and
message-body limits remain. The public OpenAPI document contains the upload contracts.