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.
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 tsx
We 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-secret
A 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.
StartMessaging Team
StartMessaging Team