# Sending

> Use when sending transactional email from a project with Lettr — composing a message (HTML/text/template), attachments, cc/bcc, tracking and metadata options, batch sends, scheduling, and idempotency. Assumes the SDK is already installed (run `install` first if not).

- Skill: `lettr-com/sending` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lettr-com/sending`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lettr-com/sending/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: lettr-com (https://skillmd.com/u/lettr-com)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lettr-com/sending

---


# Send email with Lettr

Write the send in the user's own stack using the installed SDK, then verify it actually went out. The exact SDK calls are generated per language — this skill is the judgment around them.

## 1. Know the stack and load the SDK reference

Detect the language using [`../_shared/detect-stack.md`](../_shared/detect-stack.md), then read the matching generated reference for the exact, current SDK surface:

- **SDK calls:** [`../_generated/sdk/<lang>/sending.md`](../_generated/sdk) — `sendHtml`/`sendText`/`sendTemplate`, the fluent builder, attachments, options, response shape, typed exceptions.
- **Wire contract (any language):** [`../_generated/api/index.md`](../_generated/api/index.md) — the `POST /emails` field list. Bold fields are required (`from`, `to`, `subject`). Read this when the SDK reference doesn't cover a field, or when there's no SDK and you're calling the API directly.

Never invent method names from memory — the generated reference is the source of truth and is regenerated from the SDKs.

## 2. Pick the send shape

| Situation | Use |
|-|-|
| One-off HTML or text, few options | the quick `sendHtml` / `sendText` helper |
| Tracking, metadata, cc/bcc, reply-to, attachments together | the fluent email builder |
| Content managed in Lettr (merge tags) | template send — pair with the `templates` skill |
| Same content to many recipients | put up to 50 addresses in `to` (one API call delivers a separate copy to each); `substitution_data` applies to the whole batch, not per-recipient |
| More than 50 recipients | chunk into batches of ≤50 and send a separate call per batch — sequentially or in parallel, staying under 3 req/s. There is no bulk endpoint; looping batches is the intended pattern |
| Must not double-send on retry | **Lettr has no server-side idempotency.** Guard in your own code: check a DB flag or cache key derived from the business event (e.g. `order-confirmation-{orderId}`) *before* sending, and record the returned `request_id` after. There is no `idempotency_key` field — passing one is silently discarded, not rejected. Put the key in `metadata` if you want it traceable later |
| Send later | `POST /emails/scheduled` — keep the returned `transmissionId` to cancel |

## 3. Preconditions that cause most failures

- **`from` must be on a verified sending domain.** Unverified → **`400`** with `error_code: unconfigured_domain` (not `422` — see error handling below). If the user hasn't verified one, route to `install` (domain step) before sending.
- **50 recipients max per call, counted across `to` + `cc` + `bcc` combined** (not 50 each). More than that → batch (see the table above).
- **Quota is charged per recipient on the same `to` + `cc` + `bcc` basis** — a 50-address call costs 50, not 1. If you're adding a fixed `bcc` (an archive or audit address) to every send, say so out loud: it doubles the user's quota consumption. Enforcement is all-or-nothing, so a call that would cross the limit is rejected entirely rather than partially delivered.
- **`transactional` defaults to `true` — you must opt *out*.** It bypasses unsubscribe suppression, which is correct for password resets and receipts. For anything a user can opt out of, explicitly set `options.transactional: false`; leaving it unset sends to unsubscribed contacts.

## 4. Wrap the send in error handling

The SDKs surface typed errors — map them, don't swallow them. Read the generated reference for this language's exact names and shapes; they differ a lot (PHP/Java throw exceptions, Go returns a single `lettr.Error` plus `Is*` helpers, Rust returns an `Error` enum, Node returns a `{ type, error_code }` result). The cases to handle:

- **401** — bad or revoked key.
- **422** — a malformed request: missing field, bad address format.
- **429** — two distinct causes, told apart by `error_code`: `rate_limit_exceeded` (3 req/s per team — back off per `Retry-After`) vs `quota_exceeded` / `daily_quota_exceeded` (plan limit — waiting won't help until the window resets).
- **400 `unconfigured_domain`** — the `from` domain isn't verified. ⚠️ This is the most common first-send failure and it is **not** a validation error: it will not be caught by a `ValidationException`/422 branch. Match on the status or `error_code`, not on the validation type.

Production sends must handle all four.

## 5. Offer to verify the send actually happened

A `2xx` only means Lettr **accepted** the request, not that it was delivered. Don't send a live test silently — **ask the user** whether they want one (it sends a real email and consumes quota). If they decline, just wire the code and capture `request_id` in it.

If they want verification:

1. Capture `request_id` from the response (store it — it's how every later lookup works).
2. Send one real test to an address the user controls, using a verified `from`.
3. Fetch the event timeline for that `request_id` (`GET /emails/{requestId}`). A `delivery` event = the receiving server accepted it. A `bounce`/`policy_rejection` = hand off to the `diagnose` skill.

Either way, report what was wired up and the `request_id` (plus the last event seen, if verified).

## What this skill does not do

- It doesn't author template HTML — that's `templates`.
- It doesn't triage a failed/bouncing send — that's `diagnose`.
- It doesn't install the SDK or verify domains — that's `install`.

