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
- Node 18+ (built-in
fetch) or Node 16 with a fetch polyfill - Express 4+
- Notify API key
- Verified domain for production
from
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
- Start with
sandbox: trueso you can iterate on HTML without waiting on DNS - Hit
/api/send-welcomewith yourINTERNAL_API_TOKEN - Confirm the JSON response from Notify (even sandbox returns a structured body)
- Flip to production send after domain verification shows green SPF/DKIM
- 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
- Verify your domain and flip
sandboxoff - Reuse
sendEmailfor receipts and magic links (magic link guide) - Add webhooks when bounce handling matters (Pro/Scale)
- Compare providers if you are migrating: compare
- 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
Sign in
Google or GitHub
Complete profile
Takes a few minutes
Get approved & publish
Start sharing
Why write for Cubed?
The future deserves thoughtful voices, not just louder headlines.

Comments
Loading comments…