Node SDK
@letterapp/node is the official Node.js client. It batches ingestion events in
memory, retries on transient failures, and generates idempotency keys for you —
so the common letter.track(...) call from a request handler is non-blocking
and safe to retry. It also sends transactional email with letter.send(...).
If you’d rather call the HTTP API directly, see the Ingestion API and Transactional email.
| Package | @letterapp/node on npm |
| License | MIT |
| Runtime | Node 20+, ESM |
| Size | ~7 KB packed, zero runtime dependencies |
Install
pnpm add @letterapp/node
# or
npm install @letterapp/node
# or
yarn add @letterapp/node
The package is published as ESM with TypeScript types. It has no runtime
dependencies — fetch and crypto.randomUUID come from Node 20+‘s standard
library. Bundles cleanly in Next.js, Hono, Express, Fastify, Lambda, and Vercel
Edge Functions.
You’ll need an API key from Dashboard → Settings → API keys before the SDK will do anything useful.
Quick start
import { Letter } from "@letterapp/node";
const letter = new Letter({
apiKey: process.env.LETTER_API_KEY!,
});
letter.identify({
userId: user.id,
email: user.email,
traits: { name: user.name, plan: "free" },
});
letter.group({
userId: user.id,
accountId: workspace.id,
name: workspace.name,
traits: { plan: workspace.plan, mrr: 49 },
});
letter.track({
userId: user.id,
event: "Workspace Created",
properties: { workspaceId: workspace.id },
});
// Required before process exit on long-running servers.
await letter.close();
Create the apiKey in Dashboard → Settings → API keys. It’s shown once
on creation and never again — store it somewhere safe.
Bootstrapping an existing user base? See Initial import for a checkpointed, re-runnable script template built on top of this SDK.
Constructor options
| Option | Default | What it does |
|---|---|---|
apiKey | — | Required. lt_live_… from Settings. |
baseUrl | https://api.letter.app | Override for self-hosting. |
flushAt | 50 | Send a batch when this many items are queued. |
flushInterval | 100 (ms) | Send queued items at most this often. |
maxRetries | 3 | Retry attempts on 5xx and 429. |
fetch | global fetch | Override for tests / proxies. |
onError | console.error | Called when a background flush fails. |
Methods
identify(opts)/group(opts)/track(opts)— enqueue, returnvoid. Errors surface viaonError. Fast path for long-running servers.identifySync(opts)/groupSync(opts)/trackSync(opts)— make the HTTP call immediately, return a promise. Use in serverless functions where there is no background time.send(opts)— send one transactional email. Always immediate; see below.flush()— drain the queue now. Resolves when the in-flight request settles.close()— flush, stop the timer, refuse new enqueues. Call this beforeprocess.exit()so no events are lost.
Transactional email
send() mails one person right now: a receipt, a password reset, a
verification link. It is never batched and never waits for flush() or
close() — batching a password reset would be a bug, not an optimization.
const result = await letter.send({
to: "user@example.com",
subject: "Reset your password",
html: "<p>Click <a href='https://...'>here</a> to reset.</p>",
tag: "password-reset",
idempotencyKey: `password-reset:${token}`,
});
result.messageId; // provider id, appears in delivery events
result.replayed; // true if an idempotency-key replay returned an earlier send
Only to, subject and one of html / text are required. from defaults to
the project’s sender and must be on a verified domain. A plain-text part is
derived from the HTML when you don’t supply one. The full option list and the
suppression rules are in Transactional email.
Failures throw a LetterError carrying status, code and reason. The
reason is what tells “this recipient is unreachable” apart from “our account is
blocked”:
import { Letter, LetterError } from "@letterapp/node";
try {
await letter.send({ to, subject, html });
} catch (err) {
// Hard-bounced or reported spam. Nothing to retry, nothing to fix.
if (err instanceof LetterError && err.reason === "suppressed") return;
throw err;
}
Retry behavior
429: waitRetry-Afterseconds, then retry (up tomaxRetries).5xxor network errors: exponential backoff (250 ms × 2^attempt + jitter).4xxother than 429: thrown immediately, no retry.
The SDK auto-generates a UUID messageId per track call, so retries dedupe
at the server. See Idempotency for the underlying
guarantee.
send() is the exception: it only retries when you pass an idempotencyKey.
Without one, a retry after a timeout could put a second copy of the email in
someone’s inbox, which is worse than failing the call.
Serverless mode
In serverless / edge-function environments there’s no background time between
requests to drain the queue, so set flushAt: 1 and use the *Sync methods
(or await letter.flush() at the end of each handler):
const letter = new Letter({
apiKey: process.env.LETTER_API_KEY!,
flushAt: 1, // send immediately
});
await letter.trackSync({ userId, event: "Checkout Started" });
Or simply await letter.flush() after enqueueing in the same request handler.
Errors
Errors thrown by *Sync methods (or surfaced via onError) use the same
shape as the HTTP API — see Error format.
Versioning
The SDK follows semver. While we’re at 0.x:
- patch (
0.1.0 → 0.1.1) — bug fixes only. - minor (
0.1.0 → 0.2.0) — new options, new methods, behavior changes. Read the release notes. - major (
0.x → 1.0.0) — only once the HTTP API and method signatures are stable. Until then, pin a minor range ("@letterapp/node": "^0.1.0").
Every request sends a User-Agent: @letterapp/node@<version> header so we can
spot outdated clients in server logs and reach out before deprecating
anything.