Sending Email in Node.js (2026): https, fetch, axios, Express, and Next.js Webhook Verification
Send transactional email in Node.js using the built-in https module, global fetch (undici), and axios; includes concurrent batch sends with p-limit, idempotent retry, file attachments, and webhook signature verification for Express, Next.js App Router, and Next.js Pages Router.

Node.js can send transactional email with nothing beyond its standard library. For most projects, the built-in https module or the lightweight undici client (bundled since Node 18) is plenty. This guide covers both, plus axios, idempotent retries, file attachments, concurrent batch sends with p-limit, webhook signature verification in Express and Next.js, and a production checklist.
Prerequisites
- Node.js 18 or newer
- A Rovela API key from the dashboard
undiciis bundled with Node 18+;axiosandp-limitrequire installation
Built-in https: Sending a Single Email
The https module needs no installation. Keep the API key in an environment variable and never hard-code it.
// rovela.js
const https = require('https');
const ROVELA_URL = 'https://api.rovela.dev/emails';
class RovelaError extends Error {
constructor(statusCode, name, message) {
super(message);
this.statusCode = statusCode;
this.name = name;
}
}
function sendEmail(apiKey, payload, idempotencyKey = '') {
return new Promise((resolve, reject) => {
const body = JSON.stringify(payload);
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
const url = new URL(ROVELA_URL);
const req = https.request(
{
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers,
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
const parsed = JSON.parse(data);
if (res.statusCode >= 400) {
reject(new RovelaError(res.statusCode, parsed.name, parsed.message));
} else {
resolve(parsed.id);
}
});
},
);
req.on('error', reject);
req.write(body);
req.end();
});
}
module.exports = { sendEmail, RovelaError };
Usage:
const { sendEmail } = require('./rovela');
(async () => {
const id = await sendEmail(process.env.ROVELA_API_KEY, {
from: 'hello@yourcompany.com',
to: ['customer@example.com'],
subject: 'Welcome aboard',
html: '<h1>You are in.</h1><p>Click below to get started.</p>',
});
console.log('sent:', id);
})();
fetch / undici: Modern Async Syntax
Node 18 ships the fetch global backed by undici. This is cleaner for async/await code and avoids callback nesting.
const ROVELA_URL = 'https://api.rovela.dev/emails';
class RovelaError extends Error {
constructor(statusCode, name, message) {
super(message);
this.statusCode = statusCode;
this.name = name;
}
}
async function sendEmail(apiKey, payload, idempotencyKey = '') {
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new RovelaError(res.status, data.name, data.message);
}
return data.id;
}
Retries with Idempotency-Key
The Rovela API accepts an Idempotency-Key header on POST /emails. Within a 24-hour window, repeating the same key returns the cached response instead of sending a duplicate. Generate the key once before the retry loop so every attempt reuses the same value. Retry only on 429 Too Many Requests.
const crypto = require('crypto');
async function sendWithRetry(apiKey, payload, maxAttempts = 3) {
// Generated once; every retry reuses it so the send is idempotent.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await sendEmail(apiKey, payload, idempotencyKey);
} catch (err) {
if (err.statusCode !== 429) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}
throw new Error(`exceeded ${maxAttempts} attempts`);
}
File Attachments
Add an attachments array to the payload. Each item needs a filename, base64-encoded content, and a content_type.
const fs = require('fs');
const path = require('path');
function buildInvoiceEmail(recipient, invoicePath) {
const content = fs.readFileSync(invoicePath).toString('base64');
return {
from: 'billing@yourcompany.com',
to: [recipient],
subject: 'Your invoice',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: path.basename(invoicePath),
content,
content_type: 'application/pdf',
},
],
};
}
Concurrent Batch Sends with p-limit
Promise.all runs all sends in parallel with no rate control. p-limit caps the concurrency so you stay under the Rovela rate limit. Generate each idempotency key before the map so every retry inside sendWithRetry stays stable.
// npm install p-limit
const pLimit = require('p-limit');
async function sendBatch(apiKey, payloads, concurrency = 5) {
const limit = pLimit(concurrency);
// Keys generated before mapping so each retry reuses its own key.
const keys = payloads.map(() => crypto.randomUUID());
return Promise.all(
payloads.map((payload, i) =>
limit(() => sendWithRetry(apiKey, payload, 3, keys[i])),
),
);
}
Update sendWithRetry to accept an external key:
async function sendWithRetry(apiKey, payload, maxAttempts = 3, idempotencyKey = crypto.randomUUID()) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await sendEmail(apiKey, payload, idempotencyKey);
} catch (err) {
if (err.statusCode !== 429) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}
throw new Error(`exceeded ${maxAttempts} attempts`);
}
The batch endpoint (POST /emails/batch) accepts up to 100 recipients in one call and is more efficient when all recipients share the same subject and body. Use the concurrent approach above when each payload is personalised.
axios
For projects already using axios:
// npm install axios
const axios = require('axios');
async function sendEmailAxios(apiKey, payload, idempotencyKey = '') {
const headers = { Authorization: `Bearer ${apiKey}` };
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
try {
const { data } = await axios.post(ROVELA_URL, payload, { headers });
return data.id;
} catch (err) {
const { status, data } = err.response;
throw new RovelaError(status, data.name, data.message);
}
}
Webhook Signature Verification
Rovela signs webhook deliveries with HMAC-SHA256. The signature is in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. The signed string is the timestamp, a literal dot, then the raw request body: <timestamp>.<raw-body>.
Node's crypto.timingSafeEqual does the constant-time comparison to prevent timing attacks.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
if (!signatureHeader || !signatureHeader.includes(',')) {
throw new Error('missing or malformed x-resend-signature');
}
const parts = Object.fromEntries(
signatureHeader.split(',').map((seg) => seg.split('=')),
);
const { t: timestamp, v1 } = parts;
if (!timestamp || !v1) throw new Error('malformed x-resend-signature');
// signed string = "<timestamp>.<raw-body>"
const signed = Buffer.from(`${timestamp}.${rawBody}`);
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex');
const expectedBuf = Buffer.from(expected);
const receivedBuf = Buffer.from(v1);
if (
expectedBuf.length !== receivedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
throw new Error('signature mismatch');
}
return JSON.parse(rawBody);
}
Express Webhook Route
Read the raw body before Express's JSON middleware processes it. Use express.raw on just this route.
const express = require('express');
const app = express();
app.post(
'/webhooks/rovela',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.headers['x-resend-signature'] ?? '';
try {
const event = verifyWebhook(req.body, header, process.env.ROVELA_WEBHOOK_SECRET);
// dispatch to your queue: queue.add(event)
res.sendStatus(204);
} catch {
res.sendStatus(401);
}
},
);
If express.json() is applied globally before this route, it will consume req.body and the signature will cover already-parsed JSON. Place express.raw on the webhook route before any global body parser, or exclude the webhook path from the global parser.
Next.js App Router Webhook Route
In app/api/webhooks/rovela/route.ts, read the raw body with request.text() before any parsing.
// app/api/webhooks/rovela/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
function verifyWebhook(rawBody: string, signatureHeader: string, secret: string) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((seg) => seg.split('=')),
) as Record<string, string>;
const { t: timestamp, v1 } = parts;
if (!timestamp || !v1) throw new Error('malformed signature');
const signed = Buffer.from(`${timestamp}.${rawBody}`);
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('signature mismatch');
}
return JSON.parse(rawBody);
}
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const header = request.headers.get('x-resend-signature') ?? '';
try {
const event = verifyWebhook(rawBody, header, process.env.ROVELA_WEBHOOK_SECRET!);
// await queue.add(event);
return new NextResponse(null, { status: 204 });
} catch {
return new NextResponse('Unauthorized', { status: 401 });
}
}
Next.js Pages Router Webhook Route
// pages/api/webhooks/rovela.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export const config = { api: { bodyParser: false } };
function readRawBody(req: NextApiRequest): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const rawBody = await readRawBody(req);
const header = (req.headers['x-resend-signature'] as string) ?? '';
try {
const event = verifyWebhook(rawBody.toString(), header, process.env.ROVELA_WEBHOOK_SECRET!);
// await queue.add(event);
res.status(204).end();
} catch {
res.status(401).end();
}
}
bodyParser: false in the config export stops Next.js from parsing the body before you can read the raw bytes.
Production Checklist
- API key in environment: read from
ROVELA_API_KEY; never hard-code or commit it. - Idempotency key before the loop: call
crypto.randomUUID()once before the retry loop, not inside it. - Retry only on 429: the API returns
{"statusCode":429,"name":"Too Many Requests"}when you exceed the send limit; retrying is safe because the idempotency key prevents duplicates. - Raw body for webhook verification: use
express.raw(Express),request.text()(Next.js App Router), orbodyParser: false(Next.js Pages Router) to get bytes before any framework transformation. - Constant-time comparison: always use
crypto.timingSafeEqual; comparing hex strings with===is vulnerable to timing attacks. - p-limit under the rate limit: cap concurrency below your Rovela send limit divided by expected per-send latency.
- Webhook secret in environment: store in
ROVELA_WEBHOOK_SECRETand rotate it in the dashboard and your deployment atomically.