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 one recipient, and they’re usually waiting for it.
Letter sends it through one endpoint, 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.
- 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 | Required. One recipient address. |
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 and List-Unsubscribe* 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. |
Bodies are capped at 500 KB each. 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.
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 a contact by email (with source: "api") so the message
appears on that person’s timeline in the dashboard, alongside their sequence
and broadcast history. The stored HTML is viewable there, which is how you
answer “what exactly did we send them” without digging through your own logs.
Contact creation is best-effort: if your workspace is at its contact limit the email still goes out, it just isn’t linked to a contact.
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.
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 exact HTML that went out
The list omits bodies (a page of receipts is megabytes of HTML); the single-message read includes them. That body is the only record of what a given email said — Letter never rendered it and can’t reconstruct it.
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, quota or daily cap reached |
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,
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 records before you cut over.
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.