LetterDuck API and MCP reference
One address per call. It receives, parses, threads, stores, searches, replies and runs the newsletter, and an agent can drive all of it over MCP.
Everything here speaks JSON over HTTPS. The base URL is https://api.letterduck.com/v1. Requests authenticate with a bearer token, responses are UTF-8, and every object carries a prefixed id (adr_, thr_, msg_, evt_) so you can tell at a glance what you are holding.
The design rule behind the whole surface: you work with threads, not messages. A send-only API hands you individual messages and leaves you to reassemble the conversation. Here the conversation is the primitive, because that is what an inbox actually is.
Quickstart
Provision an address, then read what arrives at it.
# 1. Give your domain a new mailbox
curl -X POST https://api.letterduck.com/v1/addresses \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Content-Type: application/json" \
-d '{"address": "support@yourdomain.com", "display_name": "Support"}'
# 2. Read the conversations waiting in it
curl "https://api.letterduck.com/v1/threads?address=support@yourdomain.com&unread=true" \
-H "Authorization: Bearer $LETTERDUCK_KEY"
# 3. Answer one, in thread
curl -X POST https://api.letterduck.com/v1/threads/thr_8sd21/reply \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Content-Type: application/json" \
-d '{"from": "support@yourdomain.com", "text": "Reissuing that invoice now."}'
The same three calls in Node:
import { LetterDuck } from '@letterduck/node';
const ld = new LetterDuck(process.env.LETTERDUCK_KEY);
await ld.addresses.create({ address: 'support@yourdomain.com', displayName: 'Support' });
const { data: threads } = await ld.threads.list({
address: 'support@yourdomain.com',
unread: true,
});
await ld.threads.reply(threads[0].id, {
from: 'support@yourdomain.com',
text: 'Reissuing that invoice now.',
});
There is no step where you parse MIME, decide which conversation a message belongs to, or work out which headers keep a reply in thread.
Authentication
Send the key as a bearer token on every request.
Authorization: Bearer ld_live_9f2c...
Keys are scoped, and the scope is the security model rather than a formality:
| Scope | What it can do |
|---|---|
threads:read | List and read threads, read messages, search |
threads:write | Reply to a thread, mark read, archive |
send | Send new mail from any address on the domain |
addresses:write | Create, pause and delete addresses |
automations:write | Auto-reply rules, scheduled sends, follow-ups |
contacts:write | Create groups, add and remove members |
newsletter:read | Issues, audiences and reports |
newsletter:write | Draft, test, schedule and send issues |
A key can also be pinned to a single address. A support agent gets threads:read, threads:write and send on support@yourdomain.com and cannot touch billing@, which is the difference between giving an agent a job and giving it your domain.
Keys are secret. There is no browser-safe publishable key, because every operation here either reads private mail or sends as you.
Addresses
POST/v1/addresses
Provisions a real mailbox. DNS is already in place from domain setup, so the address is live when the call returns.
{
"address": "support@yourdomain.com",
"display_name": "Support",
"can_send": true
}
{
"id": "adr_2Kf9",
"address": "support@yourdomain.com",
"display_name": "Support",
"can_send": true,
"created_at": "2026-08-10T09:14:22Z"
}
Addresses are unlimited on your domain. There is no per-mailbox charge, which is the point: an address per customer, per ticket or per agent run is a normal thing to do here.
GET/v1/addresses
Lists every address on the domain with counts.
DELETE/v1/addresses/{id}
Stops delivery. Stored mail is kept, so the history of a retired address survives it.
Threads
GET/v1/threads
Query parameters: address, unread, q, limit, cursor.
{
"data": [
{
"id": "thr_8sd21",
"address": "support@yourdomain.com",
"participant": "sofia@ledgerhouse.com",
"subject": "Invoice 2043 has the wrong VAT number",
"unread": true,
"message_count": 2,
"last_message_at": "2026-08-10T08:41:02Z"
}
],
"next_cursor": "eyJvIjoyNX0"
}
GET/v1/threads/{id}
The same object with messages expanded in order, each with from, to, direction, text, html, headers and attachments.
How a message joins a thread
In order: In-Reply-To against a Message-ID we issued, then any id in References against the same, then the (participant, normalised subject) pair inside a 30 day window. Subject normalisation strips Re:, Fwd: and their localised forms. If nothing matches, the message starts a thread.
The last rule is the one worth knowing about, because it is the one that can be wrong: two unrelated messages from the same person with the same subject inside a month join. Set X-LetterDuck-Thread: thr_8sd21 on an outbound message to force placement and skip the heuristics entirely.
GET/v1/messages/{id}/raw
The original .eml, byte for byte, as it arrived. Content type message/rfc822. You will want this the first time somebody disputes what they sent.
Sending and replying
POST/v1/threads/{id}/reply
{
"from": "support@yourdomain.com",
"text": "Reissuing that invoice now.",
"html": "<p>Reissuing that invoice now.</p>"
}
In-Reply-To and References are built from the stored message, so the reply lands inside the existing conversation in Gmail, Outlook and Apple Mail. Getting this wrong is why support tooling built on send-only APIs splits threads into confetti.
POST/v1/send
Starts a new conversation. Takes from, to, subject, text, html, optional headers and attachments, and returns the created thread_id so the reply you get back is already attached to something.
Every send passes the suppression list first. A recipient who bounced, complained or unsubscribed is refused with 422 suppressed_recipient rather than quietly dropped, so your logs tell the truth.
Send Idempotency-Key with any POST that creates something. A repeat of the same key within 24 hours returns the original response instead of sending again, which is what you want when a retry fires against a request that actually succeeded.
curl -X POST https://api.letterduck.com/v1/send \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Idempotency-Key: order-4482-receipt" \
-H "Content-Type: application/json" \
-d @body.json
Attachments
Inline as base64 or by URL, up to 25 MB across the whole message after encoding, which is the practical ceiling most receivers enforce.
{
"attachments": [
{ "filename": "invoice-2043.pdf", "content": "JVBERi0xLjQK..." },
{ "filename": "catalogue.pdf", "url": "https://yourapp.com/files/catalogue.pdf" }
]
}
Inbound attachments are stored and returned on the message as { id, filename, content_type, size, download_url }. Download URLs are signed and expire after an hour; call the message again for a fresh one.
Webhooks
Point an endpoint at us and we post JSON. Events:
| Event | Fires when |
|---|---|
message.received | Mail arrives at one of your addresses, parsed and threaded |
message.sent | An outbound message is accepted by the relay |
message.delivered | The receiving server accepted it |
message.bounced | Hard or soft bounce, with the reason |
message.complained | A spam complaint, which also suppresses the recipient |
{
"id": "evt_71bd",
"type": "message.received",
"created_at": "2026-08-10T08:41:02Z",
"data": {
"thread_id": "thr_8sd21",
"message_id": "msg_44f1",
"address": "support@yourdomain.com",
"from": "sofia@ledgerhouse.com",
"subject": "Invoice 2043 has the wrong VAT number"
}
}
Verifying a delivery
Every request carries X-LetterDuck-Signature: t=1754812862,v1=5257a8.... Compute HMAC-SHA256 over t + "." + rawBody using your endpoint secret and compare in constant time. Reject anything where t is more than five minutes old, which is what stops a captured request being replayed at you later.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify against the raw body, before any JSON parsing: re-serialising changes the bytes and the signature will not match.
Delivery is at-least-once, so use the event id to deduplicate. We retry a non-2xx for 24 hours with exponential backoff, then disable the endpoint and email you.
The MCP server
The reason the API is shaped around threads. An agent connected over the Model Context Protocol gets its own address on your domain rather than borrowed credentials for a human's mailbox, and the same scoped keys apply.
{
"mcpServers": {
"letterduck": {
"command": "npx",
"args": ["-y", "@letterduck/mcp"],
"env": { "LETTERDUCK_KEY": "ld_live_9f2c..." }
}
}
}
Tools exposed:
| Tool | Scope required |
|---|---|
list_threads | threads:read |
read_thread | threads:read |
search | threads:read |
summarize_inbox | threads:read |
reply_to_thread | threads:write |
send_email | send |
create_address | addresses:write |
set_auto_reply | automations:write |
schedule_send | automations:write |
manage_group | contacts:write |
draft_issue | newsletter:write |
send_issue | newsletter:write |
issue_report | newsletter:read |
A read-only key exposes only the read tools, so an agent you are still testing physically cannot send mail as your company. That is enforced server-side, not by prompting.
What an agent actually does with this
The tool list is the means; these are the jobs. Any MCP client runs them: Claude Code and Codex from the terminal, Claude and other assistants from chat, your own agent from the SDK.
- It runs the newsletter. Point Claude Code at your repo and the MCP server, and Friday afternoon looks like: read the week's merged changes,
draft_issueinto the Letter template, send itself a test,send_issuescheduled for Sunday 08:00 to the engaged segment, thenissue_reporton Monday and a summary in your terminal, with clicks verified and opens labelled estimates. - It works the support address with a gate. A key pinned to
support@yourdomain.comwiththreads:readgives an agent triage:summarize_inboxfor the morning brief,read_threadand a drafted answer for a human to approve. Graduate the key tothreads:writewhen the drafts stop needing edits. - It keeps the list clean. A contact replies "add my colleague": the agent reads the thread,
manage_groupadds the address to Stockists, and every future send to that group includes them. No CSV round-trip. - It gets its own identity.
create_addressgives an agent or a job its own mailbox (agent-runs@,receipts@), scoped to exactly what it should touch, with every message it sends and receives threaded and kept. When the experiment ends, delete the address; the history survives.
Automations
POST/v1/auto-replies
An auto-reply applies to everyone, to a saved contact group, or to a list of addresses.
{
"name": "New enquiries",
"applies_to": { "type": "group", "group": "Leads" },
"message": "Thanks for getting in touch. Pricing and lead times are on their way today."
}
applies_to.type is all, group or addresses. Replies fire once per sender per thread, not once per message, which is the difference between an auto-responder and a loop.
POST/v1/scheduled-sends
Same body as /v1/send plus send_at in ISO 8601. Returns a sch_ id you can cancel until it fires.
Groups
Groups are the audience primitive. Make one once and it works everywhere mail goes: sends to many people, newsletter audiences, auto-replies, follow-ups.
curl -X POST https://api.letterduck.com/v1/groups \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Stockists", "members": ["sam@northsupply.co", "lena@mossandco.shop"]}'
GET /v1/groups lists them with counts; POST /v1/groups/{id}/members adds or removes; POST /v1/send accepts {"to": {"group": "Stockists"}} anywhere it accepts addresses.
The newsletter
The same engine that sends our own list, exposed as objects. An issue is a draft until you send it, a template is a named layout that dresses the body, an audience is a segment or one of your groups, and the report separates what is measured from what is estimated.
# Draft an issue into a template
curl -X POST https://api.letterduck.com/v1/newsletter/issues \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Content-Type: application/json" \
-d '{"subject": "Issue no. 14: the paper we print on",
"preview_text": "Inside: the mill visit and the new stock.",
"markdown": "The mill was louder than the presses...",
"template": "letter"}'
# Send yourself a test, then schedule it
curl -X POST https://api.letterduck.com/v1/newsletter/issues/iss_9a41/test \
-H "Authorization: Bearer $LETTERDUCK_KEY"
curl -X POST https://api.letterduck.com/v1/newsletter/issues/iss_9a41/send \
-H "Authorization: Bearer $LETTERDUCK_KEY" \
-H "Content-Type: application/json" \
-d '{"audience": {"type": "segment", "segment": "engaged_90d"}, "send_at": "2026-08-16T08:00:00Z"}'
GET /v1/newsletter/issues/{id}/report returns delivered, clicks_verified, opens_estimated, unsubscribed and the top links. The split is deliberate: Apple opens mail by machine and security scanners click every link, so clicks are verified humans and opens are labelled the estimate they are. Numbers your agent can act on rather than numbers that flatter.
Templates are the same ones the workspace uses (postcard, letter, broadsheet today; new ones land every month) and they apply to regular sends too: pass "template" to /v1/send and the message goes out dressed.
GET /v1/subscribers/export streams the whole list as CSV with history. Your list is yours; the export exists so that stays true.
Errors
Conventional status codes, with a machine-readable code and a sentence a human can act on.
{
"error": {
"code": "suppressed_recipient",
"message": "sofia@ledgerhouse.com unsubscribed on 2026-07-02 and cannot be mailed.",
"status": 422
}
}
| Code | Status | Meaning |
|---|---|---|
invalid_key | 401 | Missing, malformed or revoked key |
insufficient_scope | 403 | The key lacks the scope, or is pinned to another address |
not_found | 404 | No such thread, message or address |
suppressed_recipient | 422 | Bounced, complained or unsubscribed |
warmup_limit | 429 | The domain's warm-up ramp is the binding limit today |
rate_limited | 429 | Too many requests, see the retry header |
Rate limits and warm-up
Two separate ceilings, and confusing them is the usual mistake.
Request rate is per key: 600 requests per minute, burst 60 per second. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a 429 rate_limited carries Retry-After in seconds. These are the launch numbers and will only move up.
Send volume is per domain, and it is a deliverability control rather than a billing one. A new domain ramps instead of spiking, because mailbox providers judge a domain on its history and there is no way to buy past that. Read the ceiling rather than hard-coding it: every send response carries X-LetterDuck-Send-Remaining and X-LetterDuck-Send-Reset, and exceeding it returns 429 warmup_limit with today's figure in the body. An established domain moving to us keeps its reputation and ramps faster than a domain sending its first mail ever, which is why the number is served rather than published as a table.
This is the same throttle our own mail runs under.
Open source
The pipeline is being published as an open-source project, and it is built for Cloudflare. The receiving worker sits behind Email Routing, parsing and threading land in D1, raw mail and attachments in R2, and sending goes through a relay adapter you point at the provider of your choice. One Worker per domain, on your own Cloudflare account.
That is a complete system, not a sample: clone it and you can run your own inboxes end to end and wire mail into your own sites and products as you please, with unlimited addresses on a domain you control. A small domain fits inside Cloudflare's free tier. The MCP server ships in the same repository, so your self-hosted pipeline speaks to agents the same way the hosted one does; fork it and add the tools your product needs. Nothing about the protocol is proprietary.
Self-hosting gets you the machinery. The hosted product is the operations on top of it: warm-up, suppression shared across every surface, complaint watch, monitoring around the clock, keys, SDKs and the workspace itself. Run your own, or let us run it; the API keeps the same shape either way, so moving between the two is a config change rather than a rewrite.
What is live today
We're making sure everything works perfectly before opening it up. The product, the open-source repo, API keys and MCP all go live together.
