Send from TypeScript
Send and read email with @lepsto/sdk/mail, including React Email, errors and retries.
Use @lepsto/sdk/mail to send email and read its status from TypeScript. The client builds the public URL for your product, sends its public key and applies Mail’s retry policy. createMailer also supports inline bodies, hosted templates and React Email components, plus a per-send Idempotency-Key.
The Mail subpath supports one send and one read operation. Manage domains, templates, webhooks, suppressions, contacts, audiences and broadcasts with the HTTP API or the MCP tools linked from their guides.
Before you start: use Node.js 20 or newer, a verified sending domain and a product public key scoped to the routes you need. Keep the sending key on your backend. These examples use Acme and send.acme.com; replace the sender, recipient, product ID, key, template ID and example user values.
Install it
- Install the SDK. Run this command in your backend project:
npm install @lepsto/sdkInstall the public npm package; Mail needs no separate package or .npmrc setup. @lepsto/sdk has no runtime dependencies and provides ES module and CommonJS builds with TypeScript declarations. It uses global fetch on Node.js 20 or newer and modern browsers. Run credential-bearing Mail code on your backend even though the SDK runtime supports browsers.
For React Email rendering, install the three additional packages listed under React Email. The SDK does not install them automatically.
Create a client
- Configure the mailer. Read your product ID and secret from the environment and select the sending edge:
import { createMailer } from '@lepsto/sdk/mail';
const mail = createMailer({
productId: process.env.LEPSTO_PRODUCT_ID!,
publicKey: process.env.LEPSTO_PUBLIC_KEY!,
baseUrl: 'https://public.lepsto.com',
});createMailer returns a Mailer with mail.emails for send and read methods and mail.operations for the generated client underneath.
Option | Required | Default | What it does |
|---|---|---|---|
productId | Yes | — | Your product. Substituted into every {productId} in a request path. |
publicKey | Yes | — | The product public key, sent as Authorization: Bearer. |
baseUrl | Yes | — | The public edge this client sends through. There is no default; see below. |
timeoutMs | No | 30000 | Per-request timeout in milliseconds. |
retry | No | Mail’s policy | See retries. |
headers | No | — | Headers added to every request. Per-call headers win. |
apiUrl | No | — | Where the API answers, when that is not derived from baseUrl — a local stack, or the service addressed directly. |
fetch | No | the platform fetch | An injected fetch, for tests. |
Authenticate with the product public key shown once under Settings → Public Access. Include both public routes in its scope if you call emails.send and emails.get; a key limited to POST /emails returns 403 on the read. Imported lmk_ keys work because they are present in the product’s public-key list. This subpath has no separate server-credential form. See authenticate the public API.
The request path is {baseUrl}/{productId}/mail/.... For productId: "p-123", the example client sends to https://public.lepsto.com/p-123/mail/emails.
baseUrl is required
Set baseUrl explicitly. Mail has no default edge, and omitting it prevents client construction:
createMailer({ productId: 'p-123', publicKey: process.env.LEPSTO_PUBLIC_KEY! });
// Error: createMailer: `baseUrl` is required — this subpath has no default
// edge on purpose.Environment | baseUrl |
|---|---|
Production | https://public.lepsto.com |
Development | https://public.lepsto.dev |
Production and development use separate deployments and key stores. A key from either environment returns 401 on the other. Choose the URL deliberately: a successful send delivers real email.
Client construction also rejects an empty productId or publicKey. Its error messages do not include the secret.
Send an email
- Send a message and keep its ID. Use an address on your verified domain:
const { id } = await mail.emails.send({
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
subject: 'Welcome',
html: '<p>Hello</p>',
});send resolves to { id } when the message is accepted. It does not wait for delivery. Read that ID or subscribe to delivery webhooks for the outcome.
Every send accepts these common fields:
Field | Type | Notes |
|---|---|---|
from | string | Required. An address at a verified domain of yours. |
to | string | string[] | Required. |
cc | string | string[] | |
bcc | string | string[] | |
reply_to | string | string[] | |
headers | Record<string, string> | Custom headers on the message. |
tags | { name: string; value: string }[] | Your own name/value pairs for grouping messages. |
Choose exactly one of these three content forms. TypeScript rejects an object combining them.
Inline HTML or text: include subject and at least one of html and text:
await mail.emails.send({
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
text: 'Thanks for your order.',
});Published template: provide its ID and variable values. Mail renders the template’s own subject. This form has no subject field; if one is passed at runtime, the SDK removes it before sending the request:
await mail.emails.send({
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
template: { id: 'tmpl_1', variables: { FIRST_NAME: 'Sam' } },
});An unknown template or one without a published revision returns 404. For creation and publication, see send from a template.
React Email: supply a component as described below.
Do not send the same message twice
Supply an idempotency key as send’s second argument. It becomes the Idempotency-Key header for that call only. The SDK neither generates a key nor reuses one on a later call:
await mail.emails.send(
{ from: 'Acme <hi@send.acme.com>', to: 'user@example.com', subject: 'Welcome', html: '<p>Hi</p>' },
{ idempotencyKey: `welcome-${userId}` },
);Derive the key from the message’s business event, such as the user and welcome message. The same key can then survive a restarted process or retried job. A new crypto.randomUUID() generated on each attempt cannot do that. Reusing the business-event key matters because Mail automatically retries some failures.
Use send’s third argument for per-call headers, timeoutMs, retry and abort signal. For emails.get, these options are the second argument.
Read an email back
- Read the message’s outcome. Your key must also cover the read route:
const email = await mail.emails.get(id);
console.log(email.status, email.last_event.type);get returns an unwrapped Email with these fields:
Field | Type |
|---|---|
id | string |
from | string |
to, cc, bcc | string[] |
subject | string |
status | string |
provider_message_id | string | null |
created_at | string |
last_event | { type: string; created_at: string | null } |
Use the message status table to interpret status. get('') throws plain Error with id must not be empty and sends no request.
React Email
Pass a React Email component in react instead of inline HTML:
import { WelcomeEmail } from './emails/Welcome';
await mail.emails.send({
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
subject: 'Welcome',
react: <WelcomeEmail name="Sam" />,
});The SDK renders the component in your process before sending. The request contains the rendered HTML and plain-text strings; the react value and component tree stay local. Include a subject, as for inline content.
Only the React send form dynamically imports the renderer. The three renderer packages are optional peer dependencies, so install them when you use this form:
npm install @react-email/render react react-domUse version 18 or 19 of react and react-dom.
Without these packages, a React send fails before any network request and names what to install. Inline sends, hosted-template sends and emails.get do not need them.
Handle errors
When a request receives an HTTP failure, the SDK throws LepstoApiError:
Property | Type | |
|---|---|---|
status | number | The HTTP status. 0 means the SDK refused the call and nothing was sent. |
code | string | undefined | The machine-readable error code. The edge’s own code wins; where it names none, the code its contract implies for that status. |
body | unknown | The response body as it arrived. |
serverMessage | string | undefined | The service’s own wording, already parsed out — render this to a person rather than message. |
retryAfter | number | undefined | Seconds, from a Retry-After header. |
environmentHint | 'production' | 'development' | 'local' | 'unknown' | undefined | Set on a 401 or 403: which deployment the host belongs to. A key issued for one environment and used against another is the commonest integration mistake, and nothing else in the answer distinguishes it from a simply wrong key. |
Use status and code to choose your error handling:
Status | What it means here |
|---|---|
0 | The SDK never sent the request — code: 'public_key_required' when no key was configured. |
400 | The request body is malformed. |
401 | The key is missing or matches no active key of the product. |
403 | The key is valid but its scope does not cover this route. |
402 | The organization’s plan does not cover the send. Inspect code and body.reason; do not automatically retry. |
404 | No such email, or the operation is not on this product’s allowlist. |
409 | The request conflicts with the current state. |
422 | Well formed, but cannot be carried out — an invalid recipient, or a from address at a domain that is not verified. |
429 | A rate limit, Mail quota or reputation gate rejected the send. Inspect code; retryAfter applies when supplied. |
500 and above | Mail failed. |
From @lepsto/sdk 1.2.0, a plan refusal sets code to entitlement_exhausted or entitlement_unavailable; earlier releases leave that code unset. The mailer uses a product public key, so the 402 body contains only error and reason, with no upgrade_url or plan quantities. See plan refusals.
Timeouts and transport failures are different because neither provides an HTTP response:
LepstoTimeoutErroridentifies a client timeout and includesurlandtimeoutMs. The request might still have been applied.- A transport failure, such as DNS failure or a disconnected socket, propagates the original error thrown by
fetch.
import { createMailer } from '@lepsto/sdk/mail';
import { LepstoApiError, LepstoTimeoutError } from '@lepsto/sdk';
try {
await mail.emails.send({
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
subject: 'Welcome',
html: '<p>Hi</p>',
});
} catch (error) {
if (error instanceof LepstoApiError && error.status === 402) {
const { reason } = error.body as { error: string; reason: string };
console.error('sending unavailable:', error.code, reason);
} else if (error instanceof LepstoApiError && error.status === 429) {
if (error.code === 'rate_limit_exceeded') {
console.warn('rate limited, retry after', error.retryAfter, 'seconds');
} else {
console.error('Mail sending gate:', error.code, error.serverMessage);
}
} else if (error instanceof LepstoApiError) {
console.error(error.status, error.code, error.serverMessage);
} else if (error instanceof LepstoTimeoutError) {
console.error('no answer from', error.url, '— the send may still have been accepted');
} else {
throw error;
}
}Retries
The shared SDK runtime makes no retries by default because most operations are non-idempotent POSTs. Mail enables this policy:
- Three total attempts, including the first.
- Wait 500 ms before the second attempt; subsequent waits double up to 5000 ms.
- A
Retry-Afterheader increases the wait to at least the requested delay. - Retry 429 and 5xx responses only.
Mail’s retry predicate repeats 429 and 5xx responses, including quota and reputation 429s; it does not distinguish those gates from rate limiting. A short retry does not necessarily clear either gate. 402 is not retried. It does not retry other HTTP statuses, timeouts or transport failures. A request lost in transit may already have created an email; reusing an Idempotency-Key protects a deliberate resend, and the SDK cannot choose that key for you.
Override retries on the client, or in the final options argument to send or get:
const mail = createMailer({
productId: 'p-123',
publicKey: process.env.LEPSTO_PUBLIC_KEY!,
baseUrl: 'https://public.lepsto.com',
retry: { attempts: 5, minDelayMs: 1_000, maxDelayMs: 10_000 },
});Field | Mail’s value | Meaning |
|---|---|---|
attempts | 3 | Total attempts, including the first. 1 disables retrying. |
minDelayMs | 500 | Milliseconds waited before the second attempt. |
maxDelayMs | 5000 | Ceiling on the wait. |
retryOn | 429 and 5xx | A predicate over the thrown error. |
Reuse exported MAIL_RETRY and isRetryableMailFailure when overriding part of the policy.
The generated operations
For the catalog’s wire operations, import createMail. createMail({ productId, ... }) exposes email.send and email.get. A Mailer exposes that same generated client as mail.operations:
import { createMail } from '@lepsto/sdk/mail';Choose the generated client when you need the wire body directly. Choose createMailer for its three send forms, local React rendering and per-send idempotency option.
The open pixel, click redirect and two unsubscribe routes are recipient-facing assets. They have no SDK methods.
End to end
import { createMailer } from '@lepsto/sdk/mail';
import { LepstoApiError } from '@lepsto/sdk';
const mail = createMailer({
productId: process.env.LEPSTO_PRODUCT_ID!,
publicKey: process.env.LEPSTO_PUBLIC_KEY!,
baseUrl: 'https://public.lepsto.com',
});
try {
const { id } = await mail.emails.send(
{
from: 'Acme <hi@send.acme.com>',
to: 'user@example.com',
template: { id: 'tmpl_1', variables: { FIRST_NAME: 'Sam' } },
tags: [{ name: 'kind', value: 'welcome' }],
},
{ idempotencyKey: 'welcome-user-42' },
);
const email = await mail.emails.get(id);
console.log(email.status, email.last_event.type);
} catch (error) {
if (error instanceof LepstoApiError) {
console.error(error.status, error.code, error.serverMessage);
} else {
throw error;
}
}Check the result: emails.send returns an ID and emails.get returns that same message’s status and last event. Find it in Mail → Logs. Acceptance may show queued, delivery may show sent or delivered, and suppression can produce suppressed; inspect the outcome rather than assuming the response proves delivery.
Next steps
- Authenticate the public API: Choose public-key scopes and the sending edge.
- Send a message: Use the batch and scheduling operations outside this subpath.
- Send from a template: Manage hosted template content and revisions.
- Receive delivery events: Receive outcomes without polling.