Skip to content

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

  1. Install the SDK. Run this command in your backend project:
npm install @lepsto/sdk

Install 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

  1. 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

  1. 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

  1. 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-dom

Use 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:

  • LepstoTimeoutError identifies a client timeout and includes url and timeoutMs. 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-After header 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

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select