How to Send OTP with Express.js (2026)
Production-ready Express.js OTP guide using StartMessaging. Covers send, verify, idempotency, rate-limit middleware, error mapping and a session-based verification flow.
StartMessaging Team
Engineering
Express.js is still the dominant Node.js framework for new APIs in 2026, especially in India where teams iterate quickly on auth flows. This tutorial walks through a production-ready /auth/send-otp and /auth/verify-otp pair on top of StartMessaging, with the middleware patterns most teams reach for.
Project Setup
mkdir otp-express && cd otp-express
npm init -y
npm install express dotenv cookie-session
npm install -D typescript @types/node @types/express tsxWe use ESM and TypeScript. Add to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}Environment Variables
# .env
SM_API_KEY=sm_live_xxxxxxxxxxxxxxxx
SM_BASE_URL=https://api.startmessaging.com
SESSION_SECRET=replace-me-with-a-real-secretA Tiny SM Client Wrapper
// src/sm.ts
import { randomUUID } from 'node:crypto';
const BASE_URL = process.env.SM_BASE_URL!;
const API_KEY = process.env.SM_API_KEY!;
interface SendResp { requestId: string; expiresAt: string; attemptsLeft: number; }
async function smPost(path: string, body: object) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(json.message ?? `SM error ${res.status}`) as Error & { status?: number };
err.status = res.status;
throw err;
}
return json.data;
}
export const sm = {
sendOtp: (phoneNumber: string): Promise<SendResp> =>
smPost('/otp/send', { phoneNumber, idempotencyKey: randomUUID() }),
verifyOtp: (requestId: string, otpCode: string) =>
smPost('/otp/verify', { requestId, otpCode }),
};The /auth Router
// src/auth.router.ts
import { Router } from 'express';
import { sm } from './sm.js';
import { phoneRateLimit } from './middleware.js';
export const auth = Router();
auth.post('/send-otp', phoneRateLimit, async (req, res) => {
const { phoneNumber } = req.body ?? {};
if (!/^\+91\d{10}$/.test(phoneNumber ?? '')) {
return res.status(400).json({ error: 'Invalid phoneNumber' });
}
try {
const data = await sm.sendOtp(phoneNumber);
req.session!.otp = { requestId: data.requestId, phoneNumber };
return res.json({ expiresAt: data.expiresAt, attemptsLeft: data.attemptsLeft });
} catch (e: any) {
return res.status(e.status ?? 500).json({ error: e.message });
}
});
auth.post('/verify-otp', async (req, res) => {
const { otpCode } = req.body ?? {};
const session = req.session?.otp;
if (!session) return res.status(400).json({ error: 'No active OTP request' });
try {
await sm.verifyOtp(session.requestId, otpCode);
req.session!.otp = undefined;
req.session!.userPhone = session.phoneNumber;
return res.json({ verified: true });
} catch (e: any) {
return res.status(e.status ?? 500).json({ error: e.message });
}
});Rate Limiting
// src/middleware.ts
import type { Request, Response, NextFunction } from 'express';
const WINDOW = 60 * 60 * 1000;
const LIMIT = 5;
const buckets = new Map<string, number[]>();
export function phoneRateLimit(req: Request, res: Response, next: NextFunction) {
const phone: string | undefined = req.body?.phoneNumber;
if (!phone) return next();
const now = Date.now();
const bucket = (buckets.get(phone) ?? []).filter((t) => now - t < WINDOW);
if (bucket.length >= LIMIT) {
return res.status(429).json({ error: 'Too many OTP requests for this number' });
}
bucket.push(now);
buckets.set(phone, bucket);
next();
}Swap the in-memory map for Redis in production — full pattern here.
Session-Based Verification
// src/index.ts
import 'dotenv/config';
import express from 'express';
import cookieSession from 'cookie-session';
import { auth } from './auth.router.js';
const app = express();
app.use(express.json());
app.use(cookieSession({
name: 'session',
keys: [process.env.SESSION_SECRET!],
maxAge: 30 * 60_000,
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
}));
app.use('/auth', auth);
app.listen(3001, () => console.log('Listening on :3001'));The session cookie holds the requestId between send and verify, so the client never has to remember it. After verify, we elevate the session to userPhone.
Error Handling
The codes you will see from StartMessaging:
| Status | Cause | Suggested response |
|---|---|---|
| 400 | Bad phone / OTP format | 4xx to user |
| 402 | Wallet exhausted | 503 + ops alert |
| 410 | OTP expired | 410 with retry hint |
| 429 | Provider rate-limit | 429 with Retry-After |
Tests
For unit tests, mock global fetch with vi.stubGlobal (Vitest) or jest.spyOn(global, 'fetch'). Integration tests should hit the StartMessaging sandbox key — see our guide on testing OTP flows in staging.
FAQ
Want the same flow in NestJS, Hono or Next.js? NestJS, Next.js App Router, and the rest are in our tutorials library.
Related Articles
Step-by-step Node.js tutorial to send and verify OTP via SMS using the StartMessaging API. Includes fetch examples, error handling, and verification flow.
Complete NestJS guide to send and verify SMS OTPs via StartMessaging. Covers a typed service, DTOs, ConfigModule, ThrottlerGuard, exception filters and Jest tests.
Send and verify SMS OTPs from a Next.js 14/15 App Router app using server actions and the StartMessaging API. Includes a full login form, server actions, and middleware.
Ready to Send OTPs?
Integrate StartMessaging in 5 minutes. No DLT registration required.