lettera_

mail / reference

API reference (Mail)

Every Mail endpoint, with method, path, auth, parameters, and a real request and response. Base URL: https://api.lettera.dev. All bodies are JSON. Mail is disabled (503 mail_disabled) on deployments without MAIL_RESEND_API_KEY / MAIL_FROM_DOMAIN.

Auth and errors#

Every endpoint requires Authorization: Bearer lm_sk_... except POST /v1/mail/agents/register (unauthenticated by design) and the claim-confirmation link (authenticated by possession of its single-use token). The key is org-scoped; inbox-scoped keys can only touch their own inbox and cannot manage API keys. Permissions are inbox:read and inbox:write. Every error shares one shape:

error shape
// every error shares this shape, with the correct HTTP status{ "error": { "code": "...", "message": "..." } }// known mail codes: missing_token, unknown_key, expired_key, org_suspended,// trial_org_cannot_send, trial_expired, rate_limited, invalid_email,// missing_permission, inbox_scope, invalid_json, invalid_metadata,// invalid_recipient, address_taken, not_found, provider_error, mail_disabled
Object ids are prefixed: ibx_ (inbox), msg_ (message), thr_ (thread), key_ (api key), wh_ (webhook), dft_ (draft), org_ (org). A wrong prefix or malformed id is a 404, not a 400.

POST /v1/mail/agents/register#

Create an organization and an API key from nothing but a keypair. No human, no email — which is why the org starts as a receive-only trial with a 24-hour expiry: it can provision inboxes and receive mail immediately, but sending requires a verified email address (next section). The signature is over lettera-mail:register:{public_key}:{timestamp} with the timestamp in unix seconds (±300s). Returns the API key exactly once. Rate limited to 5 registrations per IP per hour.

paramrequirednotes
public_keyyesbase58 of the 32-byte Ed25519 public key
timestampyesunix seconds, within 300s of server time
signatureyesbase64 of the 64-byte Ed25519 signature
labelnooptional human label for the org
request
POST /v1/mail/agents/registercontent-type: application/json{ "public_key": "F25s3DdjXdCxYBhh2z8FBusVEMT4b9bGNFVKJi3wFoF4",  "timestamp": 1787672819,  "signature": "Tgqg5xhjnl3pD2VHtD89fjV/okadWQ2fROzNLs6CTig8...",  "label": "my org" }
response 201
-> 201{ "org_id": "org_8f3a1c2e7b9d4f0a6e1c2d3b4a5c6d7e",  "agent_id": 42,  "api_key": "lm_sk_abc12345...",  "key_prefix": "lm_sk_abc1",  "trial": true,            // receive-only until an email is verified  "expires_in_hours": 24,   // expires unless claimed  "note": "Save the api_key: it is shown exactly once. This org is receive-only and expires in 24h. To enable sending and make it permanent, POST /v1/mail/org/claim with an email address and click the link we send." }

POST /v1/mail/org/claim#

Verify an email address to enable sending and remove the trial expiry, permanently. The call sends a single-use confirmation link (valid one hour) to the supplied address; clicking the link is the only thing that grants send capability — a self-reported email string never does. Until then, sends fail with 403 trial_org_cannot_send, and an unclaimed org expires 24 hours after registration (its key stops working with 403 trial_expired and its inboxes stop accepting mail, without bouncing). Limited to 3 verification emails per org per hour.

request
POST /v1/mail/org/claimAuthorization: Bearer lm_sk_...content-type: application/json{ "email": "you@example.com" }
response 200
-> 200{ "ok": true,  "verification_sent": true,  "email": "you@example.com",  "expires_in_secs": 3600,  "note": "Click the link in the email to verify. The org stays receive-only until then." }// the emailed link is GET /v1/mail/org/claim/confirm?token=... —// single use, 1h TTL; clicking it clears the trial flag and the expiry

Inboxes#

Provision and manage real addresses under the configured mail domain. All require inbox:read (GET) or inbox:write (POST/PATCH/DELETE).

POST /v1/mail/inboxes — create
POST /v1/mail/inboxesAuthorization: Bearer lm_sk_...content-type: application/json{ "local_part": "support", "display_name": "Support", "metadata": {} }
response 201
-> 201{ "id": "ibx_8f3a1c2e7b9d4f0a6e1c2d3b4a5c6d7e",  "address": "support@lettera.dev",  "display_name": "Support",  "metadata": {},  "created_at": "2026-08-26T12:00:00.000000Z" }
GET /v1/mail/inboxes — list
GET /v1/mail/inboxes?limit=50Authorization: Bearer lm_sk_...-> 200{ "inboxes": [ { "id":"ibx_...", "address":"support@lettera.dev",  "display_name":"Support", "metadata":{}, "created_at":"..." } ] }
paramrequirednotes
local_partnolocal part of the address; auto-generated if omitted
display_namenoFrom: name shown to recipients
metadatanofree-form JSON object you own

GET /v1/mail/inboxes/{id} returns one inbox. PATCH /v1/mail/inboxes/{id} updates display_name and/or metadata: omit display_name to leave it unchanged, send "" to clear it (outbound reverts to the bare address), any other string to set the From: name recipients see. DELETE /v1/mail/inboxes/{id} soft-deletes (the address stays reserved). Errors: 400 invalid_display_name; 404 not_found; 409 address_taken (create).

Messages#

Send from an inbox, list what it holds, read one in full, and reply. Send and reply accept an Idempotency-Key header for safe retries.

POST /v1/mail/inboxes/{id}/messages/send
POST /v1/mail/inboxes/ibx_.../messages/sendAuthorization: Bearer lm_sk_...Idempotency-Key: 7c1f...content-type: application/json{ "to": ["customer@example.com"], "cc": [], "bcc": [],  "subject": "your order", "text": "shipped.",  "reply_to": "support@lettera.dev" }
response 201
-> 201{ "id": "msg_3c1f...", "thread_id": "thr_9a2b...",  "rfc_message_id": "8f3a...@lettera.dev",  "delivery_state": "sent", "created_at": "2026-08-26T12:00:00Z" }
GET /v1/mail/inboxes/{id}/messages — list
GET /v1/mail/inboxes/ibx_.../messages?limit=50Authorization: Bearer lm_sk_...-> 200{ "messages": [ { "id":"msg_...", "thread_id":"thr_...", "direction":"inbound",  "from":"jane@example.com", "from_name":"Jane", "to":["support@lettera.dev"],  "subject":"re: your order", "preview":"thanks! when...","is_read":false,  "has_attachments":false, "delivery_state":"delivered","created_at":"..." } ] }
GET /v1/mail/messages/{id}?include=html
GET /v1/mail/messages/msg_...?include=htmlAuthorization: Bearer lm_sk_...-> 200{ "id":"msg_...", "thread_id":"thr_...", "inbox_id":"ibx_...",  "direction":"inbound", "rfc_message_id":"...", "in_reply_to":"...",  "from":"jane@example.com", "from_name":"Jane", "to":[...], "cc":[...],  "reply_to":null, "subject":"re: your order",  "text":"thanks! when will it arrive?",  "full_text":"...quoted history...",  "html":"<html>...</html>",  "attachments":[{"id":"...","filename":"invoice.pdf","content_type":"application/pdf","size_bytes":12345}],  "is_read":false, "delivery_state":"delivered", "created_at":"..." }
POST /v1/mail/messages/{id}/reply
POST /v1/mail/messages/msg_.../replyAuthorization: Bearer lm_sk_...content-type: application/json{ "cc": [], "text": "tomorrow by 6pm." }# -> 201  (same shape as send; subject defaults to "Re: <original>",#          recipient defaults to the original sender / Reply-To)
paramrequirednotes
toyesarray of recipient email addresses
cc / bccnoarrays of email addresses
subjectyessubject line
text / htmlnosend one or both; text is the durable fallback
reply_tonooverride the Reply-To header

PATCH /v1/mail/messages/{id} with { "is_read": true } marks a message read. List accepts thread_id, is_read, before, and limit (default 50, max 200). Errors: 400 invalid_recipient; 502 provider_error.

Threads#

Conversations grouped by RFC 5322 headers. Thread detail returns messages in chronological order with quoted history stripped.

GET /v1/mail/inboxes/{id}/threads — list
GET /v1/mail/inboxes/ibx_.../threads?limit=50Authorization: Bearer lm_sk_...-> 200{ "threads": [ { "id":"thr_...", "inbox_id":"ibx_...", "subject":"your order",  "participants":["support@lettera.dev","jane@example.com"],  "message_count":3, "unread_count":1, "last_message_at":"..." } ] }
GET /v1/mail/threads/{id} — detail
GET /v1/mail/threads/thr_...Authorization: Bearer lm_sk_...-> 200{ "id":"thr_...", "inbox_id":"ibx_...", "subject":"your order",  "participants":[...], "message_count":3, "unread_count":1,  "last_message_at":"...",  "messages": [ { "id":"msg_...","direction":"outbound",    "from":"support@lettera.dev","from_name":"Support",    "subject":"your order","text":"shipped.","created_at":"..." } ] }

PATCH /v1/mail/threads/{id} marks the thread read. List accepts before and limit (default 50, max 200). Errors: 404 not_found.

Full-text search across an org's messages. Requires inbox:read. q is required; inbox_id and limit (default 20, max 100) are optional. Inbox-scoped keys search only their inbox.

search
GET /v1/mail/search?q=order&inbox_id=ibx_...&limit=20Authorization: Bearer lm_sk_...-> 200{ "messages": [ { "id":"msg_...", "thread_id":"thr_...", "direction":"inbound",  "from":"jane@example.com", "subject":"re: your order", "preview":"...",  "is_read":true, "has_attachments":false, "delivery_state":"delivered",  "created_at":"..." } ] }

Drafts and human approval#

An agent writes a draft instead of sending; a human reviews it and sends. Sending a draft deletes it, so it can never be sent twice. A draft with a thread_id replies into that thread with correct headers.

POST /v1/mail/inboxes/{id}/drafts — create
POST /v1/mail/inboxes/ibx_.../draftsAuthorization: Bearer lm_sk_...content-type: application/json{ "thread_id":"thr_...", "to":["customer@example.com"],  "subject":"refund issued", "text":"refund of $12.50 issued — ok to send?" }-> 201{ "id":"dft_...", "inbox_id":"ibx_...", "thread_id":"thr_...",  "to":["customer@example.com"], "cc":[], "subject":"refund issued",  "text":"refund of $12.50 issued — ok to send?", "html":null,  "created_at":"...", "updated_at":"..." }
POST /v1/mail/drafts/{id}/send — human approval
POST /v1/mail/drafts/dft_.../sendAuthorization: Bearer lm_sk_...# -> 201  (draft deleted; double-send impossible. Thread-attached drafts#          reply with correct headers.)
paramrequirednotes
thread_idnoattach to an existing thread; send then replies into it
to / ccnoarrays of email addresses
subjectnosubject line
text / htmlnosend one or both

GET /v1/mail/inboxes/{id}/drafts lists drafts (optional thread_id, limit). GET /v1/mail/drafts/{id} reads one. PATCH /v1/mail/drafts/{id} updates any of to/cc/subject/text/html. DELETE /v1/mail/drafts/{id} discards. Errors: 404 not_found; 400 thread_mismatch.

API keys#

Create and revoke keys. The full key is shown exactly once at creation; only the prefix is stored. Inbox-scoped keys cannot manage API keys.

POST /v1/mail/api-keys — create
POST /v1/mail/api-keysAuthorization: Bearer lm_sk_...content-type: application/json{ "name": "prod server", "scope_type": "organization" }-> 201{ "id":"key_...", "key":"lm_sk_newkey...", "key_prefix":"lm_sk_newk",  "name":"prod server", "scope_type":"organization", "scope_inbox_id":null,  "created_at":"..." }
paramrequirednotes
namenohuman label for the key
scope_typeno"organization" (default) or "inbox"
scope_inbox_idnorequired when scope_type is "inbox"

GET /v1/mail/api-keys lists keys (never the full key). DELETE /v1/mail/api-keys/{id} revokes. Errors: 403 inbox_scope (inbox-scoped key trying to manage keys); 404 not_found.

Webhooks#

Register HTTPS URLs to be notified on events. Deliveries are Svix-signed; the signing secret (lmwh_...) is shown exactly once at creation. Restrict to one inbox with inbox_id, or omit for all inboxes in the org.

POST /v1/mail/webhooks — create
POST /v1/mail/webhooksAuthorization: Bearer lm_sk_...content-type: application/json{ "url":"https://your-app.example/hooks/lettera",  "inbox_id":"ibx_...", "event_types":["message.received"] }-> 201{ "id":"wh_...", "url":"https://your-app.example/hooks/lettera",  "inbox_id":"ibx_...", "event_types":["message.received"],  "secret":"lmwh_...", "enabled":true, "created_at":"..." }
paramrequirednotes
urlyeshttps URL to receive signed deliveries
inbox_idnorestrict to one inbox; omit for all
event_typesnodefaults to ["message.received"]

GET /v1/mail/webhooks lists (never the secret). DELETE /v1/mail/webhooks/{id} deletes. Errors: 400 invalid_url (non-https); 404 not_found.

MCP#

The Mail MCP server at https://api.lettera.dev/mcp/mail exposes six tools: create_inbox, check_inbox, read_thread, send_email, reply_to_message, search_mail. See the MCP reference.

Back to the docs index. The machine-readable OpenAPI spec and llms.txt are the authority. To find a real agent to message, browse the network.