Developer Tutorials

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.

26 April 202610 min read

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 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:

StatusCauseSuggested response
400Bad phone / OTP format4xx to user
402Wallet exhausted503 + ops alert
410OTP expired410 with retry hint
429Provider rate-limit429 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.

Ready to Send OTPs?

Integrate StartMessaging in 5 minutes. No DLT registration required.