Your application works.
Users can sign up. They can log in. Payments go through. Your database is behaving itself for once.
Then someone asks:
"Where is my verification email?"
And suddenly, email has become your problem.
Sending an email from a Node.js application is easy. Sending transactional emails reliably in production is a completely different problem.
You can write a few lines of JavaScript, connect to an SMTP server, and send a message. Congratulations. It works.
Until:
- your SMTP credentials end up in GitHub
- your password reset email takes 45 seconds to arrive
- your emails start landing in spam
- your email provider rate-limits you
- your request handler waits for an external mail server
- an email provider goes down
- your OTP gets sent twice
- your application sends thousands of emails after one unfortunate loop
This guide covers how to build transactional email into a Node.js application properly.
We'll look at SMTP vs email APIs, API-based email delivery, authentication, retries, failure handling, email verification, password resets, OTPs, and production considerations.
We'll also use Senviok as the email API in the examples.
What Is Transactional Email?
Transactional emails are emails triggered by a specific action or event in your application.
They are different from marketing emails.
If you send:
"50% OFF THIS WEEKEND!!!"
that's marketing.
If your application sends:
"Your password was successfully changed."
that's transactional email.
Common transactional emails include:
- Email verification
- Password reset
- One-time passwords (OTPs)
- Login alerts
- Payment receipts
- Order confirmations
- Account notifications
- Invoice notifications
- Subscription updates
- Security alerts
- Welcome emails
- Account activation emails
Basically, transactional email is the part of your application that says:
"Something happened, and the user needs to know."
That makes reliability extremely important.
If a marketing email arrives five minutes late, it is annoying.
If a password reset email arrives five minutes late, the user may already have clicked "Forgot password" three more times and started questioning your entire existence.
SMTP vs Email APIs
There are two common approaches to sending email from a Node.js application.
1. SMTP
SMTP stands for Simple Mail Transfer Protocol.
Your application connects to an SMTP server and sends email through it.
A typical Node.js implementation might use a library such as Nodemailer.
The architecture looks roughly like this:
Node.js application
|
| SMTP
v
SMTP server
|
v
Recipient's mail server
|
v
Inbox
SMTP is mature and widely supported.
But your application now has to deal with SMTP credentials, connections, timeouts, authentication, provider-specific behavior, and potentially more operational complexity.
2. Email API
With an email API, your application makes an HTTPS request.
Node.js application
|
| HTTPS
v
Email API
|
v
Delivery infrastructure
|
v
Inbox
For many modern applications, this is a simpler integration model.
Instead of managing SMTP connections yourself, you send structured HTTP requests.
For example:
POST /v1/emails
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
With a JSON payload:
{
"from": "hello@example.com",
"to": "user@example.com",
"subject": "Welcome",
"html": "<h1>Welcome!</h1>",
"text": "Welcome!"
}
This is the approach we'll use in this tutorial.
What Makes Transactional Email "Production-Ready"?
Before writing code, let's define what production-ready actually means.
A production email integration should consider at least:
- API authentication
- Secret management
- Domain authentication
- Request timeouts
- Error handling
- Retries
- Duplicate sends
- Logging
- Delivery events
- Rate limiting
- Queueing
- Email content
- HTML and plain-text versions
- Bounce handling
- Monitoring
The API call is the easy part.
The boring operational stuff is where production systems are made.
Step 1: Create a Node.js Project
Let's start with a simple Node.js project.
mkdir transactional-email
cd transactional-email
npm init -y
If you're using modern Node.js, you can use the built-in fetch() API rather than installing another HTTP client just to make an HTTP request.
Create:
transactional-email/
├── package.json
├── .env
├── .gitignore
└── index.js
You can configure your project to use ES modules:
{
"type": "module"
}
Step 2: Store Your API Key Securely
Never do this:
const API_KEY = "svk_live_123456789";
Especially not in a Git repository.
Use an environment variable instead.
Create a .env file:
SENVIOK_API_KEY=svk_live_your_api_key
And make sure .env is ignored by Git:
.env
node_modules/
Modern Node.js also provides built-in environment file support, so you can load the environment file when starting your application:
node --env-file=.env index.js
Then access the value with:
const apiKey = process.env.SENVIOK_API_KEY;
Your API key is a credential.
Treat it like a database password, not like a configuration value you are proud to display in a README.
Step 3: Send Your First Transactional Email
Now for the fun part.
Create index.js:
const apiKey = process.env.SENVIOK_API_KEY;
if (!apiKey) {
throw new Error("SENVIOK_API_KEY is not configured");
}
const response = await fetch("https://api.senviok.live/v1/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: "hello@yourdomain.com",
to: "user@example.com",
subject: "Welcome to our application",
html: `
<h1>Welcome!</h1>
<p>Your account has been created successfully.</p>
`,
text: "Welcome! Your account has been created successfully."
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Email request failed: ${response.status} ${error}`
);
}
console.log("Email accepted for delivery");
Run it:
node --env-file=.env index.js
That's the basic integration.
Your Node.js application makes an HTTPS request to the email API, and the email infrastructure takes over from there.
Why Include Both html and text?
You will often see developers send only HTML.
For example:
{
"html": "<h1>Hello</h1>"
}
It works.
But providing a plain-text version is generally a better practice.
{
"html": "<h1>Hello</h1>",
"text": "Hello"
}
The HTML version provides formatting.
The plain-text version gives clients and environments that prefer or require text an alternative representation.
It also gives you a cleaner fallback when HTML isn't appropriate.
Think of it as progressive enhancement for email.
Step 4: Don't Put Email Logic Everywhere
This is where many applications become painful.
You don't want this scattered throughout your codebase:
await fetch("https://api.senviok.live/v1/emails", ...);
inside your registration controller.
Then another copy inside your password reset controller.
Then another inside your payment controller.
Then another inside your OTP service.
Six months later:
sendEmail()
sendEmail()
sendEmail()
sendEmail()
sendEmailButDifferent()
sendVerificationEmail()
sendEmailV2()
Beautiful.
Instead, create a dedicated email service.
For example:
src/
├── services/
│ └── email.js
├── routes/
│ ├── auth.js
│ └── payments.js
└── index.js
Your application code should say:
await emailService.send({
to,
subject,
html,
text
});
It shouldn't care about HTTP headers, API endpoints, authentication, or provider-specific details.
That separation becomes extremely useful later.
A Simple Email Service
For example:
const EMAIL_API_URL = "https://api.senviok.live/v1/emails";
export async function sendEmail({
from,
to,
subject,
html,
text
}) {
const apiKey = process.env.SENVIOK_API_KEY;
if (!apiKey) {
throw new Error("SENVIOK_API_KEY is not configured");
}
const response = await fetch(EMAIL_API_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from,
to,
subject,
html,
text
})
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Email provider returned ${response.status}: ${body}`
);
}
return response;
}
Now your application doesn't need to know how Senviok works internally.
It only knows:
await sendEmail({
from: "hello@yourdomain.com",
to: user.email,
subject: "Welcome",
html: "<h1>Welcome</h1>",
text: "Welcome"
});
That's a much healthier abstraction.
Sending Email Verification Messages
One of the most common transactional email flows is account verification.
A typical flow looks like this:
User signs up
|
v
Create account
|
v
Generate verification token
|
v
Store token
|
v
Send verification email
|
v
User clicks link
|
v
Validate token
|
v
Verify account
The email itself is only one part of the system.
For example, you might generate a random token:
import crypto from "node:crypto";
const token = crypto.randomBytes(32).toString("hex");
Store a hash of the token in your database along with an expiration time.
Then send a URL such as:
https://yourapp.com/verify-email?token=...
Do not store sensitive verification tokens in plaintext if you can avoid it.
A common pattern is:
raw token
|
v
SHA-256
|
v
database
When the user returns with the token, hash the supplied token and compare the hash with the stored value.
Also give verification tokens an expiration time.
A verification link should not live forever.
OTP Emails
Transactional email is also commonly used for one-time passwords.
The architecture is similar:
User requests OTP
|
v
Generate cryptographically secure code
|
v
Store hashed OTP + expiration
|
v
Send email
|
v
User submits OTP
|
v
Verify + expire OTP
For generating an OTP, don't use:
Math.random();
for security-sensitive authentication codes.
Use a cryptographically secure random source instead.
For example:
import crypto from "node:crypto";
const otp = crypto
.randomInt(100000, 1000000)
.toString();
Then store the OTP securely with an expiration time and a maximum number of verification attempts.
And please don't log it.
This:
console.log(`Generated OTP: ${otp}`);
may look harmless during development.
It becomes considerably less funny when your production logs are accessible to someone who shouldn't see authentication credentials.
Password Reset Emails
Password reset flows deserve similar treatment.
A secure flow looks like:
Forgot password
|
v
Generate random reset token
|
v
Store hashed token + expiry
|
v
Send reset email
|
v
User clicks link
|
v
Validate token
|
v
Set new password
|
v
Invalidate token
The email should contain a reset URL, not the user's password.
Never email a user's existing password.
If your system can email someone their current password, that's not a clever feature.
That's a very uncomfortable security conversation waiting to happen.
Don't Send Email Directly From Every Request
Here's a subtle production issue.
Imagine this endpoint:
app.post("/register", async (req, res) => {
const user = await createUser(req.body);
await sendEmail({
to: user.email,
subject: "Verify your account",
html: "..."
});
res.json({
success: true
});
});
This works.
But now your registration request depends directly on the email provider.
If the email service is slow, your API request is slow.
If the email service is unavailable, your registration endpoint might fail.
If the request times out after the provider accepted the message, you can end up with an ambiguous result.
This is where asynchronous processing becomes valuable.
Use a Queue for Important Email
A more resilient architecture looks like this:
┌──────────────┐
│ Node.js │
│ API │
└──────┬───────┘
|
v
┌──────────────┐
│ Queue │
└──────┬───────┘
|
v
┌──────────────┐
│ Email Worker │
└──────┬───────┘
|
v
┌──────────────┐
│ Email API │
└──────┬───────┘
|
v
Inbox
Your API accepts the business event.
A worker processes the email job.
This gives you much more control over:
- retries
- backoff
- concurrency
- rate limiting
- failures
- monitoring
- provider outages
For Node.js applications, queues can be implemented using technologies such as Redis-backed job queues, RabbitMQ, Amazon SQS, or other message brokers depending on your architecture.
The important idea isn't the particular queue.
It's separating user-facing application requests from background delivery work.
Retries Are Not as Simple as They Look
Suppose the email API returns:
503 Service Unavailable
Retrying may make sense.
But blindly retrying every error is dangerous.
For example:
400 Bad Request
probably isn't fixed by sending the exact same invalid request five more times.
A basic classification might look like:
2xx
success
4xx
usually a request/authentication/configuration problem
5xx
potentially transient provider/server failure
But production retry logic should use the actual API's documented semantics and distinguish retryable from non-retryable errors.
A retry strategy might use exponential backoff:
Attempt 1: immediately
Attempt 2: 1 second later
Attempt 3: 2 seconds later
Attempt 4: 4 seconds later
Attempt 5: 8 seconds later
Usually with a maximum delay and a maximum number of attempts.
And here's another important problem.
Retries can create duplicates
Imagine your application sends an email.
The provider accepts it.
Then your HTTP connection dies before your application receives the response.
Your application sees:
"Request failed"
So it retries.
Now the user might receive the same email twice.
This is why reliable messaging systems care about idempotency and message identity.
For critical workflows, design your email jobs so that retries don't accidentally create uncontrolled duplicate messages.
Email Deliverability Is Not Just an API Problem
You can have perfect JavaScript and still have terrible email delivery.
Why?
Because inbox providers care about authentication, reputation, sending behavior, content, recipient engagement, and other signals.
At minimum, your sending domain should be properly authenticated.
Three terms you'll encounter constantly are:
SPF
Sender Policy Framework helps specify which systems are authorized to send email for a domain.
DKIM
DomainKeys Identified Mail adds a cryptographic signature to outgoing email so receiving systems can verify that the message was authorized by the domain.
DMARC
Domain-based Message Authentication, Reporting, and Conformance builds on domain authentication mechanisms and lets domain owners publish policies for handling authentication failures.
You should understand these concepts even if your email provider handles most of the DNS configuration for you.
Because when an email mysteriously disappears into the internet, "but my API returned 200" isn't going to make your customer feel better.
Use a Custom Sending Domain
Don't build your production application around something like:
yourapp@gmail.com
Use a domain you control.
For example:
hello@example.com
notifications@example.com
security@example.com
Then configure the required DNS records with your email provider.
With Senviok, you can verify a custom sending domain and configure the necessary email authentication records through the dashboard.
This matters for both trust and deliverability.
Monitoring Transactional Email
If your application sends important emails, you need visibility into what is happening.
At minimum, track:
emails_requested
emails_accepted
emails_failed
emails_delivered
emails_bounced
emails_complained
You may also want:
delivery_latency
provider_errors
retry_count
queue_depth
failure_rate
For example:
Email delivery
Requested: 12,421
Accepted: 12,380
Failed: 41
Delivered: 12,201
Bounced: 179
Now you have something you can actually investigate.
Without metrics, "users aren't receiving emails" is a mystery.
With metrics, it becomes an engineering problem.
And engineering problems are much nicer when they have numbers attached.
Webhooks Matter Too
Sending email is only half the story.
You also want to know what happened after the request was accepted.
Many email platforms provide events such as:
accepted
delivered
bounced
complained
failed
Your application can consume these events through webhooks.
For example:
Email API
|
| delivery event
v
Your webhook
|
v
Update database
|
v
Application state
You could use this to update a user's notification status:
{
"messageId": "msg_123",
"status": "delivered"
}
Webhooks should be authenticated and treated as untrusted input until verified.
Also make webhook handlers idempotent.
Webhook providers can retry event delivery, so your endpoint should be able to safely process the same event more than once.
Security Checklist for Node.js Email APIs
Before shipping your integration, check these.
API keys
Never commit them.
.env
Use environment variables or your deployment platform's secret manager.
HTTPS
Your application should communicate with the email API over HTTPS.
Authentication
Use the API's supported authentication mechanism correctly.
Input validation
Don't blindly accept arbitrary from, to, HTML, or template data from untrusted users.
HTML injection
Be careful when inserting user-controlled values into HTML email templates.
This:
html: `<h1>Hello ${username}</h1>`
is not automatically safe if username can contain arbitrary HTML.
Escape or sanitize values appropriately.
Rate limiting
An endpoint like:
POST /send-otp
should not be allowed to send unlimited emails.
Otherwise someone can abuse your infrastructure or spam a victim's inbox.
Logging
Log useful metadata.
Don't log:
- API keys
- passwords
- raw OTPs
- reset tokens
- sensitive personal data
Secrets rotation
Have a process for replacing compromised credentials.
Building a Reusable Transactional Email Function
At this point, you can make the integration cleaner by creating a reusable function.
export async function sendTransactionalEmail({
to,
subject,
html,
text
}) {
const response = await fetch(
"https://api.senviok.live/v1/emails",
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SENVIOK_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: "notifications@yourdomain.com",
to,
subject,
html,
text
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(
`Transactional email failed: ${response.status} ${error}`
);
}
return response;
}
Then your application becomes much cleaner.
For example:
await sendTransactionalEmail({
to: user.email,
subject: "Verify your email address",
html: `
<h1>Verify your email</h1>
<p>
Click the link below to verify your account.
</p>
<a href="${verificationUrl}">
Verify email
</a>
`,
text: `
Verify your email:
${verificationUrl}
`
});
Your business logic knows that it needs to send a verification email.
It doesn't need to know the details of the delivery infrastructure.
That's exactly what an abstraction should do.
Why Use an Email API Instead of Building Your Own Mail Server?
This question comes up surprisingly often.
And technically, yes, you can build your own email infrastructure.
You can run mail servers.
You can configure DNS.
You can manage queues.
You can implement retries.
You can monitor delivery.
You can deal with bounces.
You can work on reputation.
You can investigate why Gmail accepted something yesterday and decided to send it into spam today.
You can do all of this.
The question is whether you should.
For most application developers, email delivery is infrastructure they depend on, not the infrastructure they want to spend six months becoming experts in.
An email API gives you an HTTP interface to that infrastructure.
Your application focuses on:
business logic
while the email platform focuses on:
email delivery infrastructure
That's the trade-off.
Sending Transactional Email with Senviok
Senviok provides a REST API for sending transactional email from Node.js and other environments.
The basic request looks like:
curl -X POST https://api.senviok.live/v1/emails \
-H "Authorization: Bearer svk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": "user@example.com",
"subject": "Payment Confirmed",
"html": "<h1>Your payment was successful!</h1>",
"text": "Your payment was successful!"
}'
The same API can be called directly from Node.js using fetch().
The important part is that your application is communicating with the email service over a standard HTTP API.
That means you aren't locked into a particular Node.js framework.
Express works.
Fastify works.
NestJS works.
Next.js server-side code works.
A plain Node.js application works.
The API doesn't really care.
A Better Production Architecture
For a serious application, I'd eventually structure the system something like this:
┌───────────────┐
│ Web / App │
└───────┬───────┘
|
v
┌───────────────┐
│ Node.js API │
└───────┬───────┘
|
┌───────────────┴──────────────┐
| |
v v
┌─────────────┐ ┌─────────────┐
│ Database │ │ Queue │
└─────────────┘ └──────┬──────┘
|
v
┌─────────────┐
│Email Worker │
└──────┬──────┘
|
v
┌─────────────┐
│ Senviok │
│ Email API │
└──────┬──────┘
|
v
Recipient
And then:
Senviok
|
| webhook
v
Your webhook endpoint
|
v
Database / event system
|
v
Application
This architecture separates the application from the delivery process.
If the email provider has a temporary problem, your application doesn't necessarily need to become unavailable.
The queue can hold the work.
The worker can retry.
Your users can keep using the application.
That's the difference between:
"We send emails."
and:
"We operate a transactional messaging system."
Common Mistakes When Sending Email from Node.js
1. Hardcoding API keys
Don't.
const key = "super-secret-production-key";
Your future self will not thank you.
Neither will your security team.
2. Sending email synchronously for every request
Don't make every user-facing API request depend directly on an external email provider if the email doesn't need to be completed before responding.
Use background processing when appropriate.
3. No retry strategy
Transient failures happen.
Build controlled retries for operations where retries are appropriate.
4. Retrying everything
A 400 error isn't usually fixed by throwing the same request at the server six more times.
Classify failures.
5. No rate limiting
OTP and password reset endpoints are especially sensitive.
Rate-limit them.
6. Ignoring deliverability
An API returning 200 OK means your HTTP request was accepted.
It does not magically guarantee that a human will see the message in their inbox.
7. Only sending HTML
Provide a plain-text alternative where appropriate.
8. Logging secrets
Don't log credentials, OTPs, reset tokens, or sensitive payloads.
9. Building your own mail server because "how hard can it be?"
Famous last words.
If email infrastructure is your actual product, that's one thing.
If you're building a fintech application, SaaS product, marketplace, API, or mobile application and your real business is somewhere else, consider whether maintaining mail infrastructure is actually worth your engineering time.
Final Thoughts
Sending an email from Node.js takes a few lines of code.
Building reliable transactional email infrastructure does not.
The difference matters.
For a small application, a straightforward HTTP request to an email API may be enough.
As your application grows, you should start thinking about:
- queues
- retries
- idempotency
- rate limiting
- domain authentication
- delivery events
- webhooks
- monitoring
- bounce handling
- secret management
- observability
The goal isn't to make email complicated.
The goal is to make sure email doesn't become the thing that breaks your application at 2:00 AM.
With an email API such as Senviok, the basic integration can stay simple:
Your application
|
| HTTPS
v
Senviok API
|
v
Email delivery
|
v
User inbox
Then, as your system grows, you can add the engineering layers that actually matter for your workload.
Because transactional email isn't really about sending messages.
It's about reliably delivering important events to people.
And when the message is:
"Your payment was successful."
or:
"Your verification code is 482913."
or:
"Your password has been changed."
"Hopefully it arrives" is not a production strategy.
Start Sending Transactional Emails
If you're building a Node.js application and need an email API, you can get started with Senviok.
You can use the REST API directly, integrate it into your existing Node.js backend, or build your own email service abstraction around it.
The free plan gives developers room to build and test before committing to a paid plan.
Build the application. Let the email infrastructure handle the email.
Top comments (0)