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:
// 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_disabledibx_ (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.
| param | required | notes |
|---|---|---|
public_key | yes | base58 of the 32-byte Ed25519 public key |
timestamp | yes | unix seconds, within 300s of server time |
signature | yes | base64 of the 64-byte Ed25519 signature |
label | no | optional human label for the org |
POST /v1/mail/agents/registercontent-type: application/json{ "public_key": "F25s3DdjXdCxYBhh2z8FBusVEMT4b9bGNFVKJi3wFoF4", "timestamp": 1787672819, "signature": "Tgqg5xhjnl3pD2VHtD89fjV/okadWQ2fROzNLs6CTig8...", "label": "my org" }-> 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.
POST /v1/mail/org/claimAuthorization: Bearer lm_sk_...content-type: application/json{ "email": "you@example.com" }-> 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 expiryInboxes#
Provision and manage real addresses under the configured mail domain. All require inbox:read (GET) or inbox:write (POST/PATCH/DELETE).
POST /v1/mail/inboxesAuthorization: Bearer lm_sk_...content-type: application/json{ "local_part": "support", "display_name": "Support", "metadata": {} }-> 201{ "id": "ibx_8f3a1c2e7b9d4f0a6e1c2d3b4a5c6d7e", "address": "support@lettera.dev", "display_name": "Support", "metadata": {}, "created_at": "2026-08-26T12:00:00.000000Z" }GET /v1/mail/inboxes?limit=50Authorization: Bearer lm_sk_...-> 200{ "inboxes": [ { "id":"ibx_...", "address":"support@lettera.dev", "display_name":"Support", "metadata":{}, "created_at":"..." } ] }| param | required | notes |
|---|---|---|
local_part | no | local part of the address; auto-generated if omitted |
display_name | no | From: name shown to recipients |
metadata | no | free-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/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" }-> 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/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/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/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)| param | required | notes |
|---|---|---|
to | yes | array of recipient email addresses |
cc / bcc | no | arrays of email addresses |
subject | yes | subject line |
text / html | no | send one or both; text is the durable fallback |
reply_to | no | override 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/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/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.
GET /v1/mail/search#
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.
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/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/dft_.../sendAuthorization: Bearer lm_sk_...# -> 201 (draft deleted; double-send impossible. Thread-attached drafts# reply with correct headers.)| param | required | notes |
|---|---|---|
thread_id | no | attach to an existing thread; send then replies into it |
to / cc | no | arrays of email addresses |
subject | no | subject line |
text / html | no | send 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-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":"..." }| param | required | notes |
|---|---|---|
name | no | human label for the key |
scope_type | no | "organization" (default) or "inbox" |
scope_inbox_id | no | required 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/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":"..." }| param | required | notes |
|---|---|---|
url | yes | https URL to receive signed deliveries |
inbox_id | no | restrict to one inbox; omit for all |
event_types | no | defaults 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.