Skip to content
Claim your free business email

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

Published Updated 9 min readBy the Rasket team

Sending email from Node.js with an API: a terminal window releasing an envelope toward a mailbox.

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 takes both

Rasket is an HTTP API, and it also runs an SMTP relay. If your application is built around a Nodemailer transport today, you can point that transport at smtp.rasket.com with your key as the password and change nothing else — then move to the one HTTP call below when you want the message id back. The SMTP docs have the transport settings.

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, campaignsA 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 plain fetch

The shortest path today is the fetch built into Node. The API is ordinary HTTP: a bearer token, a JSON body, and an idempotency header, with nothing to install.

  1. Use the fetch Node already has — Nothing to install: Node 18 and later ship fetch, and the send is one POST to https://api.rasket.com/emails with a JSON body.
  2. Export the key — Create 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 domain — Add 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 key — POST from, to, subject and html with a bearer key and a User-Agent, and an Idempotency-Key header derived from the thing that caused the send — an order id, not a random string.
  5. Handle the response — Store the returned email id against your own record. It is the id every later event and every support question is about.
  6. Subscribe a webhook — Point an endpoint at the delivery events you care about and verify the signature on every request before you trust the body.
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();console.log(email.id);

Four headers carry the whole contract. Authorization is Bearer and your API key, read from the environment and never written into the file — a key in a repository is a key you will rotate on somebody else’s schedule. Content-Type is application/json. User-Agent is required: a request with no User-Agent is refused, and naming your application is what lets support tell your billing worker from your web process. And Idempotency-Key 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

A 200 carries the message’s id, and the response headers carry the rate limit state and the request id to quote in a support conversation. Store email.id against your own record. It is the id every later event and every question about this message is about.

The Rasket Node SDK, coming soon

Coming soon: the rasket package is not published yet. When it is, it will wrap this same request with typed bodies generated from the API’s OpenAPI document, a retry policy and the webhook verifier. Until then, the fetch above is the whole of a send, and a curl of the same request is the quickest first experiment.

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 in retry-after: wait that long, then send the same request with the same Idempotency-Key. Never retry a write that carries no key, 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.

// npm install svix — the scheme is Svix-compatible, so the official package verifies it.import { Webhook } from "svix";
const wh = new Webhook(process.env.RASKET_WEBHOOK_SECRET);
export async function POST(request: Request): Promise<Response> {  const rawBody = await request.text(); // text(), never json()
  try {    const event = wh.verify(rawBody, Object.fromEntries(request.headers));    await record(event);    return new Response(null, { status: 200 });  } catch {    return new Response("invalid signature", { status: 400 });  }}

The scheme is Svix-compatible, so the official svix package verifies our deliveries as they are; @rasket/webhook-verify, our own zero-dependency verifier, follows when it is published. 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?

Yes. Point a Nodemailer transport at smtp.rasket.com on port 465 with secure: true, the username rasket and your API key as the password, and each message becomes an ordinary API send. The HTTP call this guide shows is still the better long-term shape, because it returns the message id and makes a retry safe, and the message fields have the same names, so the move is small.

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 Protocol — IETF, read 2026-09-16
  2. RFC 5322: Internet Message Format — IETF, read 2026-09-16
  3. Nodemailer documentation — Nodemailer, read 2026-09-16
  4. Using the Fetch API — MDN Web Docs, read 2026-09-16
  • Quickstart — Key, domain, first send — in that order.
  • Node SDK — The rasket package: typed from the API's own document, retries only what is safe.
  • Idempotency — Retry a send without sending it twice.
  • Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
  • Email API — Send 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.