LetterDuck
Developer docs

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.

Private beta The engine below runs our own mail today. API keys, SDKs, the MCP server and the open-source repo ship together, and this reference is the contract they ship against. Join the waitlist

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.

Skip the API. Connect your agent or LLM directly to the MCP server and talk to it. Point it at your mailbox and it reads threads, replies to them, and runs your newsletter. No REST calls needed.

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:

ScopeWhat it can do
threads:readList and read threads, read messages, search
threads:writeReply to a thread, mark read, archive
sendSend new mail from any address on the domain
addresses:writeCreate, pause and delete addresses
automations:writeAuto-reply rules, scheduled sends, follow-ups
contacts:writeCreate groups, add and remove members
newsletter:readIssues, audiences and reports
newsletter:writeDraft, 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:

EventFires when
message.receivedMail arrives at one of your addresses, parsed and threaded
message.sentAn outbound message is accepted by the relay
message.deliveredThe receiving server accepted it
message.bouncedHard or soft bounce, with the reason
message.complainedA 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:

ToolScope required
list_threadsthreads:read
read_threadthreads:read
searchthreads:read
summarize_inboxthreads:read
reply_to_threadthreads:write
send_emailsend
create_addressaddresses:write
set_auto_replyautomations:write
schedule_sendautomations:write
manage_groupcontacts:write
draft_issuenewsletter:write
send_issuenewsletter:write
issue_reportnewsletter: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_issue into the Letter template, send itself a test, send_issue scheduled for Sunday 08:00 to the engaged segment, then issue_report on 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.com with threads:read gives an agent triage: summarize_inbox for the morning brief, read_thread and a drafted answer for a human to approve. Graduate the key to threads:write when the drafts stop needing edits.
  • It keeps the list clean. A contact replies "add my colleague": the agent reads the thread, manage_group adds the address to Stockists, and every future send to that group includes them. No CSV round-trip.
  • It gets its own identity. create_address gives 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
  }
}
CodeStatusMeaning
invalid_key401Missing, malformed or revoked key
insufficient_scope403The key lacks the scope, or is pinned to another address
not_found404No such thread, message or address
suppressed_recipient422Bounced, complained or unsubscribed
warmup_limit429The domain's warm-up ramp is the binding limit today
rate_limited429Too 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.