Docs Go to app →

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-Unsubscribe header 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

FieldTypeNotes
tostringRequired. One recipient address.
subjectstringRequired. Max 998 characters.
htmlstringHTML body. Required unless you send text.
textstringPlain-text body. Derived from html when omitted.
fromstringMust be on a verified sending domain. Defaults to the project’s From address.
fromNamestringDisplay name. Defaults to the project’s From name.
replyTostringDefaults to the project’s reply-to.
headersobjectCustom headers, max 20. Envelope and List-Unsubscribe* headers are reserved.
tagstringGrouping label, e.g. password-reset. Shown in the dashboard and attached to delivery events.
metadataobjectUp to 20 string key/value pairs, echoed back in the log.
idempotencyKeystring8–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:

StatusMeaning
queuedRow created, not yet handed to the provider. Transient.
sentThe provider accepted it.
deliveredThe receiving server accepted it.
bouncedRejected. The address is now suppressed.
complainedMarked as spam. The address is now suppressed.
failedNever 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.

CodeHTTPWhen
bad_request400Validation failed, or the provider rejected the message (bad address, unverified sender)
unauthorized401Missing or invalid API key
forbidden403A sending gate blocked it: suppressed recipient, suspended account, quota or daily cap reached
rate_limited429Per-key limit, or the provider throttled us. Retry after the delay
internal_error500Transport 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:

PostmarkLetter
Fromfrom (optional, defaults to the project)
Toto
Subjectsubject
HtmlBodyhtml
TextBodytext
ReplyToreplyTo
Tagtag
Metadatametadata
Headers (array)headers (object)
MessageStreamnot needed - one stream per project
MessageID (response)messageId (response)

Two behavioural differences worth knowing:

  1. Postmark’s InactiveRecipient is our 403 forbidden with reason: "suppressed". If your code tolerated inactive recipients, catch the 403 and check error.reason instead.
  2. 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.