Send email with Node.js

Send email with Node.js using one HTTPS request: HTML and text, attachments, idempotency keys, error handling and delivery webhooks. No SMTP server or SDK.

Updated 2026-10-25

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 --version to check. Older versions need a fetch polyfill.
  • An api.email API key. Keep it in an environment variable such as API_EMAIL_KEY and 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_KEY in 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=none is a safe start that reports problems without blocking mail.
  • Log the returned id with 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.

FAQ

Do I need Nodemailer to send email with Node.js?

No. Nodemailer is an SMTP client. With an email API you make one HTTPS request with the built-in fetch, so there is nothing to install and no SMTP connection to manage. If an existing app already uses Nodemailer, point it at smtp.api.email instead.

How do I send attachments from Node.js?

Read the file into a Buffer, encode it as base64 and add it to the attachments array with a filename and content type. Keep the total message under 10 MB; for larger files, send a link instead.

How do I avoid sending the same email twice when a request is retried?

Send an Idempotency-Key header that is unique per logical email, such as the id of the password-reset request. If the same key arrives again, api.email returns the original result instead of sending a second copy.

Can I use SMTP from Node.js instead of the API?

Yes. Use host smtp.api.email, port 587 with STARTTLS or 465 with TLS, username api and your API key as the password. The API is still the better default: it returns an id you can track and supports idempotency keys.

Get API key

Create your api.email account

What do you need?

Early access: accounts open in batches. By continuing you agree to the Terms and Privacy Policy.