Send Email from Express / Node with Notify

Express is still the default “small Node API” for a lot of SaaS backends. Transactional email fits that shape perfectly: keep secrets on the server,…

3 min readJordan Hale

Express is still the default “small Node API” for a lot of SaaS backends. Transactional email fits that shape perfectly: keep secrets on the server, expose authenticated routes, and fire one HTTPS request when a product event happens.

This guide uses Notify — a minimal transactional email API. Endpoint: POST https://notify.cx/api/email/send. Auth header: x-api-key. Body: to, from, subject, message (plain text or HTML). No newsletter suite, no template studio — you own the HTML.

Official stack guide: How to use Notify with Express.

Prerequisites

Plans for context: Free 1,000 emails/mo, Pro $10 / 10,000, Scale $50 / 100,000 — pricing.

npm init -y
npm install express dotenv
# optional if you prefer CommonJS-free ESM: "type": "module" in package.json
# .env
NOTIFY_API_KEY=your_api_key_here
PORT=3000
APP_URL=http://localhost:3000
// load env before anything else
import 'dotenv/config';

Project layout

.
├── .env
├── package.json
├── lib/
│   └── notify.js
└── server.js

Helper module

// lib/notify.js
const NOTIFY_URL = 'https://notify.cx/api/email/send';
const NOTIFY_TEST_URL = 'https://notify.cx/api/email/send/test';

export async function sendEmail({
  to,
  subject,
  message,
  from = 'noreply@your-verified-domain.com',
  sandbox = false
}) {
  const apiKey = process.env.NOTIFY_API_KEY;
  if (!apiKey) {
    throw new Error('NOTIFY_API_KEY is not set');
  }

  const response = await fetch(sandbox ? NOTIFY_TEST_URL : NOTIFY_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': apiKey
    },
    body: JSON.stringify({ from, to, subject, message })
  });

  if (!response.ok) {
    throw new Error(`Notify error: ${await response.text()}`);
  }

  return response.json();
}

Use sandbox: true while DNS is pending — same body shape, non-delivering rehearsal. Details: sandbox vs production.

Complete Express app (welcome email)

// server.js
import express from 'express';
import { sendEmail } from './lib/notify.js';

const app = express();
app.use(express.json());

/**
 * Demo auth stub — replace with session/JWT middleware.
 * Returning true means “caller may trigger this transactional email.”
 */
function requireAuth(req, res, next) {
  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
  if (!token || token !== process.env.INTERNAL_API_TOKEN) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
}

app.post('/api/send-welcome', requireAuth, async (req, res) => {
  const to = typeof req.body.to === 'string' ? req.body.to.trim() : '';
  const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';

  if (!to) {
    return res.status(400).json({ error: 'to is required' });
  }

  try {
    const data = await sendEmail({
      to,
      subject: 'Welcome',
      message: `
        <h1>Welcome${name ? `, ${name}` : ''}</h1>
        <p>Thanks for joining.</p>
        <p><a href="${process.env.APP_URL}/dashboard">Open your dashboard</a></p>
      `
      // sandbox: true  // uncomment until domain verification is complete
    });
    res.json({ ok: true, data });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to send email' });
  }
});

app.listen(process.env.PORT || 3000, () => {
  console.log(`Listening on ${process.env.PORT || 3000}`);
});
# .env — add a shared secret for the demo gate
INTERNAL_API_TOKEN=change-me-in-production

node server.js

curl -X POST http://localhost:3000/api/send-welcome \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer change-me-in-production" \
  -d '{"to":"you@example.com","name":"Ada"}'

Password reset pattern (sketch with stubs)

Generate a random token, store a hash + expiry, email the raw link, always return a generic success body. Full walkthrough: password reset guide.

import { createHash, randomBytes } from 'crypto';
import { sendEmail } from './lib/notify.js';

// Implement against your DB:
// findUserByEmail(email) → { id, email } | null
// saveResetToken({ userId, tokenHash, expiresAt })
async function findUserByEmail(email) {
  throw new Error('Implement findUserByEmail');
}
async function saveResetToken(row) {
  throw new Error('Implement saveResetToken');
}

app.post('/api/forgot-password', async (req, res) => {
  const email = String(req.body.email || '').trim().toLowerCase();
  // Always return the same shape (no user enumeration)
  const generic = {
    ok: true,
    message: 'If that email exists, we sent a reset link.'
  };

  if (!email) return res.status(400).json({ error: 'email required' });

  // Rate-limit by IP + email in production (Redis / express-rate-limit).
  // Example policy: 5 requests per 15 minutes per key.

  const user = await findUserByEmail(email).catch(() => null);
  if (user) {
    const token = randomBytes(32).toString('hex');
    const tokenHash = createHash('sha256').update(token).digest('hex');
    await saveResetToken({
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 60 * 60 * 1000)
    });

    await sendEmail({
      to: email,
      subject: 'Reset your password',
      message: `
        <p><a href="${process.env.APP_URL}/reset-password?token=${token}">
          Choose a new password
        </a></p>
        <p>Expires in one hour.</p>
      `
    });
  }

  return res.json(generic);
});

Common pitfalls

  • Unauthenticated open relay — never accept arbitrary to / HTML from the public internet
  • Calling Notify from the frontend — leaks NOTIFY_API_KEY
  • Unverified from — finish domain verification before production traffic
  • Ignoring non-OK responses — log Notify error bodies; they usually say what failed
  • Marketing on the transactional domain — keep campaigns elsewhere

Testing locally

  1. Start with sandbox: true so you can iterate on HTML without waiting on DNS
  2. Hit /api/send-welcome with your INTERNAL_API_TOKEN
  3. Confirm the JSON response from Notify (even sandbox returns a structured body)
  4. Flip to production send after domain verification shows green SPF/DKIM
  5. Send one real message to yourself and check email logs

For CI, keep the key in encrypted secrets and never print it in logs. Prefer a dedicated staging Notify account or the sandbox endpoint so test suites cannot burn production reputation.

Observability

At low volume, dashboard logs are enough. When password resets and receipts matter to support:

  • Log your own correlation IDs alongside Notify responses
  • On Pro/Scale, subscribe to webhooks for bounces and complaints
  • Suppress hard-bounced addresses in your user table before the next send

Notify Free retains logs for 48 hours; paid plans keep them permanently — see pricing.

Next steps

  1. Verify your domain and flip sandbox off
  2. Reuse sendEmail for receipts and magic links (magic link guide)
  3. Add webhooks when bounce handling matters (Pro/Scale)
  4. Compare providers if you are migrating: compare
  5. Skim the quick start if a teammate is new to Notify

Resources

More in web-development

Cubed

Write about the technologies shaping the future.

For developers, founders, and curious minds exploring AI, crypto, Web3, and emerging tech—signal over noise.

One free account across In Plain English, Stackademic, Venture, and Cubed.

How it works
  • AI, crypto & Web3
  • Software & emerging technologies
  • Analysis & practical resources
  • Thoughtful voices, not hype
1

Sign in

Google or GitHub

2

Complete profile

Takes a few minutes

3

Get approved & publish

Start sharing

Why write for Cubed?

The future deserves thoughtful voices, not just louder headlines.

Comments

Loading comments…

Posts Across the Network