Here is how to send transactional email from Node in 2026 without standing up SMTP servers: use Notify with the official notifycx SDK (or a single fetch call). You pass HTML you already rendered, authenticate with NOTIFY_API_KEY, and get a messageId back. Free tier covers 1,000 emails/month; Pro is $10 for 10,000 with permanent logs and webhooks.
This guide walks through setup, code, production habits, and what to skip.
What “transactional email from Node” means
Transactional messages are triggered by product events: signup verification, password reset, magic link, invoice, shipping update. They are not newsletters. In Node they usually leave from:
- An Express/Fastify/Hono API route
- A Next.js App Router server action or route handler
- A background worker (BullMQ, Queue, cron)
You want an HTTPS email API—not Nodemailer pointed at a random SMTP host—because debugging and observability stay in one place.
Step 1: Create Notify credentials
- Sign up at notify.cx.
- Create an API key in the dashboard.
- Export it locally:
export NOTIFY_API_KEY=your_key_here
In production, put the same value in your host’s secret store (Vercel env, Doppler, AWS SM, etc.). Never commit keys.
Prefer a sending_access key locked to one verified domain inside the app. Use full_access keys for admin tooling only.
Step 2: Verify a sending domain
Production custom from addresses require a verified domain. In the Notify dashboard (or domains API), add your domain and publish the SPF/DKIM/DMARC records shown. Run verification until it passes.
Do this early. DNS propagation is the boring thing that slips past launch checklists.
While waiting, you can still write code against the test send endpoint so payloads and error handling are ready.
Step 3: Install and send with notifycx
npm install notifycx
import Notify from 'notifycx'
const notify = new Notify({ apiKey: process.env.NOTIFY_API_KEY! })
async function main() {
const result = await notify.send({
from: 'noreply@your-verified-domain.com',
to: 'user@example.com',
subject: 'Confirm your email',
html: '<p>Thanks for signing up.</p><p><a href="https://example.com/confirm">Confirm</a></p>',
})
console.log(result)
}
main().catch(console.error)
Run with Node 18+ (or whatever LTS you ship) and TypeScript via tsx / compiled JS as you prefer.
The SDK is the path of least resistance for TypeScript apps: typed client, shared error type (NotifyError), and helpers for logs/webhooks/domains as you grow.
Step 4: Same send with fetch (no SDK)
Useful for tiny scripts or dependency-averse services:
async function sendTransactionalEmail(input: {
to: string
from: string
subject: string
html: string
}) {
const response = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!,
},
body: JSON.stringify(input),
})
const text = await response.text()
if (!response.ok) {
throw new Error(`Notify send failed: ${response.status} ${text}`)
}
return JSON.parse(text) as { success: boolean; data: { messageId: string } }
}
Canonical endpoint: POST https://notify.cx/api/email/send. Auth header is x-api-key, not Bearer. Prefer the html field; message and text aliases exist for compatibility.
Step 5: Wire it into a real handler
Example Express route (sketch):
import express from 'express'
import Notify from 'notifycx'
const app = express()
app.use(express.json())
const notify = new Notify({ apiKey: process.env.NOTIFY_API_KEY! })
app.post('/auth/request-reset', async (req, res) => {
const email = String(req.body.email || '')
// 1) create token in DB
const resetUrl = `https://yourapp.com/reset?token=...`
// 2) send mail
const html = `<p>Reset link (expires soon):</p><p><a href="${resetUrl}">${resetUrl}</a></p>`
try {
const sent = await notify.send({
from: 'security@your-verified-domain.com',
to: email,
subject: 'Reset your password',
html,
})
// 3) persist message id next to the token row
res.json({ ok: true })
} catch (err) {
console.error(err)
res.status(502).json({ ok: false })
}
})
Escape any user-controlled strings before interpolating into HTML.
Step 6: Production habits (do not skip)
Outbox. For money or auth, write an email job in the same DB transaction as the state change, then send from a worker. Retries become explicit.
Idempotency. Password resets should not multiply because a client retried the HTTP API. Key jobs by (userId, purpose, tokenId).
Backoff. Retry transient 5xx/network errors. Do not retry forever on 4xx validation failures—fix from/domain/API key issues.
Store messageId. Join later to Notify logs and webhooks.
Suppression. If an address hard-bounces, stop sending; prompt the user to update email.
Separate marketing. Keep newsletters off the transactional domain and off Notify’s product scope—use a marketing tool if you need campaigns.
Step 7: Logs and webhooks
Once send works, turn on observability:
- Dashboard: notify.cx/dashboard/logs
- API:
GET https://notify.cx/api/email/logsornotify.listLogs({ limit: 20 }) - Webhooks (Pro): subscribe to Bounce and Complaint first; add Delivery/Open/Click as needed
Free log retention is 48 hours; Pro is permanent. For anything past a hobby project, Pro’s $10 / 10k is the practical default.
Webhook handlers should respond fast, process idempotently, and correlate on message_id.
Why Notify for this tutorial (and not the alternatives)
- Nodemailer + SMTP: Extra moving parts; still need a provider.
- SES SDK: Cheap at huge scale; slower and heavier for first transactional send (sandbox, IAM, SNS).
- SendGrid/Mailgun: More product than most Node services need for transactional only.
- Resend: Also fine for developers; Notify’s bet is minimal surface + $10 Pro when you already have HTML.
Notify’s frozen send contract and OpenAPI make the Node integration boring in the good way.
Rendering HTML in Node before you send
Notify expects you to pass html. A few patterns that work well:
Template literals for one-off ops messages (fine for internal alerts; escape carefully for user-facing).
MJML compiled to HTML in a build step or at runtime for responsive layouts.
React Email if your app is already React/Next—render to HTML string on the server, then notify.send.
Whatever you choose, keep templates versioned. Do not treat the email provider as a CMS.
Example with a tiny helper:
export function resetPasswordHtml(resetUrl: string) {
const safe = resetUrl.replace(/&/g, '&').replace(/"/g, '"')
return `<p>Reset your password:</p><p><a href="${safe}">${safe}</a></p>`
}
Then html: resetPasswordHtml(url) in the send call.
Testing without annoying real users
- Use Notify’s test send API while developing handlers—rehearse payload shape without treating it like production delivery.
- Maintain a allowlist of staff inboxes for staging.
- In CI, mock the Notify client; do not call the live API from unit tests.
- Occasionally send a real message to Gmail/Outlook from staging with production-like DNS on a staging subdomain if you have one.
TypeScript project layout that stays maintainable
src/
email/
client.ts // Notify singleton
templates/ // html builders
send.ts // sendX helpers used by features
workers/
emailWorker.ts // outbox consumer
client.ts constructs new Notify({ apiKey: process.env.NOTIFY_API_KEY! }) once. Feature code never sees the raw key or URL. When you add webhooks later, keep verification/handlers under email/webhooks.ts.
Common mistakes
- Sending from unverified domains in production and chasing mysterious rejects.
- Putting API keys in client-side Next.js bundles—send only from the server.
- Building HTML with unescaped user content.
- Fire-and-forget sends without recording success/failure.
- Using the transactional domain for a launch blast to 50k cold addresses.
- Catching send errors and returning 200 to the client anyway—surface failure or queue a retry.
Quick checklist
-
NOTIFY_API_KEYin server env - Domain verified (SPF/DKIM/DMARC)
-
notifycxorfetchwrapper module - HTML templates in repo
-
messageIdpersisted - Outbox for critical flows
- Pro + webhooks when email is operationally important
Closing
To send transactional email from Node: verify a domain on Notify, set NOTIFY_API_KEY, call notify.send (or POST /api/email/send) with html, and persist the messageId. That is the whole core loop—then layer outbox, logs, and webhooks as your product hardens. Start at notify.cx and keep the rest of your stack focused on the app, not on email infrastructure.
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…