How to Send OTP with NestJS (2026 Tutorial)
Complete NestJS guide to send and verify SMS OTPs via StartMessaging. Covers a typed service, DTOs, ConfigModule, ThrottlerGuard, exception filters and Jest tests.
StartMessaging Team
Engineering
NestJS gives you typed dependency injection, declarative controllers, and a healthy testing story out of the box — all good things for an auth flow. This tutorial wires up StartMessaging as a first-class service with DTOs, throttling, exception mapping, and Jest unit tests.
Project Setup
npx @nestjs/cli new otp-nestjs --package-manager pnpm
cd otp-nestjs
pnpm add @nestjs/config @nestjs/throttler class-validator class-transformer
pnpm add -D @types/nodeConfiguration Module
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 3600_000, limit: 5 }]),
AuthModule,
],
})
export class AppModule {}Add to .env:
SM_API_KEY=sm_live_xxxxxxxxxxxxxxxx
SM_BASE_URL=https://api.startmessaging.comStartMessaging Service
// src/auth/sm.service.ts
import { Injectable, HttpException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
interface SmEnvelope<T> { data: T; }
interface SendData { requestId: string; expiresAt: string; attemptsLeft: number; }
interface VerifyData { verified: true; }
@Injectable()
export class StartMessagingService {
constructor(private readonly cfg: ConfigService) {}
async sendOtp(phoneNumber: string): Promise<SendData> {
return this.post<SendData>('/otp/send', {
phoneNumber,
idempotencyKey: randomUUID(),
});
}
async verifyOtp(requestId: string, otpCode: string): Promise<VerifyData> {
return this.post<VerifyData>('/otp/verify', { requestId, otpCode });
}
private async post<T>(path: string, body: object): Promise<T> {
const url = this.cfg.getOrThrow<string>('SM_BASE_URL') + path;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cfg.getOrThrow<string>('SM_API_KEY'),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const json: SmEnvelope<T> & { message?: string } = await res
.json()
.catch(() => ({} as any));
if (!res.ok) {
throw new HttpException(json.message ?? 'OTP API error', res.status);
}
return json.data;
}
}Auth Controller and DTOs
// src/auth/dto/send-otp.dto.ts
import { Matches } from 'class-validator';
export class SendOtpDto {
@Matches(/^\+91\d{10}$/, { message: 'phoneNumber must be E.164 (+91XXXXXXXXXX)' })
phoneNumber!: string;
}
// src/auth/dto/verify-otp.dto.ts
import { IsString, Length, Matches } from 'class-validator';
export class VerifyOtpDto {
@IsString()
requestId!: string;
@Matches(/^\d{4,8}$/, { message: 'otpCode must be 4–8 digits' })
otpCode!: string;
}
// src/auth/auth.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { StartMessagingService } from './sm.service';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly sm: StartMessagingService) {}
@Post('send-otp')
send(@Body() body: SendOtpDto) {
return this.sm.sendOtp(body.phoneNumber);
}
@Post('verify-otp')
verify(@Body() body: VerifyOtpDto) {
return this.sm.verifyOtp(body.requestId, body.otpCode);
}
}Throttling per Phone
Use Nest’s ThrottlerGuard to cap per-IP, then layer a per-phone check inside the controller using a small Redis-backed map. See our rate-limiting guide.
Exception Filter
// src/common/sm-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
@Catch(HttpException)
export class SmExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const status = exception.getStatus();
const userMessage =
status === 402 ? 'Service temporarily unavailable.' :
status === 410 ? 'OTP expired. Request a new one.' :
exception.message;
host.switchToHttp().getResponse<Response>()
.status(status === 402 ? 503 : status)
.json({ error: userMessage });
}
}Tests with Jest
// src/auth/sm.service.spec.ts
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { StartMessagingService } from './sm.service';
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: { requestId: 'req_abc', expiresAt: 'X', attemptsLeft: 3 } }),
status: 200,
} as any);
});
it('sends an OTP', async () => {
const moduleRef = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ ignoreEnvFile: true, load: [() => ({
SM_API_KEY: 'k', SM_BASE_URL: 'https://api.startmessaging.com',
})] })],
providers: [StartMessagingService],
}).compile();
const svc = moduleRef.get(StartMessagingService);
const data = await svc.sendOtp('+919876543210');
expect(data.requestId).toBe('req_abc');
});FAQ
For more flavours of the same recipe see Node.js, Express, Next.js App Router, and the full tutorial library. Our API reference has the canonical request / response shapes.
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.
Production-ready Express.js OTP guide using StartMessaging. Covers send, verify, idempotency, rate-limit middleware, error mapping and a session-based verification flow.
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.