Transactional email
Transactional email is the mail your product has to send: a password reset, a receipt, a verification link. It’s triggered by something a specific person did, it goes to specific recipients, and they’re usually waiting for it.
Letter sends it through POST /v1/send, authenticated with the same project
API key your SDK already uses for identify and track. You supply the HTML;
Letter supplies the verified sending domain, the suppression list, the
delivery events, and a log you can search when a customer says they never got
the email.
Apps that cannot call HTTP (Twenty, WordPress, Laravel) use the same pipeline
over SMTP (smtp.letter.app, username letter, password the
API key).
- Base URL:
https://api.letter.app - Auth:
Authorization: Bearer lt_live_<48 hex characters>
Sending marketing mail? Use sequences or broadcasts instead. They render from your branded templates, respect unsubscribes, and add the
List-Unsubscribeheader that bulk mail is required to carry.
Quick start
curl https://api.letter.app/v1/send \
-H "Authorization: Bearer $LETTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "ada@example.com",
"subject": "Reset your password",
"html": "<p>Click <a href=\"https://app.example.com/r/abc\">here</a> to reset your password.</p>",
"tag": "password-reset"
}'
{
"id": "0f2c...",
"messageId": "0100019a...",
"to": "ada@example.com",
"from": "hello@example.com",
"subject": "Reset your password",
"status": "sent",
"submittedAt": "2026-08-20T14:03:11.482Z",
"replayed": false
}
The response comes back after the provider has accepted the message, so a
200 means it’s on its way. id is Letter’s own id (use it to find the send
in the dashboard); messageId is the provider’s, which is what appears in
delivery events.
Request
| Field | Type | Notes |
|---|---|---|
to | string or string[] | Required. One or more recipient addresses. |
cc | string[] | Carbon-copy recipients. |
bcc | string[] | Blind-copy recipients; omitted from the delivered headers. |
subject | string | Required. Max 998 characters. |
html | string | HTML body. Required unless you send text. |
text | string | Plain-text body. Derived from html when omitted. |
from | string | Must be on a verified sending domain. Defaults to the project’s From address. |
fromName | string | Display name. Defaults to the project’s From name. |
replyTo | string | Defaults to the project’s reply-to. |
headers | object | Custom headers, max 20. Envelope/MIME, List-Unsubscribe*, X-Letter-*, and X-SES-* headers are reserved. |
tag | string | Grouping label, e.g. password-reset. Shown in the dashboard and attached to delivery events. |
metadata | object | Up to 20 string key/value pairs, echoed back in the log. |
idempotencyKey | string | 8–128 characters. See below. |
attachments | array | Up to 10 { filename, contentType?, content } items; content is base64. |
There can be at most 50 unique addresses across To/Cc/Bcc. Body text plus
decoded attachment bytes are capped at 10 MB. Nested message/rfc822
attachments are refused. Always send something readable as text, or let
Letter derive it: HTML-only mail scores worse with spam filters and renders as
a blank message in text-only clients.
{
"to": ["buyer@example.com"],
"cc": ["accounts@example.com"],
"subject": "Your invoice",
"html": "<p>Thanks — your invoice is attached.</p>",
"attachments": [{
"filename": "invoice.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjcK..."
}]
}
Idempotency
Transactional sends are usually triggered from a queue worker, and queue
workers retry. Pass an idempotencyKey (either the body field or an
Idempotency-Key header) and a repeat call returns the original send instead
of mailing your customer twice:
{ "..." : "...", "replayed": true }
Keys are scoped to a project and never expire. Use something stable and derived
from the thing you’re sending about, e.g. password-reset:{tokenId} or
receipt:{invoiceId}.
Suppression
Transactional mail and marketing mail follow different rules, because someone who unsubscribed from your newsletter has not asked to stop receiving their own password reset.
Does not block a transactional send: a marketing unsubscribe, whether clicked in an email footer, done through one-click unsubscribe, or set by hand.
Blocks the send (returns 403 with reason: "suppressed"):
- the address hard-bounced,
- the recipient marked a previous email as spam,
- someone suppressed the address in Settings → Suppression.
Those three are facts about the address, not preferences. Sending to them anyway damages the deliverability of every other email you send.
Transactional messages also carry no List-Unsubscribe header. An unsubscribe
link on a receipt is both wrong and a support burden.
Contacts and the timeline
Every send upserts every To/Cc/Bcc address as a contact (with source: "api").
The message’s primary To is linked to the timeline. Stored HTML and attachment
metadata are viewable in the log; attachment bytes are not retained.
If all new addresses do not fit under the workspace’s plan contact cap, the
whole send is refused (403 with reason: "contact_quota_exceeded"). Sends
only to existing contacts still go out at the cap. Monthly and daily quotas
count unique recipients, not API calls; the monthly cap refuses with
email_quota_exceeded.
Delivery status
Sends start at sent and move as the provider reports back:
| Status | Meaning |
|---|---|
queued | Row created, not yet handed to the provider. Transient. |
sent | The provider accepted it. |
delivered | The receiving server accepted it. |
bounced | Rejected. The address is now suppressed. |
complained | Marked as spam. The address is now suppressed. |
failed | Never left Letter, or the provider rejected the call. |
You can see all of this on the Transactional page in the dashboard. A multi-recipient send has one log row. A bounce or complaint marks that row and suppresses the affected address; v2 does not expose mixed per-recipient status.
Reading the log back
The dashboard’s Transactional page is the interactive view. For scripts and support tooling, the same log is on the Management API (workspace PAT, not the project key):
GET /v1/projects/{slug}/messages?to=ada@example.com
GET /v1/projects/{slug}/messages?status=bounced
GET /v1/projects/{slug}/messages/{id}
Or from the CLI:
letter messages list --status bounced
letter messages get msg_123 # includes the stored HTML/text preview
The list omits bodies; the single-message read includes the stored HTML/text preview (up to 500 KB each). Letter does not retain raw MIME or attachment bytes.
Errors
Errors use the same shape as the rest of the API.
| Code | HTTP | When |
|---|---|---|
bad_request | 400 | Validation failed, or the provider rejected the message (bad address, unverified sender) |
unauthorized | 401 | Missing or invalid API key |
forbidden | 403 | A sending gate blocked it: suppressed recipient, suspended account, contact cap, email quota, or daily cap |
rate_limited | 429 | Per-key limit, or the provider throttled us. Retry after the delay |
internal_error | 500 | Transport failure. Safe to retry with the same idempotency key |
A 403 carries a reason field (suppressed, account_suspended,
contact_quota_exceeded, email_quota_exceeded, daily_cap,
billing_required) so you can tell a recipient problem from an account
problem.
Migrating from Postmark
The request shape is close enough that most migrations are a transport swap:
| Postmark | Letter |
|---|---|
From | from (optional, defaults to the project) |
To | to |
Subject | subject |
HtmlBody | html |
TextBody | text |
ReplyTo | replyTo |
Tag | tag |
Metadata | metadata |
Headers (array) | headers (object) |
MessageStream | not needed - one stream per project |
MessageID (response) | messageId (response) |
Two behavioural differences worth knowing:
- Postmark’s
InactiveRecipientis our403 forbiddenwithreason: "suppressed". If your code tolerated inactive recipients, catch the 403 and checkerror.reasoninstead. - The sending domain must be verified in Letter, not just in Postmark. Add it under Settings → Domain and publish the DKIM CNAMEs plus the bounce subdomain’s MX and SPF records before you cut over. Mail can send once DKIM verifies; SPF alignment starts when the MX and TXT verify.
Keep both providers configured behind a flag until you’ve watched real traffic land, then remove the old one.
Clients
Every official client exposes the same call:
// @letterapp/node
await letter.send({ to, subject, html, tag: "receipt" });
# letterapp (Python)
letter.send(to=..., subject=..., html=..., tag="receipt")
# letterapp (Ruby). Named send_email because Object#send is Ruby's
# dynamic dispatch.
letter.send_email(to: ..., subject: ..., html: ..., tag: "receipt")
# @letterapp/cli - handy for a smoke test after cutting over.
# --html also takes @path, since a real email doesn't survive a
# shell command line.
letter send --to you@example.com --subject "Hello" --html @body.html
Unlike identify and track, send is never batched or buffered: it performs
the request and resolves with the result. It also only retries a 5xx when you
gave it an idempotencyKey — without one, a retry after a timeout could put a
second copy of the email in someone’s inbox.
Agents can send through @letterapp/mcp’s send_email
tool, which uses the same credential.
Rails
The Ruby gem registers a :letter ActionMailer delivery method, so a Rails app
switches providers in config and keeps every existing mailer, view, and
deliver_later:
config.action_mailer.delivery_method = :letter
config.action_mailer.letter_settings = { api_key: ENV["LETTER_API_KEY"] }
Details in the Ruby SDK docs.