← Back to Blog
Developer Guides13 May 2026 · NexusProMail Team

How to Send Transactional Email with Node.js

A practical guide to sending transactional emails from a Node.js application using the NexusProMail REST API — including authentication, error handling, webhook setup and environment configuration.

Transactional emails — password resets, order confirmations, welcome messages, account alerts — need to reach the inbox reliably. This guide shows how to integrate the NexusProMail REST API into a Node.js application with production-ready patterns: environment-based configuration, structured error handling, retry logic and webhook verification.

Prerequisites

  • Node.js 18 or later
  • A NexusProMail account (free — sandbox key available immediately on signup)
  • A verified sending domain for production sends (DKIM + SPF)

Step 1 — Install Dependencies

The NexusProMail API is a standard REST API. You can use the native fetch API (Node.js 18+) or node-fetch for earlier versions. No proprietary SDK is required.

npm install dotenv
# node-fetch only if using Node.js < 18
npm install node-fetch

Step 2 — Configure Environment Variables

Never hardcode API keys. Store them in environment variables:

# .env (add to .gitignore — never commit this file)
NEXUSPROMAIL_API_KEY=your_api_key_here
NEXUSPROMAIL_API_URL=https://app.nexuspromail.com/api
FROM_EMAIL=hello@mail.yourdomain.com
FROM_NAME=Your App

Step 3 — Create a Send Helper

Create a reusable function that handles the API call, error classification and logging:

// lib/email.js
require('dotenv').config()

const API_URL = process.env.NEXUSPROMAIL_API_URL
const API_KEY = process.env.NEXUSPROMAIL_API_KEY

/**
 * Send a single transactional email.
 * Returns { success: true, messageId } or { success: false, error, code }
 */
async function sendTransactional({ to, subject, html, text, replyTo }) {
  if (!API_KEY) throw new Error('NEXUSPROMAIL_API_KEY environment variable not set')

  const payload = {
    to: typeof to === 'string' ? { email: to } : to,
    from: { email: process.env.FROM_EMAIL, name: process.env.FROM_NAME },
    subject,
    html,
    ...(text && { text }),
    ...(replyTo && { reply_to: { email: replyTo } }),
  }

  const response = await fetch(`${API_URL}/v1/transactional/send`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })

  const body = await response.json()

  if (response.ok) {
    return { success: true, messageId: body.message_id }
  }

  // Classify the error
  if (response.status === 422 && body.error === 'suppressed') {
    return { success: false, code: 'SUPPRESSED', error: 'Address is suppressed' }
  }
  if (response.status === 422) {
    return { success: false, code: 'VALIDATION', error: body.message }
  }
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After') || '60'
    return { success: false, code: 'RATE_LIMITED', retryAfter: parseInt(retryAfter, 10) }
  }

  return { success: false, code: 'API_ERROR', error: body.message, status: response.status }
}

module.exports = { sendTransactional }

Step 4 — Send a Password Reset Email

// Example: password reset flow
const { sendTransactional } = require('./lib/email')

async function sendPasswordReset(userEmail, resetToken) {
  const resetUrl = `https://yourapp.com/reset?token=${resetToken}`

  const result = await sendTransactional({
    to: userEmail,
    subject: 'Reset your password',
    html: `
      

You requested a password reset.

Click here to reset your password

This link expires in 1 hour. If you did not request this, you can safely ignore this email.

`, text: `Reset your password: ${resetUrl} This link expires in 1 hour.`, }) if (!result.success) { if (result.code === 'SUPPRESSED') { // Address is suppressed — user unsubscribed or complained // Log the event; do not send. This is expected behaviour. console.log(`Password reset skipped — address suppressed: ${userEmail}`) return } if (result.code === 'RATE_LIMITED') { // Queue for retry after result.retryAfter seconds throw new Error(`Rate limited — retry after ${result.retryAfter}s`) } throw new Error(`Failed to send password reset: ${result.error}`) } console.log(`Password reset sent. Message ID: ${result.messageId}`) }

Step 5 — Add Retry Logic for Transient Failures

Wrap your send function with retry logic for 429 and 500 responses:

async function sendWithRetry(params, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const result = await sendTransactional(params)

    if (result.success || result.code === 'SUPPRESSED' || result.code === 'VALIDATION') {
      return result // Terminal outcomes — do not retry
    }

    if (result.code === 'RATE_LIMITED') {
      const delay = (result.retryAfter || 30) * 1000 * attempt
      console.log(`Rate limited. Waiting ${delay}ms before retry ${attempt}/${maxAttempts}`)
      await new Promise(r => setTimeout(r, delay))
      continue
    }

    // API_ERROR — exponential backoff
    const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000)
    console.log(`API error. Waiting ${delay}ms before retry ${attempt}/${maxAttempts}`)
    await new Promise(r => setTimeout(r, delay))
  }

  throw new Error('Max retry attempts exceeded')
}

Step 6 — Handle Delivery Webhooks

Register a webhook endpoint to receive delivery events — bounces, complaints and opens. Always verify the signature before processing.

// routes/webhooks.js (Express)
const crypto = require('crypto')
const express = require('express')
const router = express.Router()

function verifySignature(rawBody, signature) {
  const secret = process.env.WEBHOOK_SECRET
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  try {
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature, 'hex'))
  } catch {
    return false
  }
}

router.post('/email', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-nexuspromail-signature']
  if (!signature || !verifySignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  const event = JSON.parse(req.body)

  switch (event.type) {
    case 'bounced.hard':
      // Suppress in your own database immediately
      await db.users.update({ email: event.data.email }, { emailBounced: true })
      break
    case 'complained':
      // Suppress and mark — do not contact again
      await db.users.update({ email: event.data.email }, { complained: true })
      break
    case 'unsubscribed':
      await db.users.update({ email: event.data.email }, { marketingOptOut: true })
      break
  }

  res.sendStatus(200) // Always return 200 promptly to acknowledge receipt
})

module.exports = router

Testing

Use your sandbox API key for development and CI. Sandbox sends do not require domain verification and do not deliver real email. Flip to your production key (and a verified sending domain) when you go live. Both environments use identical API endpoints and response formats.

Further reading

Related reading

Email deliverability guideGDPR complianceTransactional email API

Start sending with NexusProMail

Launch email campaigns and transactional emails from one platform.

Start FreeView Pricing

Free plan · No credit card required · GDPR-compliant · Built in Finland

How to Send Transactional Email with Node.js | NexusProMail