Skip to content

How to send email from Node.js: API vs SMTP vs Nodemailer

Published Updated 10 min readBy the Rasket team

A terminal window releasing a single envelope along a smooth curve towards a mailbox, drawn as white and violet outlines on black.

The three ways to send email from Node.js

To send email from Node.js you have to hand the message to something that will deliver it. Node has no mail transport of its own — there is no fs.writeMail — so every answer is a choice about what sits between your process and the recipient’s mailbox provider.

There are three, and they are easy to confuse because two of them are the same thing wearing different clothes. A Node.js email API is an HTTPS endpoint: you POST a JSON message and get back an id. SMTP is the protocol mail servers have spoken since RFC 5321, and you can open a socket and speak it yourself. Nodemailer is the package almost everyone reaches for when they mean the second one: it is an SMTP client with a friendly message builder on top.

The important distinction is not the protocol. It is what the thing on the other end does for you. A relay takes bytes and forwards them. A sending platform verifies your domain, signs the message, keeps a suppression list so you do not mail an address that already bounced, records what happened, and tells you about it.

Rasket has no SMTP relay

This is an HTTP API. If your application is built around a Nodemailer transport today, moving to Rasket means replacing the transport call with one HTTP call — not pointing Nodemailer at a different host.

API, SMTP and Nodemailer, side by side

The three approaches differ in how much of the work is yours. This is the shape of the trade, with the last row the one most people actually decide on.

How an HTTP email API, raw SMTP and Nodemailer compare
HTTP APIRaw SMTPNodemailer
ProtocolHTTPS, one requestSMTP over TCPSMTP over TCP
Round trips per sendOneSeveral, by designSeveral, by design
What you get backA message id, immediatelyA queue response lineA queue response line
AttachmentsIn the same JSON bodyMIME you assembleBuilt for you
Domain authenticationGenerated for you, checked for youYours to publish and watchYours to publish and watch
SuppressionsKept and enforced on sendNoneNone
Delivery, bounce and complaint eventsSigned webhooksBounce mail you parseBounce mail you parse
Works behind a firewall that blocks port 587YesNoNo
Best forProduct mail you need to account for: receipts, resets, broadcastsA mail server you already runAn app that already has an SMTP relay and does not need events

None of this makes Nodemailer a bad package. It is a very good SMTP client. It is simply not a sending platform, and a great many outages have started with a team assuming it was one.

Sending with the Rasket Node SDK

The shortest path is the client library. It is one package with no dependencies of its own, it is generated from the same OpenAPI document the API is served from, and it returns typed bodies rather than unknown.

  1. Install the clientRun npm install rasket in the project that will send the mail. The package has no dependencies of its own.
  2. Export the keyCreate an API key in the dashboard and put it in the environment as RASKET_API_KEY. Never write the key into a source file.
  3. Verify a domainAdd the domain you want to send from, publish the DKIM, SPF and DMARC records it generates, and wait for verification to pass. Mail from an unverified domain is refused.
  4. Send with an idempotency keyCall emails.send with from, to, subject and html, and pass an idempotencyKey derived from the thing that caused the send — an order id, not a random string.
  5. Handle the responseStore the returned email id against your own record. It is the id every later event and every support question is about.
  6. Subscribe a webhookPoint an endpoint at the delivery events you care about and verify the signature on every request before you trust the body.
import { Rasket } from "rasket";
const rasket = new Rasket({  apiKey: process.env.RASKET_API_KEY,  userAgent: "acme-billing/1.0",});
const { body: email } = await rasket.emails.send(  {    from: "Acme <billing@acme.example>",    to: ["ronald.williams@example.com"],    subject: "Your receipt",    html: "<p>Thanks for your order.</p>",  },  { idempotencyKey: "receipt-1042" },);
console.log(email.id);

Three details in that sample are worth more than the rest. The key is read from the environment, never written into the file — a key in a repository is a key you will rotate on somebody else’s schedule. userAgent is required: a request with no User-Agent is refused, and what you pass is appended to the SDK’s own, which is what lets support tell your billing worker from your web process. And idempotencyKey is derived from the thing that caused the send — the order — rather than generated fresh, which is the difference between a retry that is safe and a retry that sends a second receipt.

What comes back

Every call resolves to an envelope rather than a bare body: the parsed body typed per route, the rate limit state, the request id to quote in a support conversation, and whether this response was a replay of an earlier keyed request. Store email.id against your own record. It is the id every later event and every question about this message is about.

Sending with plain fetch

There is nothing in the SDK you cannot do with the fetch built into Node. The API is ordinary HTTP: a bearer token, a JSON body, and an idempotency header when you want one.

const response = await fetch("https://api.rasket.com/emails", {  method: "POST",  headers: {    authorization: `Bearer ${process.env.RASKET_API_KEY}`,    "content-type": "application/json",    "user-agent": "acme-billing/1.0",    "idempotency-key": "receipt-1042",  },  body: JSON.stringify({    from: "Acme <billing@acme.example>",    to: ["ronald.williams@example.com"],    subject: "Your receipt",    html: "<p>Thanks for your order.</p>",  }),});
if (!response.ok) {  throw new Error(`send failed: ${String(response.status)}`);}
const email = await response.json();

Four headers carry the whole contract. Authorization is Bearer and your API key. Content-Type is application/json. User-Agent names your application. Idempotency-Key is optional, and it is the only one of the four that changes what happens when you send the same request twice.

Writing it by hand costs you the typed bodies, the retry policy and the webhook verifier the package bundles. It is the right choice in a runtime where you would rather not add a dependency at all, and it is the right choice for a first experiment, because a curl and this snippet are the same request.

Where SMTP and Nodemailer still fit

SMTP is not obsolete, and saying so would be a sales pitch rather than an engineering claim. It is the protocol every message eventually travels over, and there are three situations where speaking it directly from Node is the right answer.

  • You already run a mail server. If there is a relay inside your network that handles authentication, queueing and retries, pointing Nodemailer at it is less work than adding an external dependency.
  • The mail never leaves the building. Internal notifications to an internal relay have no deliverability problem to solve, because there is no external reputation involved.
  • You are porting something that already works. A working SMTP integration you are not being asked to improve is not a bug.

For reference, this is what the Nodemailer version of the same send looks like.

import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({  host: "smtp.example.com",  port: 587,  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },});
await transport.sendMail({  from: "Acme <billing@acme.example>",  to: "ronald.williams@example.com",  subject: "Your receipt",  html: "<p>Thanks for your order.</p>",});

Compare it with the API sample above and notice what is missing rather than what is different. There is no id to store. There is nothing that will tell you, four hours later, that the message bounced. If the address was already known to be dead, the message goes out anyway. Those gaps are not Nodemailer’s fault — they are on the other side of the socket — but they are yours to fill.

Retries, idempotency and rate limits

A network call that times out has not necessarily failed. The request may have arrived, the message may have been accepted, and only the response may have been lost. Retrying blindly is how one order becomes three receipts.

An Idempotency-Key is what makes the retry safe. Send the same key with the retry and a repeat is recognised as a repeat: the original response comes back, marked as a replay, and no second message is sent. The window is 24 hours. Derive the key from the event that caused the send — an order id, an invoice number, a password-reset request id — so that two attempts to send the same thing produce the same key, and two different things never do. Read the idempotency guide for the exact semantics.

The rate limit is ten requests a second per team, across every endpoint. You do not have to guess where you are in the window, because every response says:

ratelimit-limit: 10ratelimit-remaining: 7ratelimit-reset: 1

Pace a bulk job off ratelimit-remaining rather than off a sleep you tuned once on a quiet afternoon. If you do go over, the answer is a 429 carrying how long to wait, and the SDK’s built-in retry policy already backs off on it. A write without an idempotency key is never retried automatically, which is the correct default: at-most-once matters more than a saved round trip.

Verifying delivery with webhooks

A 200 from the send endpoint means the message was accepted, not that it arrived. What happened next — delivered, delayed, bounced, complained, opened, clicked — reaches you as a signed webhook. This is the part that has no equivalent in the Nodemailer path, and it is usually the reason a team moves.

Three headers travel with every delivery:

  • svix-id — the event’s own id, stable across replays. Deduplicate on it.
  • svix-timestamp — Unix seconds. Anything more than five minutes out is refused.
  • svix-signature — one or more signatures; any one matching is enough, which is what makes a secret rotation survivable.

Verify before you trust, and verify against the bytes that arrived rather than a body your framework has already parsed and re-serialised — a re-serialised body is a different string, and a different string has a different signature.

import { verify } from "@rasket/webhook-verify";
export async function POST(request: Request): Promise<Response> {  const rawBody = await request.text(); // text(), never json()
  try {    const event = verify(rawBody, request.headers, process.env.RASKET_WEBHOOK_SECRET);    await record(event);    return new Response(null, { status: 200 });  } catch {    return new Response("invalid signature", { status: 400 });  }}

The verifier is a separate package on purpose: it imports node:crypto and nothing else, so an endpoint that only needs to check a signature does not have to install a client. Events can arrive more than once and can arrive out of order, so treat your handler as a fold over a stream rather than a state machine with an expected sequence. The events guide lists every event and its payload.

Where to go from here

If you are starting from nothing, the quickstart is a key, a domain and a first send in about five minutes. If you are migrating, the shortest useful step is to send one real message through the API beside your existing transport and compare what each one can tell you about it an hour later.

Frequently asked questions

Can I use Nodemailer with Rasket?

Not directly. Nodemailer speaks SMTP, and Rasket has no SMTP relay — it is an HTTP API. If you already have Nodemailer wired into your application, the migration is to replace the transport call with one HTTP call, which is usually a smaller change than it sounds because the message fields have the same names.

Do I need a verified domain to send?

Yes. A send from a domain that has not passed verification is refused before anything leaves the building. Verification means publishing the DKIM, SPF and DMARC records the dashboard generates for that domain and letting the checks pass, which usually takes minutes once DNS has propagated.

What happens if my request times out and I retry?

Send the retry with the same Idempotency-Key as the first attempt. A repeat of a keyed request returns the original response rather than sending a second copy, for 24 hours. Without a key, a retry is a new send, so at-most-once delivery is something you opt into deliberately.

How many requests a second can I make?

Ten per second per team, across every endpoint. Every response carries the limit, what is left and when the window resets, so a client can pace itself rather than guess. Going over gets a 429 with a retry-after value.

Is an HTTP API faster than SMTP?

Usually, and for a reason that has nothing to do with the network. SMTP is a conversational protocol: a send is several round trips before the body is even offered. One HTTPS request is one round trip, and it returns an id you can store immediately rather than a queue receipt you have to correlate later.

Can I send attachments from Node.js?

Yes. Attachments go in the same request, either as base64 content or as a URL we fetch. There is a ceiling on the total request size, so very large files are better sent as a signed link in the body than as an attachment.

Sources

  1. RFC 5321: Simple Mail Transfer ProtocolIETF, read 2026-09-16
  2. RFC 5322: Internet Message FormatIETF, read 2026-09-16
  3. Nodemailer documentationNodemailer, read 2026-09-16
  4. Using the Fetch APIMDN Web Docs, read 2026-09-16
  • QuickstartKey, domain, first send — in that order.
  • Node SDKThe rasket package: typed from the API's own document, retries only what is safe.
  • IdempotencyRetry a send without sending it twice.
  • EmailsSend, batch, retrieve, list, reschedule, cancel, attachments.
  • Email APISend email over one REST call: idempotent sends, batches, scheduling, attachments, templates and a delivery timeline for every message.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.