[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-0b5474da-69d8-48dc-a5b2-e9fa95e49810":3},{"id":4,"body":5,"uuid":6,"created_at":7,"updated_at":8,"brand":9,"header":10,"short_body":11,"image":12,"published":13,"published_at":14,"tags":15},14,"\u003Cp>Adding \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> to an application — password resets, email verification, receipts, OTP codes, alerts — should take minutes, not days. With a modern HTTP \u003Cstrong>email API\u003C\u002Fstrong>, you can go from an empty project to a delivered, inbox-placed message in about ten minutes of work: install nothing more than your HTTP client of choice, set one environment variable, and make a single authenticated \u003Ccode>POST\u003C\u002Fcode> request.\u003C\u002Fp>\n\u003Cp>This is a hands-on tutorial for software engineers, SaaS founders, and CTOs wiring email into a Node.js app. We'll send a real transactional email step by step, then layer in the production concerns that separate a quick demo from a reliable system: environment-based configuration, robust error handling, retries with idempotency, and webhook handling for delivery events. Every code sample is realistic and runnable.\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>Quick answer:\u003C\u002Fstrong> To send transactional email in Node.js, call an HTTP email API with a single authenticated \u003Ccode>POST\u003C\u002Fcode> request — using the built-in \u003Ccode>fetch\u003C\u002Fcode> (Node 18+) or \u003Ccode>axios\u003C\u002Fcode>. Put the recipient, subject, and body in a JSON payload, your API key in an \u003Ccode>Authorization\u003C\u002Fcode> header, and you're sending in under ten minutes. Add retries, idempotency keys, and a webhook endpoint to make it production-grade.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2>What Is Transactional Email in Node.js?\u003C\u002Fh2>\n\u003Cp>\u003Cstrong>Transactional email\u003C\u002Fstrong> is mail your application sends automatically in response to a user action or system event — one message to one recipient, triggered by something specific. Examples engineers ship constantly:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Account verification\u003C\u002Fstrong> and welcome emails on sign-up\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Password reset\u003C\u002Fstrong> links\u003C\u002Fli>\n\u003Cli>\u003Cstrong>One-time passcodes (OTP)\u003C\u002Fstrong> for two-factor authentication\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Receipts and invoices\u003C\u002Fstrong> after a purchase\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Alerts and notifications\u003C\u002Fstrong> (security warnings, usage limits, status changes)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>This is distinct from \u003Cstrong>marketing email\u003C\u002Fstrong> (bulk campaigns to a list). Transactional messages are expected by the user, time-sensitive, and high-stakes: if a password reset lands in spam, a user is locked out.\u003C\u002Fp>\n\u003Cp>In Node.js, the cleanest way to send these is through a \u003Cstrong>transactional email API\u003C\u002Fstrong> — an HTTP service you call from your backend. You make a request, the provider handles the actual SMTP delivery, signing, reputation, and tracking, and returns a message ID you can correlate with later events. No mail server to run, no SMTP connection pool to tune.\u003C\u002Fp>\n\u003Ch2>Why Use an Email API for Node.js Transactional Email?\u003C\u002Fh2>\n\u003Cp>You have two technical options for sending mail from Node.js: speak \u003Cstrong>SMTP\u003C\u002Fstrong> (typically via Nodemailer) or call an \u003Cstrong>HTTP email API\u003C\u002Fstrong>. Both work, but for application-driven transactional mail in modern, often serverless, deployments, an API is the better default.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Concern\u003C\u002Fth>\n\u003Cth>SMTP (Nodemailer)\u003C\u002Fth>\n\u003Cth>Email API (HTTP)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Integration\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Configure host, port, credentials; manage transport\u003C\u002Ftd>\n\u003Ctd>One \u003Ccode>POST\u003C\u002Fcode> request with \u003Ccode>fetch\u003C\u002Fcode>\u002F\u003Ccode>axios\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency per send\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Handshake + multi-step dialogue\u003C\u002Ftd>\n\u003Ctd>Single stateless request\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Serverless friendliness\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Long-lived connections awkward on Lambda\u002Fedge; ports often blocked\u003C\u002Ftd>\n\u003Ctd>Port 443 HTTPS — works everywhere\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Delivery feedback\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Accept\u002Freject at handshake only\u003C\u002Ftd>\n\u003Ctd>Synchronous status + async webhooks\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Built-in features\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Transport only\u003C\u002Ftd>\n\u003Ctd>Templates, suppression, idempotency, analytics\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Security\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Long-lived shared credentials\u003C\u002Ftd>\n\u003Ctd>Scoped, rotatable API keys\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The headline reason: an email API returns a \u003Cstrong>message ID synchronously\u003C\u002Fstrong> and pushes \u003Cstrong>delivery events\u003C\u002Fstrong> (delivered, bounced, complained) to your webhook endpoint. That observability loop is what lets you answer \"did the password reset actually arrive?\" — a question SMTP alone can't answer cleanly.\u003C\u002Fp>\n\u003Cp>For the full breakdown, see our \u003Ca href=\"\u002Fblog\u002Fsmtp-vs-email-api\">SMTP vs Email API guide\u003C\u002Fa>. For this tutorial, we'll use the API approach.\u003C\u002Fp>\n\u003Ch2>Prerequisites\u003C\u002Fh2>\n\u003Cp>Before you start, you need:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Node.js 18 or newer.\u003C\u002Fstrong> Node 18+ ships a global \u003Ccode>fetch\u003C\u002Fcode>, so you can send your first email with zero dependencies. Check with \u003Ccode>node --version\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>An email API key.\u003C\u002Fstrong> Sign up with a transactional email provider and generate an API key. (We'll use \u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> in the examples; the pattern is the same for any HTTP email API.)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A verified sending domain\u003C\u002Fstrong> with SPF, DKIM, and DMARC configured. Most providers walk you through adding a few DNS records. This is what keeps your mail out of spam — more on it below.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>That's it. No mail server, no SMTP credentials, no queue infrastructure required to get started.\u003C\u002Fp>\n\u003Ch2>Step 1: Configure Your Environment\u003C\u002Fh2>\n\u003Cp>Never hardcode an API key in source. Store it in an environment variable so it stays out of your repository and can differ per environment (dev, staging, production).\u003C\u002Fp>\n\u003Cp>Create a \u003Ccode>.env\u003C\u002Fcode> file in your project root:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># .env\nPOSTWING_API_KEY=pw_live_xxxxxxxxxxxxxxxxxxxxxxxx\nPOSTWING_API_URL=https:\u002F\u002Fapi.postwing.app\u002Fv1\nEMAIL_FROM=noreply@yourapp.com\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Add \u003Ccode>.env\u003C\u002Fcode> to your \u003Ccode>.gitignore\u003C\u002Fcode> immediately:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># .gitignore\n.env\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Load it in your app. Node 20.6+ can load env files natively with \u003Ccode>node --env-file=.env app.js\u003C\u002Fcode>, but the \u003Ccode>dotenv\u003C\u002Fcode> package works on every version:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">npm install dotenv\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F config.js\nimport &quot;dotenv\u002Fconfig&quot;;\n\nexport const config = {\n  apiKey: process.env.POSTWING_API_KEY,\n  apiUrl: process.env.POSTWING_API_URL ?? &quot;https:\u002F\u002Fapi.postwing.app\u002Fv1&quot;,\n  from: process.env.EMAIL_FROM ?? &quot;noreply@yourapp.com&quot;,\n};\n\nif (!config.apiKey) {\n  throw new Error(&quot;POSTWING_API_KEY is not set. Check your .env file.&quot;);\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Failing fast when the key is missing saves you from confusing 401 errors at runtime.\u003C\u002Fp>\n\u003Ch2>Step 2: Send Your First Node.js Transactional Email with fetch\u003C\u002Fh2>\n\u003Cp>Here's the minimal \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> send using the built-in \u003Ccode>fetch\u003C\u002Fcode> — no dependencies beyond Node 18+. This sends a password-reset email:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F send.js\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nconst response = await fetch(`${config.apiUrl}\u002Fsend`, {\n  method: &quot;POST&quot;,\n  headers: {\n    &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n    &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n  },\n  body: JSON.stringify({\n    from: config.from,\n    to: &quot;user@example.com&quot;,\n    subject: &quot;Reset your password&quot;,\n    text: &quot;Click to reset your password: https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;,\n    html: `&lt;p&gt;Click to &lt;a href=&quot;https:\u002F\u002Fyourapp.com\u002Freset?token=abc123&quot;&gt;reset your password&lt;\u002Fa&gt;.&lt;\u002Fp&gt;`,\n  }),\n});\n\nconst data = await response.json();\nconsole.log(data);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Run it:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">node send.js\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The API responds immediately with a message ID and status:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;id&quot;: &quot;msg_9f8a7b6c5d4e&quot;,\n  &quot;status&quot;: &quot;queued&quot;,\n  &quot;to&quot;: &quot;user@example.com&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Store that \u003Ccode>id\u003C\u002Fcode>. It's the key you'll use to match delivery webhook events back to this specific send. That's your first transactional email — sent over HTTPS on port 443, which works even on locked-down cloud hosts where SMTP ports are blocked.\u003C\u002Fp>\n\u003Ch2>Step 3: Wrap It in a Reusable Function\u003C\u002Fh2>\n\u003Cp>A raw \u003Ccode>fetch\u003C\u002Fcode> call inline is fine for a demo. In a real app, wrap it in a small module with proper error handling so every part of your codebase sends mail the same way.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F mailer.js\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nexport class EmailError extends Error {\n  constructor(message, { status, body } = {}) {\n    super(message);\n    this.name = &quot;EmailError&quot;;\n    this.status = status;\n    this.body = body;\n  }\n}\n\nexport async function sendEmail({ to, subject, text, html, from }) {\n  const response = await fetch(`${config.apiUrl}\u002Fsend`, {\n    method: &quot;POST&quot;,\n    headers: {\n      &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n      &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n    },\n    body: JSON.stringify({\n      from: from ?? config.from,\n      to,\n      subject,\n      text,\n      html,\n    }),\n  });\n\n  if (!response.ok) {\n    let body;\n    try {\n      body = await response.json();\n    } catch {\n      body = await response.text();\n    }\n    throw new EmailError(`Email send failed (${response.status})`, {\n      status: response.status,\n      body,\n    });\n  }\n\n  return response.json();\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Now sending from anywhere in your app is a one-liner:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { sendEmail } from &quot;.\u002Fmailer.js&quot;;\n\nawait sendEmail({\n  to: user.email,\n  subject: &quot;Welcome to YourApp&quot;,\n  text: `Hi ${user.name}, welcome aboard!`,\n  html: `&lt;p&gt;Hi ${user.name}, welcome aboard!&lt;\u002Fp&gt;`,\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Using axios instead of fetch\u003C\u002Fh3>\n\u003Cp>If your stack already uses \u003Ccode>axios\u003C\u002Fcode>, the \u003Cstrong>email API in Node.js\u003C\u002Fstrong> pattern is nearly identical. \u003Ccode>axios\u003C\u002Fcode> throws on non-2xx responses by default, which simplifies error handling:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F mailer-axios.js\nimport axios from &quot;axios&quot;;\nimport { config } from &quot;.\u002Fconfig.js&quot;;\n\nconst client = axios.create({\n  baseURL: config.apiUrl,\n  headers: { Authorization: `Bearer ${config.apiKey}` },\n  timeout: 10_000,\n});\n\nexport async function sendEmail({ to, subject, text, html, from }) {\n  try {\n    const { data } = await client.post(&quot;\u002Fsend&quot;, {\n      from: from ?? config.from,\n      to,\n      subject,\n      text,\n      html,\n    });\n    return data;\n  } catch (err) {\n    if (axios.isAxiosError(err) &amp;&amp; err.response) {\n      throw new Error(\n        `Email send failed (${err.response.status}): ${JSON.stringify(err.response.data)}`\n      );\n    }\n    throw err;\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Use whichever HTTP client your project already depends on. \u003Ccode>fetch\u003C\u002Fcode> keeps your dependency count at zero; \u003Ccode>axios\u003C\u002Fcode> gives you interceptors, timeouts, and automatic JSON handling out of the box.\u003C\u002Fp>\n\u003Ch2>Step 4: Handle Errors and Retries Properly\u003C\u002Fh2>\n\u003Cp>Network calls fail. A production \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> integration must distinguish between errors it should retry and errors it must not.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Status\u003C\u002Fth>\n\u003Cth>Meaning\u003C\u002Fth>\n\u003Cth>Retry?\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>400\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Bad request (malformed payload, invalid address)\u003C\u002Ftd>\n\u003Ctd>No — fix the input\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>401\u003C\u002Fcode> \u002F \u003Ccode>403\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Invalid or unauthorized API key\u003C\u002Ftd>\n\u003Ctd>No — fix credentials\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>422\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Validation error (e.g., unverified sender)\u003C\u002Ftd>\n\u003Ctd>No — fix configuration\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>429\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Rate limited\u003C\u002Ftd>\n\u003Ctd>Yes — back off and retry\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>500\u003C\u002Fcode>–\u003Ccode>504\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Provider-side error\u003C\u002Ftd>\n\u003Ctd>Yes — retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Network timeout \u002F \u003Ccode>ECONNRESET\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Transient connectivity\u003C\u002Ftd>\n\u003Ctd>Yes — retry with backoff\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The rule of thumb: \u003Cstrong>retry transient failures (429, 5xx, network errors) with exponential backoff; never retry client errors (4xx other than 429)\u003C\u002Fstrong> — they'll just fail again. Here's a retry wrapper:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F retry.js\nconst RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);\n\nconst sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));\n\nexport async function withRetry(fn, { retries = 3, baseDelay = 500 } = {}) {\n  let lastError;\n\n  for (let attempt = 0; attempt &lt;= retries; attempt++) {\n    try {\n      return await fn();\n    } catch (err) {\n      lastError = err;\n\n      const status = err.status;\n      const isRetryable =\n        status === undefined || RETRYABLE_STATUS.has(status);\n\n      if (!isRetryable || attempt === retries) break;\n\n      \u002F\u002F Exponential backoff with jitter: 500ms, 1s, 2s (+ random)\n      const delay = baseDelay * 2 ** attempt + Math.random() * 200;\n      await sleep(delay);\n    }\n  }\n\n  throw lastError;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Wrap your send:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { withRetry } from &quot;.\u002Fretry.js&quot;;\nimport { sendEmail } from &quot;.\u002Fmailer.js&quot;;\n\nawait withRetry(() =&gt;\n  sendEmail({\n    to: user.email,\n    subject: &quot;Your one-time code&quot;,\n    text: `Your code is 481920. It expires in 5 minutes.`,\n  })\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Use idempotency keys to avoid duplicate sends\u003C\u002Fh3>\n\u003Cp>Retries introduce a danger: if a request actually succeeded but the response was lost, retrying double-sends the email. A user getting two receipts — or two charge confirmations — erodes trust.\u003C\u002Fp>\n\u003Cp>Most transactional email APIs support an \u003Cstrong>idempotency key\u003C\u002Fstrong>: a unique value you attach to a request so the provider deduplicates retries server-side. Generate one per logical send and reuse it across retries:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { randomUUID } from &quot;node:crypto&quot;;\n\nexport async function sendEmailIdempotent(payload, idempotencyKey = randomUUID()) {\n  const response = await fetch(`${config.apiUrl}\u002Fsend`, {\n    method: &quot;POST&quot;,\n    headers: {\n      &quot;Authorization&quot;: `Bearer ${config.apiKey}`,\n      &quot;Content-Type&quot;: &quot;application\u002Fjson&quot;,\n      &quot;Idempotency-Key&quot;: idempotencyKey,\n    },\n    body: JSON.stringify(payload),\n  });\n  \u002F\u002F ...same error handling as before\n  return response.json();\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Generate the key once, outside the retry loop, so all attempts share it. The provider returns the original result for repeated keys instead of sending again.\u003C\u002Fp>\n\u003Ch2>Step 5: Send Emails Without Blocking the Request\u003C\u002Fh2>\n\u003Cp>A common mistake is awaiting the email send inside an HTTP request handler. If the email API is slow, your user waits. Worse, if it fails, you might fail the whole operation (the user signed up but got a 500).\u003C\u002Fp>\n\u003Cp>Email delivery should be \u003Cstrong>decoupled from the user's request\u003C\u002Fstrong>. For most apps, the simplest correct pattern is to enqueue the send and respond immediately:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F signup route (Express)\napp.post(&quot;\u002Fsignup&quot;, async (req, res) =&gt; {\n  const user = await createUser(req.body);\n\n  \u002F\u002F Don't block the response on email delivery.\n  \u002F\u002F Enqueue it; a worker handles the actual send with retries.\n  await emailQueue.add(&quot;welcome&quot;, {\n    to: user.email,\n    name: user.name,\n  });\n\n  res.status(201).json({ id: user.id });\n});\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A background worker (BullMQ, a serverless queue, or a simple cron-drained table) then calls your \u003Ccode>sendEmail\u003C\u002Fcode> with retries. This keeps your API fast and ensures a transient email outage doesn't break sign-up. For lower-traffic apps, even a fire-and-forget \u003Ccode>sendEmail(...).catch(logError)\u003C\u002Fcode> after responding is better than blocking the user — though a real queue gives you durability and retry guarantees.\u003C\u002Fp>\n\u003Ch2>Step 6: Handle Delivery Webhooks\u003C\u002Fh2>\n\u003Cp>Sending is only half the loop. To know what actually happened to each message — \u003Cstrong>delivered\u003C\u002Fstrong>, \u003Cstrong>bounced\u003C\u002Fstrong>, \u003Cstrong>opened\u003C\u002Fstrong>, \u003Cstrong>complained\u003C\u002Fstrong> (marked as spam) — you handle \u003Cstrong>webhooks\u003C\u002Fstrong>: HTTP callbacks the provider sends to your endpoint as events occur.\u003C\u002Fp>\n\u003Cp>Here's a webhook handler in Express that processes delivery events:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F webhooks.js\nimport express from &quot;express&quot;;\nimport crypto from &quot;node:crypto&quot;;\n\nconst router = express.Router();\n\n\u002F\u002F Use the raw body so we can verify the signature.\nrouter.post(\n  &quot;\u002Fwebhooks\u002Femail&quot;,\n  express.raw({ type: &quot;application\u002Fjson&quot; }),\n  (req, res) =&gt; {\n    const signature = req.header(&quot;X-Webhook-Signature&quot;);\n    const secret = process.env.POSTWING_WEBHOOK_SECRET;\n\n    \u002F\u002F 1. Verify the webhook is genuinely from your provider.\n    const expected = crypto\n      .createHmac(&quot;sha256&quot;, secret)\n      .update(req.body) \u002F\u002F raw Buffer\n      .digest(&quot;hex&quot;);\n\n    const valid =\n      signature &amp;&amp;\n      crypto.timingSafeEqual(\n        Buffer.from(signature),\n        Buffer.from(expected)\n      );\n\n    if (!valid) {\n      return res.status(401).send(&quot;Invalid signature&quot;);\n    }\n\n    \u002F\u002F 2. Parse and handle the event.\n    const event = JSON.parse(req.body.toString());\n\n    switch (event.type) {\n      case &quot;delivered&quot;:\n        console.log(`Delivered: ${event.message_id} -&gt; ${event.recipient}`);\n        break;\n      case &quot;bounced&quot;:\n        \u002F\u002F A hard bounce means the address is invalid — suppress it.\n        markEmailInvalid(event.recipient);\n        break;\n      case &quot;complained&quot;:\n        \u002F\u002F The user marked it as spam — stop emailing them.\n        unsubscribe(event.recipient);\n        break;\n      default:\n        console.log(`Unhandled event: ${event.type}`);\n    }\n\n    \u002F\u002F 3. Respond 200 quickly so the provider doesn't retry.\n    res.sendStatus(200);\n  }\n);\n\nexport default router;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Three rules for reliable webhook handling:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Verify the signature.\u003C\u002Fstrong> Never trust an unauthenticated webhook — anyone could POST fake events. Use the provider's signing secret and a constant-time comparison (\u003Ccode>crypto.timingSafeEqual\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respond fast (2xx) and do work asynchronously.\u003C\u002Fstrong> Providers retry webhooks that don't return 2xx promptly. Acknowledge first, then process (or enqueue) the heavy work.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Act on bounces and complaints.\u003C\u002Fstrong> Hard bounces mean dead addresses — suppress them. Complaints mean spam reports — stop mailing that user. Ignoring these tanks your sender reputation over time.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Webhooks close the observability loop that makes the \u003Cstrong>email API in Node.js\u003C\u002Fstrong> approach so much stronger than fire-and-forget SMTP.\u003C\u002Fp>\n\u003Ch2>Step 7: Make Sure Your Email Reaches the Inbox\u003C\u002Fh2>\n\u003Cp>Your code can be perfect and your mail can still land in spam if your domain isn't authenticated. Inbox placement depends on \u003Cstrong>domain reputation and authentication\u003C\u002Fstrong>, not your Node.js code. Three DNS-based standards are non-negotiable:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>SPF (Sender Policy Framework)\u003C\u002Fstrong> — lists which servers may send for your domain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DKIM (DomainKeys Identified Mail)\u003C\u002Fstrong> — cryptographically signs your messages so recipients can verify they weren't tampered with.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>DMARC (Domain-based Message Authentication)\u003C\u002Fstrong> — tells receiving servers what to do with mail that fails SPF\u002FDKIM, and gives you reporting.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Since Google and Yahoo's \u003Ca href=\"https:\u002F\u002Fblog.google\u002Fproducts\u002Fgmail\u002Fgmail-security-authentication-spam-protection\u002F\">2024 bulk-sender requirements\u003C\u002Fa>, authenticated mail is effectively mandatory. A good provider configures and signs these for you — you add a few DNS records once. For a deeper dive, see our guide on \u003Ca href=\"\u002Fblog\u002Fspf-dkim-dmarc-email-authentication\">SPF, DKIM, and DMARC\u003C\u002Fa>.\u003C\u002Fp>\n\u003Cp>Beyond authentication:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use a real \u003Ccode>From\u003C\u002Fcode> address\u003C\u002Fstrong> on a verified subdomain (e.g., \u003Ccode>noreply@mail.yourapp.com\u003C\u002Fcode>), not a free mailbox like Gmail.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep transactional and marketing streams separate\u003C\u002Fstrong> so a campaign can't poison the reputation that delivers your password resets.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Honor bounces and complaints\u003C\u002Fstrong> via the webhook handler above.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Common Mistakes with Node.js Transactional Email\u003C\u002Fh2>\n\u003Cp>These mistakes quietly break reliability and deliverability in real Node.js apps:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Hardcoding the API key.\u003C\u002Fstrong> Committing a key to your repo is a security incident. Use environment variables or a secrets manager, and add \u003Ccode>.env\u003C\u002Fcode> to \u003Ccode>.gitignore\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocking the HTTP response on email delivery.\u003C\u002Fstrong> Awaiting a slow or failing email API inside a request handler makes users wait and can fail core operations. Decouple the send via a queue or fire-and-forget with error logging.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying without idempotency keys.\u003C\u002Fstrong> Naive retries double-send receipts and OTPs when a response is lost. Attach an idempotency key generated once per logical send.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retrying non-retryable errors.\u003C\u002Fstrong> Re-sending on a \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>401\u003C\u002Fcode> just burns rate limits. Only retry \u003Ccode>429\u003C\u002Fcode> and \u003Ccode>5xx\u003C\u002Fcode> and network failures, with exponential backoff.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating a 2xx as \"delivered.\"\u003C\u002Fstrong> A successful API response means \u003Cem>accepted for delivery\u003C\u002Fem>, not inboxed. Process delivery webhooks to know the real outcome.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not verifying webhook signatures.\u003C\u002Fstrong> An unauthenticated webhook endpoint lets anyone inject fake events. Always verify the HMAC signature with a constant-time comparison.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Ignoring bounces and complaints.\u003C\u002Fstrong> Repeatedly mailing dead addresses or spam-reporters destroys sender reputation. Suppress hard bounces and unsubscribe complainers automatically.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Skipping SPF, DKIM, and DMARC.\u003C\u002Fstrong> The single most common reason transactional mail lands in spam. Authenticate your domain before you ship.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>How do I send transactional email in Node.js?\u003C\u002Fh3>\n\u003Cp>To send transactional email in Node.js, call an HTTP email API with a single authenticated \u003Ccode>POST\u003C\u002Fcode> request. Use the built-in \u003Ccode>fetch\u003C\u002Fcode> (Node 18+) or \u003Ccode>axios\u003C\u002Fcode>, put your API key in an \u003Ccode>Authorization: Bearer\u003C\u002Fcode> header, and send a JSON payload with \u003Ccode>from\u003C\u002Fcode>, \u003Ccode>to\u003C\u002Fcode>, \u003Ccode>subject\u003C\u002Fcode>, and \u003Ccode>text\u003C\u002Fcode>\u002F\u003Ccode>html\u003C\u002Fcode> fields. The provider handles SMTP delivery and returns a message ID. You can send your first email in under ten minutes, then add retries and webhook handling for production.\u003C\u002Fp>\n\u003Ch3>Should I use fetch or axios for an email API in Node.js?\u003C\u002Fh3>\n\u003Cp>Either works. The built-in \u003Ccode>fetch\u003C\u002Fcode> (available globally in Node 18+) keeps your dependencies at zero and is ideal for simple sends. \u003Ccode>axios\u003C\u002Fcode> is a good fit if your project already uses it — it provides interceptors, configurable timeouts, automatic JSON parsing, and throws on non-2xx responses, which can simplify error handling. Pick whichever your stack already depends on.\u003C\u002Fp>\n\u003Ch3>Do I need Nodemailer for transactional email?\u003C\u002Fh3>\n\u003Cp>Not if you use an HTTP email API. Nodemailer is an SMTP client — useful when you must speak SMTP (e.g., a relay for third-party tools). For application-driven transactional email, an HTTP API called with \u003Ccode>fetch\u003C\u002Fcode> or \u003Ccode>axios\u003C\u002Fcode> is simpler, faster on serverless platforms, and gives you synchronous message IDs plus delivery webhooks that raw SMTP doesn't.\u003C\u002Fp>\n\u003Ch3>How do I prevent sending duplicate emails on retry?\u003C\u002Fh3>\n\u003Cp>Use an idempotency key. Generate a unique value (such as \u003Ccode>crypto.randomUUID()\u003C\u002Fcode>) once per logical send, attach it as an \u003Ccode>Idempotency-Key\u003C\u002Fcode> header, and reuse the same key across all retry attempts. The email API deduplicates requests with the same key server-side, so a retried request that originally succeeded returns the original result instead of sending again.\u003C\u002Fp>\n\u003Ch3>How do I handle email delivery webhooks in Node.js?\u003C\u002Fh3>\n\u003Cp>Create an HTTP endpoint (e.g., an Express route) that receives the provider's POST callbacks. Read the raw request body, verify the HMAC signature with your webhook secret using \u003Ccode>crypto.timingSafeEqual\u003C\u002Fcode>, then parse and handle events like \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode>. Respond with a 2xx status quickly and do heavy work asynchronously so the provider doesn't retry the delivery.\u003C\u002Fp>\n\u003Ch3>Why are my Node.js transactional emails going to spam?\u003C\u002Fh3>\n\u003Cp>Almost always because of missing or misconfigured domain authentication, not your code. Set up SPF, DKIM, and DMARC DNS records for your sending domain — these are effectively required by Gmail and Yahoo since 2024. Also send from a verified subdomain rather than a free mailbox, keep transactional and marketing mail on separate streams, and suppress addresses that hard-bounce or report spam.\u003C\u002Fp>\n\u003Ch3>Can I send transactional email from a serverless function?\u003C\u002Fh3>\n\u003Cp>Yes — and an HTTP email API is the ideal fit. Serverless platforms (AWS Lambda, Vercel, Cloudflare Workers) often block SMTP ports and don't suit long-lived connections. An email API uses stateless HTTPS on port 443, so a single \u003Ccode>fetch\u003C\u002Fcode> from your function just works. Pair it with a queue for retries if your function has a short timeout.\u003C\u002Fp>\n\u003Ch3>How fast can I integrate an email API in a Node.js app?\u003C\u002Fh3>\n\u003Cp>About ten minutes for a working send: sign up and get an API key, set one environment variable, and make a \u003Ccode>POST\u003C\u002Fcode> request with \u003Ccode>fetch\u003C\u002Fcode>. Production-hardening — retries with idempotency, a webhook handler, and domain authentication — adds an hour or two but is straightforward to copy from the patterns in this guide.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Sending \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> is genuinely a ten-minute task to get started, and a well-understood path to production-grade. With Node 18+ and a modern HTTP email API, your first send is a single authenticated \u003Ccode>fetch\u003C\u002Fcode> request — no mail server, no SMTP tuning, no extra dependencies. From there, the production checklist is short and mechanical:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Configure\u003C\u002Fstrong> the API key via environment variables, never in source.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Wrap\u003C\u002Fstrong> sends in a reusable function with proper error handling.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Retry\u003C\u002Fstrong> transient failures (429, 5xx, network) with exponential backoff and idempotency keys.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Decouple\u003C\u002Fstrong> delivery from the user's request with a queue.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Handle webhooks\u003C\u002Fstrong> to track delivered, bounced, and complained events — and act on them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Authenticate\u003C\u002Fstrong> your domain with SPF, DKIM, and DMARC so mail reaches the inbox.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Nail those, and your password resets, receipts, and OTPs arrive reliably and on time — with full visibility into what happened to every message.\u003C\u002Fp>\n\u003Ch2>Start Sending with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers: a fast, observable HTTP \u003Cstrong>email API\u003C\u002Fstrong> you can call from Node.js with a single \u003Ccode>fetch\u003C\u002Fcode> or \u003Ccode>axios\u003C\u002Fcode> request, plus real-time delivery webhooks, idempotency support, suppression lists, and analytics. SPF, DKIM, and DMARC are handled for you, so your \u003Cstrong>Node.js transactional email\u003C\u002Fstrong> lands in the inbox instead of spam.\u003C\u002Fp>\n\u003Cp>And because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can fund your account and start sending without a corporate card, lengthy billing setup, or currency friction — ideal for global teams and crypto-native startups.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Get your API key and send your first Node.js email in 10 minutes →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","0b5474da-69d8-48dc-a5b2-e9fa95e49810","2026-06-24T18:28:18.039383+03:00","2026-06-24T18:28:18.039394+03:00","postwing","Send Transactional Emails with Node.js in 10 Minutes","Send Node.js transactional email in 10 minutes. Step-by-step guide to an email API with fetch\u002Faxios, env config, error handling, retries, and webhooks.",null,true,"2026-07-16T09:00:00+03:00",[16,17],"nodejs transactional email","email api nodejs"]