This guide shows how to send email with Node.js through the api.email HTTP API. You will send a password-reset message, add an HTML version and an attachment, make retries safe with an idempotency key, handle errors properly and confirm delivery with a webhook. Everything uses the fetch that ships with Node.js 18 and later, so there is no SDK or SMTP library to install.
What you’ll build
A small sendPasswordReset() function that your app calls when a user asks to reset their password. It sends one email from your own domain, returns the email’s id so you can log it, and never sends twice if your code retries after a timeout. The same pattern works for login codes, receipts, invitations and any other transactional message.
Prerequisites
- Node.js 18 or later. Run
node --versionto check. Older versions need a fetch polyfill. - An api.email API key. Keep it in an environment variable such as
API_EMAIL_KEYand never commit it to your repository. - A verified sending domain. You add two CNAME records to your DNS: one for the sending subdomain and one for DKIM. The domains documentation lists them. Until the domain is verified, the API answers with
domain_not_verified.
Send your first email
The whole integration is a single POST request to https://api.email/v1/emails. The body is JSON with the sender, the recipient, a subject and the content.
// send.js — node send.js
const res = await fetch('https://api.email/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_EMAIL_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Reset your password',
text: 'Use this link to reset your password: https://acme.com/reset/8f2c1a',
}),
});
const body = await res.json();
console.log(res.status, body); // 200 { id: 'em_8f2c1a', status: 'queued' }
A 200 response means api.email accepted the message and queued it for delivery. The id is what you store next to your own records: it appears in the dashboard and in the event log, and every webhook about this email carries it as email_id.
Check the status of an email
You don’t have to wait for a webhook to see what happened. GET /v1/emails/{id} returns the current status and the list of events so far, which is handy in support tools and in tests.
const res = await fetch(`https://api.email/v1/emails/${id}`, {
headers: { Authorization: `Bearer ${process.env.API_EMAIL_KEY}` },
});
const email = await res.json(); // { id, status: 'delivered', events: [{ type: 'email.sent', at: '…' }, …] }
Test without sending real email
Use a test API key in development and CI. Test keys go through the same validation, except that the sending domain doesn’t have to be verified yet. They return the same ids and produce the same webhook events, but nothing is delivered to a real inbox. That lets your end-to-end tests exercise the whole flow, including error handling, without spamming anyone or hurting your sender reputation. Switching to production is a change of environment variable, not of code.
Send HTML with a plain-text fallback
Most transactional email should carry both versions. Mail clients that block HTML, screen readers and spam filters all look at the text part, so send text and html together rather than HTML alone.
export async function sendPasswordReset({ to, link, requestId }) {
const res = await fetch('https://api.email/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_EMAIL_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `password-reset-${requestId}`,
},
body: JSON.stringify({
from: 'Acme <[email protected]>',
to,
subject: 'Reset your password',
text: `Reset your password: ${link}\n\nIf you didn't ask for this, ignore this email.`,
html: `<p>Reset your password:</p><p><a href="${link}">Choose a new password</a></p>
<p>If you didn't ask for this, ignore this email.</p>`,
tags: ['password-reset'],
}),
});
if (!res.ok) throw await toError(res);
return (await res.json()).id;
}
Tags are optional labels. They show up in the dashboard and in webhook payloads, which makes it easy to count how many password-reset emails bounced last week.
Attachments
Attachments travel as base64 strings inside the same JSON request. Read the file, encode it and describe it with a filename and content type.
import { readFileSync } from 'node:fs';
const invoice = readFileSync('./invoices/1042.pdf');
const payload = {
from: 'Acme Billing <[email protected]>',
to: '[email protected]',
subject: 'Your invoice #1042',
text: 'Your invoice is attached. Thank you for your order.',
attachments: [
{ filename: 'invoice-1042.pdf', content: invoice.toString('base64'), content_type: 'application/pdf' },
],
};
Keep the whole message under 10 MB. For large files, upload them to your own storage and send a link: it is faster for the recipient and less likely to trip spam filters.
Make retries safe with an idempotency key
Networks fail in the worst way: the request reaches the server, but the response never reaches you. If your code simply retries, the user gets two identical emails. The fix is an Idempotency-Key header that identifies the logical email, not the HTTP attempt. In the function above it is built from your own requestId, so every retry of the same password reset carries the same key. When api.email sees a key it has already processed, it returns the original response instead of sending again.
Good keys are stable and specific: order-1042-receipt, invite-user-77, password-reset-<request id>. Avoid timestamps or random values generated per attempt, because those defeat the purpose.
Handle errors
Every error response has the same shape: {"error": {"code", "message", "hint"}}. Branch on code, log the hint for whoever reads the logs, and retry only what can succeed later.
async function toError(res) {
const { error } = await res.json().catch(() => ({ error: { code: 'unknown', message: res.statusText } }));
const err = new Error(`${error.code}: ${error.message}`);
err.code = error.code;
err.retryable = res.status === 429 || res.status >= 500;
return err;
}
async function withRetry(fn, attempts = 4) {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
if (!err.retryable || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 500)); // 0.5s, 1s, 2s
}
}
}
invalid_request and domain_not_verified will fail the same way every time, so surface them instead of retrying. rate_limited and server errors are temporary: back off and try again with the same idempotency key, which keeps the retry safe.
Track delivery with webhooks
The API call tells you the email was accepted, not that it arrived. To know what happened next, add a webhook endpoint in the dashboard and subscribe to email.delivered, email.bounced and email.complained. Every request is signed with your endpoint’s secret in the api-email-signature header, so verify it before trusting the body.
import crypto from 'node:crypto';
import express from 'express';
// Same helper as in the webhooks docs: rejects replays, forged and malformed signatures,
// and accepts both signatures during a secret rotation.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = String(header ?? '').split(',').map((p) => p.trim().split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const sigs = parts.filter(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v ?? '')).map(([, v]) => v);
if (!/^\d+$/.test(t ?? '') || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
const app = express();
app.post('/hooks/email', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.get('api-email-signature'), process.env.API_EMAIL_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (await events.seen(event.id)) return res.sendStatus(200); // retries are safe
if (event.type === 'email.bounced') await markAddressUndeliverable(event.to);
res.sendStatus(200);
});
Each event carries its own id, which you use to skip duplicates, and the email_id you got back when sending, which links it to your records. Respond with a 2xx status quickly and do slow work in the background. Failed deliveries are retried with exponential backoff for up to 24 hours, so a short outage on your side loses nothing. The webhooks documentation lists every event and its fields.
Production checklist
- Store
API_EMAIL_KEYin your secret manager and use separate keys for development, staging and production, so a leaked test key cannot send real mail. - Send from a subdomain dedicated to your app, such as
mail.acme.com, if your main domain also sends newsletters. - Publish a DMARC record if your domain has none yet.
v=DMARC1; p=noneis a safe start that reports problems without blocking mail. - Log the returned
idwith your own request id. When a user says they never got the email, that pair is all you need to find out why. - Keep transactional email transactional: no marketing content in password resets, which protects your sender reputation.