Every business needs to send transactional emails at some point.
Transactional emails are the very foundation of an online communication framework that works towards users from a business.
What are transactional emails?
Well, in simple terms, Transactional emails are just some automated system generated emails which are sent in response to user actions, such as account verification, password resets, order confirmations, and other critical online communications.
If compared to marketing emails, transactional emails have higher discoverability requirements and often contain urgently needed information.
AutoSend is a great email platform I have recently discovered which is so easy to setup for sending transactional emails and I found it really amazing for a relatively new email platform to have so nice of a developer experience including polished docs, helpful guides, and an friendly community of encouraging developers.
I know you say it compares to resend and mailgun and you have complaints why it does not have a free tier. But you need to understand that it automates and abstracts away lot of tedius email domain configurations and provides you a easy headstart with a very intuitive web api. And, most importantly the hobby tier (lowest paid tier) starts from just $1/month, which is affordable for almost everybody with you being able to send 3000 emails/month (no max limit for daily emails).
In this guide, we will go through how to implement transactional email functionality in a Node.js application using Convex as the backend database and AutoSend API for email delivery. We'll use a complete email OTP (One-Time Password) authentication system as our practical example.
Key Tools and Technologies We'll Be Using
Node.js
Node.js will be the runtime environment for our server-side application. It will handle all HTTP requests and coordinate between different services.
Convex
Convex is a really good BaaS(Backend-as-a-Service) platform. It provides:
- Real-time database with auto-sync,
- Serverless functions (queries, mutations, actions),
- Built-in authentication,
- Type-safe API generation, etc.
AutoSend API
AutoSend is an emerging dev freindly REST API based email service that offers:
- High deliverability rates,
- Dedicated IPs,
- Both transactional and marketing email services,
- Template support with dynamic data (handlebars syntax),
- Delivery tracking and analytics,
- Compliance with email regulations, etc.
Demo Project Setup
Prerequisites
Before we begin, ensure you have:
- Node.js (v20 or higher, v22 LTS recommended),
- A Convex account and project,
- An active AutoSend billing account and an API key
Project Structure
otp-demo/
├── convex/
│ ├── _generated/
│ ├── otp.js
│ ├── schema.js
│ ├── sessions.js
│ └── users.js
├── public/
│ └── index.html
├── server.js
├── package.json
└── .env
Dependencies
Install the required packages:
npm install express cors body-parser dotenv convex node-fetch
Brief Overview
Before jumping to code you can refer to this full sequence diagram for a quick look on what the complete workflow will be like -
Database Schema Design
First, we need to define our database schema in convex/schema.js:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
email: v.string(),
verified: v.boolean(),
createdAt: v.number(),
}).index("by_email", ["email"]),
otpTokens: defineTable({
email: v.string(),
otp: v.string(),
expiresAt: v.number(),
attempts: v.number(),
createdAt: v.number(),
}).index("by_email", ["email"]),
sessions: defineTable({
userId: v.id("users"),
token: v.string(),
expiresAt: v.number(),
createdAt: v.number(),
}).index("by_token", ["token"]),
});
This schema supports:
- users: Store user information and verification status.
- otpTokens: Temporary OTP storage with expiration and attempt tracking.
- sessions: Session management for authenticated users.
After writing the file, you must run npx convex dev for uploading the database schema into your convex development server.
Managing OTP Tokens Using Convex Functions
Convex functions are 3 types - queries, mutations and actions.
Queries and Mutations are both transactional while actions are not.
Queries are used to read data from convex database while mutations are used to write data to convex database. Queries are subscribable.
Actions are used for making third party requests like making a fetch API call. Such as http-actions can be used to make an openai api call in your web app, etc.
Create a convex/otp.js for all OTP-related operations:
ANything you perform in a convex backend is done by creating and using convex functions.
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
// Store OTP token
export const createOTP = mutation({
args: {
email: v.string(),
otp: v.string(),
expiresAt: v.number(),
},
handler: async (ctx, args) => {
// Delete any existing OTP for this email
const existing = await ctx.db
.query("otpTokens")
.withIndex("by_email", (q) => q.eq("email", args.email))
.collect();
for (const token of existing) {
await ctx.db.delete(token._id);
}
// Create new OTP
const otpId = await ctx.db.insert("otpTokens", {
email: args.email,
otp: args.otp,
expiresAt: args.expiresAt,
attempts: 0,
createdAt: Date.now(),
});
// Return the stored OTP data including the actual expiresAt
const storedOtp = await ctx.db.get(otpId);
return {
id: otpId,
expiresAt: storedOtp.expiresAt,
};
},
});
// Verify OTP
export const verifyOTP = mutation({
args: {
email: v.string(),
otp: v.string(),
},
handler: async (ctx, args) => {
const token = await ctx.db
.query("otpTokens")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (!token) {
return { success: false, error: "No OTP found" };
}
// Check if expired (with 30 second buffer for timing issues)
const currentTime = Date.now();
if (currentTime > token.expiresAt + 30000) { // 30 second grace period
await ctx.db.delete(token._id);
return { success: false, error: "OTP expired" };
}
// Check attempts
if (token.attempts >= 5) {
await ctx.db.delete(token._id);
return { success: false, error: "Too many attempts" };
}
// Verify OTP
if (token.otp !== args.otp) {
await ctx.db.patch(token._id, { attempts: token.attempts + 1 });
return { success: false, error: "Invalid OTP" };
}
// Success - delete token
await ctx.db.delete(token._id);
return { success: true };
},
});
Create a convex/users.js file for user operations:
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
// Create or get user
export const createUser = mutation({
args: { email: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (existing) {
return existing._id;
}
return await ctx.db.insert("users", {
email: args.email,
verified: false,
createdAt: Date.now(),
});
},
});
// Mark user as verified
export const verifyUser = mutation({
args: { email: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (!user) {
throw new Error("User not found");
}
await ctx.db.patch(user._id, { verified: true });
return user;
},
});
// Get user by email
export const getUserByEmail = query({
args: { email: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
},
});
Session Management
Create convex/sessions.js for managing user sessions:
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
function generateSessionToken() {
return crypto.randomUUID();
}
export const createSession = mutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
// Delete any existing sessions for this user
const existing = await ctx.db
.query("sessions")
.filter((q) => q.eq(q.field("userId"), args.userId))
.collect();
for (const session of existing) {
await ctx.db.delete(session._id);
}
// Create new session (24 hours)
const token = generateSessionToken();
const expiresAt = Date.now() + 24 * 60 * 60 * 1000;
const sessionId = await ctx.db.insert("sessions", {
userId: args.userId,
token,
expiresAt,
createdAt: Date.now(),
});
return { token, expiresAt };
},
});
export const validateSession = query({
args: { token: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("by_token", (q) => q.eq("token", args.token))
.first();
if (!session) {
return null;
}
// Check if expired
if (Date.now() > session.expiresAt) {
// Clean up expired session
await ctx.db.delete(session._id);
return null;
}
const user = await ctx.db.get(session.userId);
if (!user || !user.verified) {
return null;
}
return {
userId: user._id,
email: user.email,
verified: user.verified,
createdAt: user.createdAt,
};
},
});
// Delete a session (logout)
export const deleteSession = mutation({
args: { token: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("by_token", (q) => q.eq("token", args.token))
.first();
if (session) {
await ctx.db.delete(session._id);
}
},
});
Now run npx convex dev again to upload the convex functions to your convex project cloud.
Email Service Implementation
AutoSend API Integration
Create the email sending functionality in server.js:
We are strictly enforcing IPv4 DNS order because the convex server does not work well with IPv6 because it faces synchronization issues due to IPv6 timeouts.
import { setDefaultResultOrder } from 'dns';
// Force IPv4 first to avoid IPv6 timeout issues
setDefaultResultOrder('ipv4first');
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import dotenv from "dotenv";
import https from 'https';
import http from 'http';
import fetch from 'node-fetch';
// Load environment variables FIRST
dotenv.config();
// Send email via AutoSend API
async function sendOTPEmail(email, otp) {
try {
const response = await nodefetch("https://api.autosend.com/v1/mails/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.AUTOSEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: {
email: process.env.AUTOSEND_FROM_EMAIL,
name: process.env.AUTOSEND_FROM_NAME,
},
to: {
email: email,
},
subject: "Your Verification Code",
html: `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2>Email Verification</h2>
<p>Your verification code is:</p>
<h1 style="color: #4F46E5; font-size: 32px; letter-spacing: 5px;">${otp}</h1>
<p>This code will expire in 10 minutes.</p>
<p style="color: #666; font-size: 12px;">If you didn't request this code, please ignore this email.</p>
</div>
`,
text: `Your verification code is: ${otp}. This code will expire in 10 minutes.`,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to send email");
}
return await response.json();
} catch (error) {
console.error("Error sending email:", error);
throw error;
}
}
API Endpoints
Okay, time to Implement the OTP request endpoint. Paste the following code in the same server.js file:
// Generate 6-digit OTP
function generateOTP() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
// Request OTP
app.post("/api/request-otp", async (req, res) => {
try {
const { email } = req.body;
if (!email || !email.includes("@")) {
return res.status(400).json({ error: "Valid email is required" });
}
console.log("Requesting OTP for:", email);
// Generate OTP
const otp = generateOTP();
const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes
console.log("Generated OTP:", otp);
// Create or get user
console.log("Creating user in Convex...");
await convex.mutation(api.users.createUser, { email });
console.log("User created/found");
// Store OTP in Convex
console.log("Storing OTP in Convex...");
const otpResult = await convex.mutation(api.otp.createOTP, {
email,
otp,
expiresAt,
});
console.log("OTP stored successfully");
// Send email
console.log("Sending email...");
await sendOTPEmail(email, otp);
console.log("Email sent successfully");
res.json({
success: true,
message: "OTP sent successfully",
expiresAt: otpResult.expiresAt, // Use the expiresAt from Convex for consistency
});
} catch (error) {
console.error("Error requesting OTP:", error);
res.status(500).json({
error: "Failed to send OTP",
details: error.message,
});
}
});
// Verify OTP
app.post("/api/verify-otp", async (req, res) => {
try {
const { email, otp } = req.body;
if (!email || !otp) {
return res.status(400).json({ error: "Email and OTP are required" });
}
console.log("Verifying OTP for:", email);
// Verify OTP using Convex
const result = await convex.mutation(api.otp.verifyOTP, {
email,
otp,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: result.error,
});
}
// Mark user as verified
const user = await convex.mutation(api.users.verifyUser, { email });
// Create session
const session = await convex.mutation(api.sessions.createSession, { userId: user._id });
res.json({
success: true,
message: "Email verified successfully",
sessionToken: session.token,
});
} catch (error) {
console.error("Error verifying OTP:", error);
res.status(500).json({
error: "Failed to verify OTP",
details: error.message,
});
}
});
Building the Frontend
The core logical part is in the backend. So I didn't bother building the frontend all by myself. Hehe. :)
I just prompted to claude and vibe coded a working frontend for the demo.
You can create something like that too, just prompt your cursor or any AI coding tool you use. Here is the responsive UI in my public/index.html (in case you would like to copy paste):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email OTP Authentication</title>
<style>
/* CSS styles for the authentication forms */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
}
button:hover {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-weight: 500;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.hidden {
display: none;
}
.otp-input {
text-align: center;
font-size: 18px;
letter-spacing: 8px;
}
.timer {
text-align: center;
margin: 20px 0;
font-size: 14px;
color: #666;
}
.resend-link {
text-align: center;
margin-top: 20px;
}
.resend-link a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.resend-link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<!-- Email Form -->
<div id="emailForm">
<h1>🔐 Email Verification</h1>
<p>Enter your email to receive a verification code</p>
<div id="emailMessage" class="message hidden"></div>
<form id="requestOtpForm">
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
placeholder="your@email.com"
required
>
</div>
<button type="submit" id="sendOtpBtn">Send Code</button>
</form>
</div>
<!-- OTP Form -->
<div id="otpForm" class="hidden">
<h1>✉️ Verify Code</h1>
<p>Enter the 6-digit code sent to <strong id="userEmail"></strong></p>
<div id="otpMessage" class="message hidden"></div>
<form id="verifyOtpForm">
<div class="form-group">
<label for="otp">Verification Code</label>
<input
type="text"
id="otp"
class="otp-input"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
required
>
</div>
<button type="submit" id="verifyOtpBtn">Verify Code</button>
</form>
<div class="timer">
Code expires in <span id="countdown">10:00</span>
</div>
<div class="resend-link hidden" id="resendLink">
<a href="#" id="resendBtn">Resend Code</a>
</div>
</div>
</div>
<script>
// JavaScript implementation for OTP authentication
let countdownInterval;
let userEmail = '';
// Request OTP
document.getElementById('requestOtpForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const btn = document.getElementById('sendOtpBtn');
const message = document.getElementById('emailMessage');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
const response = await fetch('/api/request-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
userEmail = email;
showMessage(message, data.message, 'success');
setTimeout(() => {
showOtpForm();
startCountdown(data.expiresAt);
}, 1000);
} else {
showMessage(message, data.error || 'Failed to send code', 'error');
btn.disabled = false;
btn.textContent = 'Send Code';
}
} catch (error) {
showMessage(message, 'Network error. Please try again.', 'error');
btn.disabled = false;
btn.textContent = 'Send Code';
}
});
// Verify OTP
document.getElementById('verifyOtpForm').addEventListener('submit', async (e) => {
e.preventDefault();
const otp = document.getElementById('otp').value;
const btn = document.getElementById('verifyOtpBtn');
const message = document.getElementById('otpMessage');
btn.disabled = true;
btn.textContent = 'Verifying...';
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, otp }),
});
const data = await response.json();
if (response.ok && data.success) {
clearInterval(countdownInterval);
showMessage(message, 'Code verified successfully!', 'success');
// Handle successful verification
} else {
showMessage(message, data.error || 'Invalid code', 'error');
btn.disabled = false;
btn.textContent = 'Verify Code';
}
} catch (error) {
showMessage(message, 'Network error. Please try again.', 'error');
btn.disabled = false;
btn.textContent = 'Verify Code';
}
});
// Resend OTP
document.getElementById('resendLink').addEventListener('click', (e) => {
e.preventDefault();
// Resend logic here
});
// Helper functions
function showMessage(element, text, type) {
element.textContent = text;
element.className = `message ${type}`;
element.classList.remove('hidden');
}
function showOtpForm() {
document.getElementById('emailForm').classList.add('hidden');
document.getElementById('otpForm').classList.remove('hidden');
document.getElementById('userEmail').textContent = userEmail;
}
function startCountdown(expiresAt) {
const now = Date.now();
let timeLeft = Math.floor((expiresAt - now) / 1000);
if (timeLeft <= 0) {
timeLeft = 0;
}
const countdownEl = document.getElementById('countdown');
const resendLink = document.getElementById('resendLink');
resendLink.classList.add('hidden');
countdownInterval = setInterval(() => {
timeLeft--;
if (timeLeft <= 0) {
clearInterval(countdownInterval);
resendLink.classList.remove('hidden');
countdownEl.textContent = 'expired';
return;
}
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
countdownEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}, 1000);
}
</script>
</body>
</html>
express js will statically serve the html file.
Environment Configuration
.env File Setup
Create a .env file in your project root:
# Convex Configuration
# Deployment used by `npx convex dev`
CONVEX_DEPLOYMENT=dev:convex-deployment # team: debajyati-dey, project: otp-demo
CONVEX_URL=https://your-deployment.convex.cloud
# AutoSend API Configuration
AUTOSEND_API_KEY=your_autosend_api_key_here
AUTOSEND_FROM_EMAIL=noreply@yourdomain.com
AUTOSEND_FROM_NAME=DEYOTP
# Server Configuration
PORT=3000
The Complete Workflow in Action
Our methodology works smooth and is very robust in implementation.
Our demo website looks like this before authentication -

After the email is sent successfully, the modal dialog will change and wait for user to enter the otp code sent to his email address to verify the user email and let him/her log in.
Now, user must enter the otp code (6-digit token) within 10 minutes, because after that the token will expire.
If everything works properly, the user will recieve the email in his/her email client (gmail or proton or thunderbird or outlook whatever he/she uses).
After successful verification the system will let the user authenticate into his account in the website and TADAA!!
Here is the profile page -
I know you might get the ick after seeing the emojis and the purple gradient in the webpage but yeah I told you before it is just a demo and didn't care about the the frontend will look like so I generated it with AI.
Best Practices
Email Content Best Practices
- Clear Subject Lines: Use descriptive, action-oriented subject lines
- Personalization: Include recipient's name when possible
- Mobile Optimization: Ensure emails render well on mobile devices
- Plain Text Fallback: Always include a plain text version.
- Unsubscribe Links: Must include unsubscribe options for marketing emails.
Troubleshooting
Common Issues
Email Not Being Delivered
Symptoms: Emails are not reaching recipients.
Solutions:
- Check AutoSend API key and credentials.
- Verify sender email is authorized in AutoSend.
- Check spam/junk folders.
- Review AutoSend delivery logs.
OTP Verification Failing
Symptoms: Valid OTP codes are rejected.
Solutions:
- Check server time synchronization between Express server and Convex.
- Verify OTP storage and retrieval logic - ensure
expiresAtis returned from Convex. - Check for race conditions in OTP creation/deletion.
- Review attempt counting logic.
- Timing Issues: This system includes a 30-second grace period for expiration checks to account for clock differences and network latency.
-
Countdown Accuracy: Client countdown uses the exact
expiresAttimestamp stored in Convex for synchronization.
Debugging Tips
- Enable Detailed Logging: Add comprehensive logging for email operations.
- Monitor API Usage: Track API call volumes and error rates.
- Database Inspection: Use Convex dashboard to inspect data and uptime.
Conclusion
I hope you learnt something new or at least found a new way to build your own secure auth system by leveraging different services that together do very well.
Now, if you found this article helpful, if this blog added some value to your time and energy, please show some love by giving the article some likes and share it with your dev friends.
Feel free to connect with me. :)
| Thanks for reading! 🙏🏻 Written with 💚 by Debajyati Dey |
|---|
Follow me on Dev to motivate me so that I can bring more such tutorials like this on here!
Happy coding 🧑🏽💻👩🏽💻! Have a nice day ahead! 🚀
Further Read For Reference
- Node.JS Security Best Practices
- convex docs:
- AutoSend API Documentation:
- Email Deliverability Guide:
- AutoSend Blogs:
- Node.JS Best Practices in Design, Architecture:






Top comments (4)
Definitely checking out autosend 👀
Sure! Let me know what you build using it!
Thanks a lot for writing such a detailed article
Glad you liked the article and found it useful!💚💚💚