Skip to content
Back to Guides

Node.js

Updated

Send emails from Node.js with Lumail

The lumail package is a typed client over the Lumail REST API. It works in any Node.js 20+ process: a one-off script, an Express server, a queue worker or a cron job.

TL;DR

Run npm install lumail, create the client with new Lumail({ apiKey }), then await lumail.emails.send({ from, to, subject, html }). The result is { data, error }: check error, read data.id. For several emails at once use lumail.emails.batch([...]), up to 100 per request. Add an idempotencyKey to anything you might retry.

Prerequisites

  • Node.js 20 or later (the package targets Node 20 and uses the built-in fetch).
  • A Lumail API token that starts with lum_.
  • A sending domain verified in the same Lumail organization as the token.

1. Install the packages

Terminal
npm install lumail

2. Set your environment variables

Keep the token in a .env file locally and in your platform's secret settings in production. Node 20.6 and later can load the file without a dependency through node --env-file=.env.

Add .env to .gitignore before the first commit. A leaked token can send email from your verified domain.

.env
# Server-side only. Create it in Lumail under Settings > API Tokens. LUMAIL_API_KEY=lum_your_api_token

3. Send your first email from a script

The package ships ESM and CommonJS builds. In an ES module, top-level await keeps a script short. In CommonJS, use const { Lumail } = require("lumail") inside an async function.

Exactly one of html, markdown or tiptap is required. Leave text out and Lumail generates the plain-text part from the HTML.

send.mjs
import { Lumail } from "lumail"; const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY ?? "" }); const { data, error } = await lumail.emails.send({ from: "Acme <[email protected]>", to: "[email protected]", // fixture address: queued, never delivered subject: "Hello from Node.js", markdown: "It **works**. This email was sent with the Lumail SDK.", }); if (error) { console.error(error.name, error.message); process.exit(1); } console.log("Queued", data.id);
Terminal
node --env-file=.env send.mjs

4. Send from an Express route

Create the client once at module level and reuse it. The constructor throws on an empty key, so a missing variable fails at boot rather than on the first request.

server.mjs
import express from "express"; import { Lumail } from "lumail"; const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY ?? "" }); const app = express(); app.use(express.json()); app.post("/password-reset", async (req, res) => { const { email, resetUrl } = req.body; const { data, error } = await lumail.emails.send({ from: "Acme <[email protected]>", to: email, subject: "Reset your password", html: `<p><a href="${resetUrl}">Choose a new password</a>. The link expires in 1 hour.</p>`, tracking: { links: false, open: false }, // keep one-time links untouched }); if (error) { console.error("Lumail send failed", error.name, error.message); return res.status(502).json({ error: "Email could not be sent" }); } res.json({ id: data.id }); }); app.listen(3000);

5. Send a batch

lumail.emails.batch takes an array of the same objects send accepts, up to 100, and returns the queued ids in the same order. Each item still has one recipient.

Batch is for transactional fan-out, such as notifying every member of a workspace. Newsletters belong in Lumail campaigns, which handle unsubscribes and segments for you.

notify.mjs
const members = ["[email protected]", "[email protected]"]; const { data, error } = await lumail.emails.batch( members.map((to) => ({ from: "Acme <[email protected]>", to, subject: "The March report is ready", markdown: "Your workspace report is ready. [Open it](https://acme.com/reports/march).", })), { idempotencyKey: "report:2026-03" }, ); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.data.map((email) => email.id));

Handle errors

Every SDK method resolves to { data, error } and never throws on an HTTP error. error.name is the stable code to branch on and error.statusCode is the HTTP status. When the request never got an answer, error.name is network_error or timeout and there is no status.

Retry only what is worth retrying: transport failures and 5xx responses. A validation_error will fail the same way every time, and a 429 from the recipient guard means that mailbox already got enough email.

Lumail send errors
Statuserror.nameWhat to do
401missing_api_keyMissing or invalid token. Check the env var on the server that sends.
403missing_permissionThe token lacks the emails permission. Mint one that has it.
402plan limitThe organization used its plan's email volume. Upgrade or wait for the next period.
4xxvalidation_errorMissing field, more than one body format, or a from domain that is not verified.
429RECIPIENT_RATE_LIMITEDThat mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After.
nonenetwork_error / timeoutThe request never got an answer. Safe to retry with the same idempotency key.
send-with-retry.mjs
export async function sendWithRetry(lumail, params, idempotencyKey, attempts = 3) { for (let attempt = 1; ; attempt++) { const result = await lumail.emails.send(params, { idempotencyKey }); const status = result.error?.statusCode; const retryable = result.error && (status === undefined || status >= 500); if (!retryable || attempt === attempts) return result; await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt)); } }

Make retries safe with idempotency

The SDK retries GET, PUT and DELETE on its own, but never POST, because a blind retry could send twice. The idempotencyKey option makes your own retries safe: the same key returns the email already queued.

On batch, the key is suffixed with :index per item on the server, so a retried batch reuses each email individually. Keys can be up to 256 characters.

Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Production checklist

  • Workers and queues pass an idempotency key built from the job or event id.
  • The process fails at boot when LUMAIL_API_KEY is missing instead of failing per request.
  • The from address is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record.
  • LUMAIL_API_KEY is set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.
  • Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
  • One-time codes and magic links set tracking: { links: false, open: false } so links are not rewritten.
  • End-to-end tests send to fixture addresses such as [email protected], which return an id without sending real email.
  • A failed email is logged with error.name and never fails the user's request on its own.

Frequently asked questions

Which Node.js version does the lumail package need?

Node.js 20 or later. The package is built for Node 20 and relies on the global fetch and AbortSignal.timeout, so it needs no HTTP dependency.

Can I use the Lumail SDK with CommonJS and require?

Yes. The package ships both an ES module and a CommonJS build, so import { Lumail } from "lumail" and const { Lumail } = require("lumail") both work, with TypeScript types for each.

Does the SDK retry failed sends automatically?

Not for sends. It retries idempotent GET, PUT and DELETE requests with backoff, but POST requests like emails.send are returned to you as-is. Retry yourself with the same idempotencyKey so a retry never sends twice.

Can I send one email to several recipients?

Each email has a single to address. Use cc and bcc for copies, or emails.batch to send up to 100 separate emails in one request so every recipient gets their own message.

Can I use Nodemailer with Lumail instead of the SDK?

Yes, through Lumail's SMTP relay at smtp.lumail.io on port 587 with your API token as the password. The HTTP API gives clearer errors and idempotency, so prefer the SDK when you control the code.

How do I test sending without spamming real inboxes?

Send to fixture addresses such as [email protected], [email protected] or any .test domain. Lumail queues them and returns an id but never delivers them, and they skip the per-recipient rate guards.

Keep building

Send your first email from code today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.