How to Send Email in Node.js: Nodemailer vs. the HTTP API
Two ways to send email from Node: a direct HTTP API call or Nodemailer over SMTP. Covers connection pooling, bounded concurrency, retries, idempotency keys, webhooks and error handling.

How to Send Email in Node.js: Nodemailer vs. the HTTP API
A Node service that sends mail has two real options: open an SMTP connection with Nodemailer, or POST to an HTTP API. Both put the same message on the wire. They differ in what you have to operate, how failures surface, and what happens when you send ten thousand messages instead of ten.
This guide covers the production details that actually decide between them — connection pooling, concurrency, retries, idempotency and delivery events — with examples you can paste into a Node 20+ project. The API path uses the built-in fetch, so there is no SDK to install.
The Two Approaches
| HTTP API | Nodemailer over SMTP | |
|---|---|---|
| Connection | One HTTPS request per send | Long-lived TCP connection |
| Credentials | Bearer token | Username and password |
| Delivery events | Webhooks keyed by message id | Bounce mailbox, return-path parsing |
| Idempotency | Idempotency-Key header | None |
| Serverless friendly | Yes | Poorly — sockets do not survive |
| Operational cost | None | Pooling, TLS, reconnects, timeouts |
The short version: use the API for application code, and reach for Nodemailer when you must speak SMTP — an existing relay, a corporate mail server, or a library that only accepts a transport.
Sending with fetch
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `welcome-${userId}`,
},
body: JSON.stringify({
from: 'Acme <hello@yourdomain.com>',
to: 'user@example.com',
subject: 'Welcome aboard',
html: '<p>Your account is ready.</p>',
text: 'Your account is ready.',
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`send failed: ${res.status} ${err.message ?? ''}`)
}
const { id } = await res.json()
Three details worth copying. The from field accepts a display name, which most clients show instead of the raw address. Error responses carry a message field, so log that rather than the whole body. And the id is the handle you will use later to look up what happened to the message.
Always send text alongside html. A plain-text part improves deliverability and covers clients that block HTML.
A Typed Helper
In TypeScript, wrap the call once instead of repeating the header dance at every call site:
type Message = {
to: string | string[]
subject: string
html?: string
text?: string
cc?: string | string[]
bcc?: string | string[]
replyTo?: string
scheduledAt?: string
tags?: Record<string, string>
}
export async function sendEmail(msg: Message, idempotencyKey?: string) {
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY!}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({
from: process.env.MAIL_FROM,
to: msg.to,
subject: msg.subject,
html: msg.html,
text: msg.text,
cc: msg.cc,
bcc: msg.bcc,
reply_to: msg.replyTo,
scheduled_at: msg.scheduledAt,
tags: msg.tags,
}),
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`Rovela ${res.status}: ${err.message ?? 'send failed'}`)
}
return (await res.json()) as { id: string }
}
AbortSignal.timeout is the piece people forget. Without a timeout, a hung connection keeps the request and its database transaction open indefinitely.
Two API behaviours are worth knowing before you build on the helper:
Recipient limits and billing. to accepts a single address or an array. cc and bcc count toward the same 50-recipient cap, and every recipient is billed — a message to one address with ten in cc is eleven sends, not one. For announcements, the batch endpoint is both cheaper and faster than a large to array.
template is exclusive. A send carries either html/text or template: { id, variables }, never both. Mixing them is a 400, which is the right behaviour: a half-rendered message is worse than a clear rejection. Template variables are interpolated server-side, so the body you send is a small JSON object rather than rendered HTML.
Nodemailer: When You Need SMTP
Nodemailer is mature and works everywhere Node runs. Configure a pooled transport rather than a fresh connection per message:
import nodemailer from 'nodemailer'
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 465,
secure: true,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true,
maxConnections: 5,
maxMessages: 100,
})
await transport.verify()
await transport.sendMail({
from: 'Acme <hello@yourdomain.com>',
to: 'user@example.com',
subject: 'Welcome aboard',
text: 'Your account is ready.',
html: '<p>Your account is ready.</p>',
})
pool: true matters at volume. Without it, every sendMail opens a TCP connection, negotiates TLS and authenticates — several hundred milliseconds and one login per message. With a pool, maxConnections bounds how many sockets you hold open, which keeps you inside the server's per-IP connection limit.
Call transport.verify() once at startup. It surfaces wrong passwords and blocked ports immediately rather than on the first real send.
One Nodemailer setting causes more support threads than any other: secure must be true on port 465 (implicit TLS) and false on 587 (STARTTLS). Get it backwards and the connection does not fail fast — it hangs until the socket times out, which reads like a network problem rather than a configuration one. If a send sits for thirty seconds and then errors, check this first.
Bound Your Concurrency
Neither approach protects you from your own loop. Promise.all over 5,000 messages opens 5,000 sockets, gets rate limited (HTTP 429 or an SMTP 421), and buries the real error under thousands of retries.
async function mapLimit(items, limit, fn) {
const out = []
const queue = items.entries()
const workers = Array.from({ length: limit }, async () => {
for (const [i, item] of queue) out[i] = await fn(item, i)
})
await Promise.all(workers)
return out
}
await mapLimit(recipients, 10, (r) => sendEmail({ to: r.email, subject, html, text }))
Ten in flight is a sensible default for individual sends. If you are sending many distinct messages in one go, the batch endpoint takes up to 100 messages per request and returns an id for each in order. Note that template and topic_id are rejected inside a batch rather than silently ignored — render the body yourself or send those one at a time.
A detail in that helper that trips people up: items.entries() returns a single iterator, and all workers share it. That is deliberate — it is what makes the workers pull from one queue instead of each scanning the whole array — but it means the function cannot be reused on the same array twice, and for...of over the result must not be nested. If you need a reusable version, materialise the entries with [...items.entries()] per call.
Retries and Idempotency
Retry only what can succeed on a second attempt: network errors, 429 and 5xx. A 400 or 401 will fail identically forever.
async function sendWithRetry(msg, key, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await sendEmail(msg, key)
} catch (err) {
const status = Number(/Rovela (\d+)/.exec(err.message)?.[1])
if (status && status < 500 && status !== 429) throw err
if (i === attempts - 1) throw err
await new Promise((r) => setTimeout(r, 250 * 2 ** i + Math.random() * 100))
}
}
}
Pair the retry with a stable Idempotency-Key derived from the event, not from Date.now(). The same key within 24 hours returns the original response instead of sending again, so a timeout followed by a retry produces exactly one email. Keys can be up to 256 characters; a hash of order-shipped:{orderId} is ideal.
The jitter in the backoff (Math.random() * 100) is not decoration. Without it, every request that failed during the same outage retries on the same schedule and hits the API as a synchronised wave, which is exactly the pattern rate limiters are built to reject. A hundred milliseconds of randomness spreads the herd out.
Idempotency matters most in the case that is hardest to reason about: the request succeeded, but the response never arrived. The client cannot tell that apart from a failure, so it retries — and without a key, the customer gets two emails. This is not a rare edge case on mobile networks or across regions; it is routine.
Receiving Delivery Events
A 200 from the send endpoint means accepted, not delivered. To learn what happened next, register a webhook and handle email.delivered, email.bounced, email.complained and email.failed.
In Express, verification needs the raw request body, so register the route before your JSON parser or use express.raw:
app.post('/webhooks/rovela', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('rovela-signature')
if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString())
queue.push(event)
res.sendStatus(200)
})
Verify the signature against the raw bytes, not the parsed object — re-serialising JSON changes key order and whitespace. Queue the event and return 200 quickly; slow handlers cause retries and duplicate deliveries.
Webhook deliveries are retried, so the same event can arrive more than once. Deduplicate on the event id before acting on it, or a single bounce will run your suppression logic twice. The cheap version is a unique constraint on the event id; the expensive version is discovering it when a customer receives the same notification three times.
Key scopes are the other thing that bites here. A sending_access key can send but cannot read messages, cancel them, or manage domains — so a service that only sends should hold a sending_access key, and anything that queries or cancels needs full_access. Using one key for both is how a compromise turns into a full account takeover.
Scheduling and Attachments
scheduled_at takes an ISO 8601 timestamp in the future. Use it for anything that should go out later — a review request an hour after delivery, a reminder in the recipient's own morning — rather than holding a setTimeout open in your process. A timer dies with the process; a scheduled send survives deploys, restarts and scaling events. Store the returned id and call POST /emails/:id/cancel if the reason for the message goes away.
Attachments go in an attachments array, each entry with filename, content (base64) and content_type. Two constraints matter in practice:
- Total message size is capped, typically around 10 MB, and base64 inflates the original by about a third. An 8 MB PDF becomes roughly 11 MB encoded and is rejected outright. Check the encoded length, not the file size on disk.
- Attachments cost deliverability. Filters treat them as a mild signal of bulk or risk, and executable or archive types more so. A message that is nothing but an attachment and a one-line body is the worst case.
For anything above a few megabytes, upload to object storage and put a signed, expiring link in the body. The link can be revoked, you can count downloads, and no receiving server will bounce the message for size.
Pre-Launch Checklist
Treat this as the gate before a sending service goes live:
- The
fromdomain is verified, and SPF, DKIM and DMARC are all in force. - Every send goes through one helper function — no bare
fetchcalls scattered through business logic. - Every send that could repeat carries an idempotency key derived from the business event.
- The helper has a timeout, retries with backoff, and does not retry 4xx.
- Batch sends are bounded; nothing fans out with a bare
Promise.all. - The webhook route is registered ahead of the JSON parser, verifies raw bytes, and deduplicates on event id.
- Send failures are logged with the message id, so a specific email can be traced.
- Bounce and complaint events update the suppression list, and later sends skip it.
Items 1 and 8 are the ones people skip. A missing domain verification fails loudly, but a missing DMARC record fails silently — mail keeps going out and quietly lands in spam, and you find out after losing a cohort of users. A missing suppression list is the mirror image: every send to an address that already bounced drags the whole domain's reputation down, and nothing in your logs looks wrong.
Error Handling Reference
| Status | Meaning | What to do |
|---|---|---|
| 200/201 | Accepted for delivery | Store id, wait for webhooks |
| 400 | Malformed payload, bad attachment, unverified domain | Fix the request |
| 401 | Bad key, or a sending_access key used to read or cancel | Check the key scope |
| 429 | Rate limited | Back off, lower concurrency |
| 5xx | Server-side failure | Retry with idempotency key |
Choosing
Use the HTTP API unless something forces SMTP. It removes an entire class of operational problems — pools, TLS versions, reconnect storms, server-side rate limits expressed as SMTP reply codes — and gives you webhooks and idempotency that Nodemailer cannot offer. Keep Nodemailer for the cases that genuinely need a mail transport, and keep both behind one sendEmail function so the rest of your code never has to know which one you picked.
If you are migrating, do it one call site at a time rather than in a branch. The two transports can run side by side indefinitely: the same domain can carry include:mail.rovela.dev in SPF and SMTP credentials at once, and there is no cutover moment where both have to be correct. That is what makes the migration low-risk — a bad deploy costs you one route, not all of them.
The API reference lists every field, and the Next.js guide covers the same API from inside a framework.