Build NestJS SMS OTP/2FA with MessageBird Verify API 2025
Build production-ready SMS OTP and two-factor authentication in NestJS using MessageBird Verify API. Step-by-step TypeScript tutorial with code examples, error handling, and security best practices.
Integrate MessageBird's Verify API into your NestJS application to implement robust SMS-based Two-Factor Authentication (2FA) or phone number verification using One-Time Passwords (OTPs). This guide shows you how to build a secure, scalable backend API that sends OTPs via SMS and verifies user-submitted tokens.
Project Overview and Goals
What You'll Build:
NestJS application with dedicated endpoints for initiating OTP requests and verifying submitted tokens
Integration with the MessageBird Verify API via their official Node.js SDK
Secure API key handling using NestJS configuration modules
Input validation for phone numbers and tokens
Robust error handling for common scenarios
Problem This Solves:
Verify user phone numbers and add a 2FA layer to your application. This prevents fraudulent account creation, secures accounts against unauthorized access, and verifies transactions by confirming control over a registered phone number. Common use cases include e-commerce checkout verification, financial transaction confirmation, and account recovery flows.
Technologies You'll Use:
Node.js: JavaScript runtime environment
NestJS: Progressive Node.js framework for building efficient, reliable, and scalable server-side applications with modular architecture and dependency injection
MessageBird: Third-party service provider for sending SMS OTPs via their Verify API
MessageBird Node.js SDK: Simplifies interaction with the MessageBird API
TypeScript: Provides static typing for enhanced code quality and maintainability
(Optional) Prisma: Modern ORM for database interaction (not required for basic OTP flow)
System Architecture:
graph LR
A[User Client (Web/Mobile)] -- 1. Request OTP (POST /otp/send) --> B(NestJS API);
B -- 2. Call OtpService.sendOtp --> C(OtpService);
C -- 3. Call MessageBird SDK (verify.create) --> D(MessageBird Verify API);
D -- 4. Send SMS OTP --> E[User's Phone];
D -- 5. Return Verification ID --> C;
C -- 6. Return Verification ID --> B;
B -- 7. Send Verification ID back --> A;
A -- 8. Submit Token & ID (POST /otp/verify) --> B;
B -- 9. Call OtpService.verifyOtp --> C;
C -- 10. Call MessageBird SDK (verify.verify) --> D;
D -- 11. Return Verification Status --> C;
C -- 12. Return Status --> B;
B -- 13. Send Verification Result back --> A;
style B fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#ff9,stroke:#333,stroke-width:2px
Prerequisites:
Install these requirements before starting:
Node.js: Install LTS version v22 (Active LTS) or v20 (Maintenance LTS) recommended as of 2025. Node.js 18 reaches end-of-life April 2025.
MessageBird Account: Sign up and obtain API credentials (both test and live keys)
Knowledge: Basic understanding of TypeScript, NestJS concepts (modules, controllers, services), REST APIs, and authentication flows
NestJS CLI: Install globally with npm install -g @nestjs/cli (NestJS v10+ requires Node.js v16 or higher)
(Optional) Docker: For database setup if using Prisma
(Optional) PostgreSQL: Local or cloud-based database instance
Final Outcome:
Build a functional NestJS API that:
Accepts a phone number via POST request
Sends an SMS OTP to that number using MessageBird
Returns a unique verification ID to the client
Accepts the verification ID and user-submitted OTP via another POST request
Verifies the token against the ID using MessageBird
Returns success or failure status to the client
After successful verification, integrate this into your full authentication flow by issuing JWT tokens or creating user sessions.
1. Setting Up the Project
Initialize a new NestJS project and install the necessary dependencies.
Create Your NestJS Project:
Run the NestJS CLI command to create a new project named nestjs-messagebird-otp.
Bash
nest new nestjs-messagebird-otp
Choose your preferred package manager (npm or yarn) when prompted.
Navigate to Your Project Directory:
Bash
cd nestjs-messagebird-otp
Install Required Dependencies:
Install these essential packages:
@nestjs/config: Manages environment variables
messagebird: Official MessageBird Node.js SDK (v4.0.1, last updated 2022 — monitor for alternatives if maintenance becomes a concern)
class-validator, class-transformer: Validate request data using DTOs
Bash
# Using npm
npm install @nestjs/config messagebird class-validator class-transformer
# Using yarn
yarn add @nestjs/config messagebird class-validator class-transformer
Package Versions (as of 2025):
messagebird: v4.0.1 (last published 3 years ago — check for security vulnerabilities before production deployment)
@nestjs/config: Compatible with NestJS v10+ and v11
NestJS v11.1.6 is the latest stable version
Security Note: The MessageBird SDK hasn't been updated since 2022. Before production deployment, check for known CVEs and consider alternatives like direct REST API integration or maintained community SDKs.
(Optional) Install Prisma Dependencies:
If you plan to integrate with a database (e.g., to link verified numbers to users), install Prisma. This section is optional for the core OTP functionality.
Bash
# Using npm
npm install prisma @prisma/client --save-dev
# Using yarn
yarn add prisma @prisma/client --dev
src/otp/: Module for OTP logic (you'll create this)
.env: Environment variables (you'll create this)
(Optional) prisma/: Prisma schema and migrations
2. Environment Configuration
Securely manage API keys using the @nestjs/config module and a .env file. For production, use secret managers like AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault instead of plain .env files.
Create .env file:
Create a file named .env in the project's root directory.
Add MessageBird API Key:
Obtain your API keys from the MessageBird Dashboard:
Log in to your MessageBird account.
Navigate to the Developers section in the left sidebar.
Go to the API access (REST) tab.
You'll find both Live and Test API keys.
Test Key: Use during development and testing. Simulates API calls without sending actual SMS messages or incurring costs. Test against specific MessageBird test numbers.
Live Key: Use in production to send real SMS messages to users.
Add the key you intend to use (start with the test key) to your .env file:
.env
# .env
# Use your TEST key for development, switch to LIVE key for production
MESSAGEBIRD_API_KEY=YOUR_TEST_API_KEY_HERE
Replace YOUR_TEST_API_KEY_HERE with your actual test key initially. Switch to the live key in your production environment.
Purpose: This variable holds the secret key required to authenticate requests to the MessageBird API. Using test keys avoids costs and real messages during development.
(Optional) Add Database URL:
If using Prisma (optional feature), add your database connection string:
.env
# .env (Optional - only if using Prisma)
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_URL="postgresql://postgres:password@localhost:5432/otp_app?schema=public"
Adjust the URL according to your database credentials and provider.
Purpose: This variable tells Prisma how to connect to your database.
Load ConfigModule:
Import and configure ConfigModule in your main application module (src/app.module.ts) to make environment variables available throughout the app via ConfigService. Make it global and load .env variables.
Why isGlobal: true? This makes the ConfigService available in any module without needing to import ConfigModule repeatedly.
3. Implementing Core Functionality (OTP Service)
Create a dedicated module and service to handle the logic of interacting with the MessageBird API.
Generate OTP Module and Service:
Use the NestJS CLI to generate the otp module and service.
Bash
nest generate module otp
nest generate service otp
This creates src/otp/otp.module.ts and src/otp/otp.service.ts (and spec file). NestJS automatically updates src/app.module.ts to import OtpModule.
Implement OtpService:
Open src/otp/otp.service.ts. Inject ConfigService to access the API key and instantiate the MessageBird client.
TypeScript
// src/otp/otp.service.tsimport { Injectable, InternalServerErrorException, BadRequestException, Logger } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import * asMessageBirdfrom'messagebird'; // Import MessageBird SDK@Injectable()
exportclassOtpService {
privatereadonly logger = newLogger(OtpService.name);
privatemessagebird: MessageBird.MessageBird;
constructor(privateconfigService: ConfigService) {
const apiKey = this.configService.get<string>('MESSAGEBIRD_API_KEY');
if (!apiKey) {
thrownewError('MessageBird API Key not found in environment variables.');
}
// Initialize MessageBird client// Type assertion might be needed due to MessageBird SDK typings; this could change with SDK updates.this.messagebird = MessageBird(apiKey) asunknownasMessageBird.MessageBird;
}
/**
* Sends an OTP code to the specified phone number via MessageBird Verify API.
* @param phoneNumber The recipient's phone number in E.164 format (e.g., +14155552671)
* @returns The verification ID from MessageBird.
* @throws InternalServerErrorException if the API call fails unexpectedly.
* @throws BadRequestException if MessageBird returns a specific user-related error (e.g., invalid number format - code 21).
*/asyncsendOtp(phoneNumber: string): Promise<string> {
// Basic normalization example: remove spaces, dashes, parentheses// For production, use libphonenumber-js for comprehensive E.164 format validation and normalizationconst normalizedPhoneNumber = phoneNumber.replace(/[\s\-()]/g, '');
this.logger.log(`Sending OTP to normalized number: ${normalizedPhoneNumber}`);
constparams: MessageBird.VerifyCreateParams = {
originator: 'VerifyApp', // Or your App Name (max 11 chars alphanumeric) or verified phone numbertemplate: 'Your verification code is %token.',
type: 'sms',
// tokenLength: 6, // Default is 6, range: 6-10 (per MessageBird Verify API docs)// timeout: 30, // Default is 30 seconds, range: 30-172801 seconds (up to 2 days)// maxAttempts: 1, // Default is 1, range: 1-10 attempts
};
returnnewPromise((resolve, reject) => {
this.messagebird.verify.create(normalizedPhoneNumber, params, (err, response) => {
if (err) {
this.logger.error('MessageBird verify.create error:', JSON.stringify(err)); // Log the full error structure// Check for specific, actionable MessageBird error codesif (err.errors && err.errors.length > 0) {
const mbError = err.errors[0];
this.logger.error(`MessageBird Error Code: ${mbError.code}, Description: ${mbError.description}`);
// Example: Code 21 indicates an invalid phone numberif (mbError.code === 21) {
returnreject(newBadRequestException(`Invalid phone number: ${mbError.description}`));
}
// Add checks for other relevant codes if needed (e.g., originator issues)// Fallback for other specific MessageBird errorsreturnreject(newBadRequestException(`Failed to send OTP: ${mbError.description}`));
}
// Generic internal error if no specific code matched or error structure is unexpectedreturnreject(newInternalServerErrorException('Failed to initiate OTP verification due to an unexpected error.'));
}
this.logger.log(`OTP request successful for ${normalizedPhoneNumber}. ID: ${response?.id}`);
// Ensure response.id is treated as a stringif (response && typeof response.id === 'string') {
resolve(response.id);
} else {
this.logger.error('MessageBird verify.create response missing or invalid ID:', response);
reject(newInternalServerErrorException('Failed to get a valid verification ID from MessageBird.'));
}
});
});
}
/**
* Verifies the OTP token submitted by the user against the verification ID.
* @param id The verification ID received from sendOtp.
* @param token The 6-digit token entered by the user.
* @returns An object indicating successful verification.
* @throws BadRequestException if the token is invalid or expired (e.g., MessageBird error code 10).
* @throws InternalServerErrorException on other API errors.
*/asyncverifyOtp(id: string, token: string): Promise<{ status: string }> {
this.logger.log(`Verifying OTP for ID: ${id}`);
returnnewPromise((resolve, reject) => {
this.messagebird.verify.verify(id, token, (err, response) => {
if (err) {
this.logger.error(`MessageBird verify.verify error for ID ${id}:`, JSON.stringify(err));
// Handle specific errors, e.g., invalid token (code 10)if (err.errors && err.errors.length > 0) {
const mbError = err.errors[0];
this.logger.error(`MessageBird Verify Error Code: ${mbError.code}, Description: ${mbError.description}`);
if (mbError.code === 10) { // Code 10 typically means invalid tokenreturnreject(newBadRequestException('Invalid or expired OTP token.'));
}
// Fallback for other specific MessageBird errors during verifyreturnreject(newBadRequestException(`Failed to verify OTP: ${mbError.description}`));
}
// Generic internal errorreturnreject(newInternalServerErrorException('Failed to verify OTP due to an unexpected error.'));
}
// Check the status if availableif (response && response.status === 'verified') {
this.logger.log(`OTP verification successful for ID: ${id}`);
resolve({ status: 'verified' });
} else {
// Log the actual status received if not 'verified' (e.g., 'expired', 'failed')this.logger.warn(`OTP verification for ID ${id} returned non-verified status: ${response?.status ?? 'unknown'}`);
// Treat any non-verified status as a failure from the client's perspectivereject(newBadRequestException('Invalid or expired OTP token.'));
}
});
});
}
}
Key Implementation Details:
Why new Promise? The MessageBird SDK uses callbacks. Wrap the callback logic in a Promise to work seamlessly with NestJS's async/await syntax.
Error Handling: Catch errors from the SDK. Throw BadRequestException for known user-input issues or specific MessageBird error codes (like invalid number code 21, invalid token code 10). Use InternalServerErrorException for unexpected API failures or missing IDs. Logging the full error (JSON.stringify(err)) and specific codes helps diagnose issues.
Configuration:originator should ideally be a purchased virtual number from MessageBird or a short alphanumeric code (max 11 characters, check country-specific restrictions). templatemust include the %token placeholder (verified from MessageBird Verify API documentation at https://developers.messagebird.com/api/verify/).
Normalization: The basic regex phoneNumber.replace(/[\s\-()]/g, '') strips common characters before sending to MessageBird. For production, use a robust phone number library like libphonenumber-js for comprehensive E.164 format validation and normalization, especially for international formats and country-specific validation.
type: Options are sms, flash, tts (text-to-speech), or email
Common MessageBird Error Codes:
Code 10: Invalid or expired OTP token
Code 21: Invalid phone number format
Code 9: Missing required parameter
Register ConfigService in OtpModule:
Since ConfigModule is global and ConfigService is injected into OtpService (which is part of OtpModule), no explicit import of ConfigModule or registration of ConfigService is needed within OtpModule itself.
TypeScript
// src/otp/otp.module.tsimport { Module } from'@nestjs/common';
import { OtpService } from'./otp.service';
import { OtpController } from'./otp.controller'; // We'll create this next@Module({
controllers: [OtpController], // Add controller hereproviders: [OtpService],
exports: [OtpService], // Export if other modules need it
})
exportclassOtpModule {}
4. Building the API Layer (OTP Controller)
Create the controller with endpoints that clients can call. Protect these endpoints with rate limiting and authentication in production.
Generate OTP Controller:
Bash
nest generate controller otp
This creates src/otp/otp.controller.ts (and spec file) and adds it to OtpModule.
Define Data Transfer Objects (DTOs):
Create DTOs to define the expected request body structure and apply validation rules using class-validator.
Create src/otp/dto/send-otp.dto.ts:
TypeScript
// src/otp/dto/send-otp.dto.tsimport { IsNotEmpty, IsPhoneNumber, IsString } from'class-validator';
exportclassSendOtpDto {
@IsNotEmpty()
@IsString()
@IsPhoneNumber(null) // Use null for generic E.164 format validation// Input might still contain formatting chars; normalization happens in the service.phoneNumber: string;
}
About @IsPhoneNumber: This validator accepts E.164 format phone numbers (e.g., +14155552671) but has limitations with formatting variations. Service-level normalization remains necessary to handle spaces, dashes, and parentheses that users might include.
Create src/otp/dto/verify-otp.dto.ts:
TypeScript
// src/otp/dto/verify-otp.dto.tsimport { IsNotEmpty, IsString, Length } from'class-validator';
exportclassVerifyOtpDto {
@IsNotEmpty()
@IsString()
// Add validation if MessageBird always returns IDs of a specific format/lengthid: string;
@IsNotEmpty()
@IsString()
@Length(6, 6) // Assuming default 6-digit tokentoken: string;
}
Implement OtpController:
Open src/otp/otp.controller.ts. Inject OtpService and define the endpoints.
TypeScript
// src/otp/otp.controller.tsimport { Controller, Post, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from'@nestjs/common';
import { OtpService } from'./otp.service';
import { SendOtpDto } from'./dto/send-otp.dto';
import { VerifyOtpDto } from'./dto/verify-otp.dto';
@Controller('otp') // Route prefix: /otpexportclassOtpController {
privatereadonly logger = newLogger(OtpController.name);
constructor(privatereadonlyotpService: OtpService) {}
@Post('send') // Endpoint: POST /otp/send@HttpCode(HttpStatus.OK) // Return 200 OK on success// If ValidationPipe is global, @UsePipes is not needed here// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))asyncsendOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ id: string }> {
this.logger.log(`Received request to send OTP to ${sendOtpDto.phoneNumber}`);
const verificationId = awaitthis.otpService.sendOtp(sendOtpDto.phoneNumber);
return { id: verificationId };
}
@Post('verify') // Endpoint: POST /otp/verify@HttpCode(HttpStatus.OK) // Return 200 OK on successful verification// If ValidationPipe is global, @UsePipes is not needed here// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))asyncverifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ status: string }> {
this.logger.log(`Received request to verify OTP for ID: ${verifyOtpDto.id}`);
returnthis.otpService.verifyOtp(verifyOtpDto.id, verifyOtpDto.token);
}
}
Endpoint Details:
@Controller('otp'): Sets the base route for all methods in this controller to /otp
@Post('send'), @Post('verify'): Define POST endpoints at /otp/send and /otp/verify
@Body(): Injects the request body
ValidationPipe: (Applied globally below) Automatically validates the incoming request body against the DTO
@HttpCode(HttpStatus.OK): Ensures a 200 OK status is returned on success
Production Security:
Implement rate limiting to prevent abuse (use @nestjs/throttler package)
Add authentication to protect endpoints (JWT tokens, API keys)
Track OTP request attempts per IP and phone number
Implement cooldown periods between OTP requests
Enable ValidationPipe Globally (Recommended):
Enable the validation pipe globally in src/main.ts for cleaner controllers.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe, Logger } from'@nestjs/common'; // Import Logger & ValidationPipeasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const logger = newLogger('Bootstrap');
// Enable global validation pipe
app.useGlobalPipes(newValidationPipe({
whitelist: true, // Strip properties not in DTOtransform: true, // Transform payload to DTO instancesforbidNonWhitelisted: true, // Throw error if non-whitelisted properties are senttransformOptions: {
enableImplicitConversion: true, // Allow auto-conversion of types
},
}));
// Enable CORS - By default allows all origins.// **IMPORTANT**: Restrict origins in production for security!
app.enableCors();
// Example for production:// app.enableCors({ origin: 'https://your-frontend-app.com' });
logger.log('CORS enabled. Remember to restrict origins in production.');
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application listening on port ${port}`);
}
bootstrap();
With the global pipe enabled, remove the @UsePipes(...) decorators from the controller methods (as shown in the controller example above).
Production Deployment Checklist:
Install helmet for security headers
Enable compression middleware
Configure structured logging (Pino recommended)
Add health check endpoints
Implement graceful shutdown
Use environment-specific configurations
Set up centralized logging (CloudWatch, Datadog, etc.)
Testing Endpoints with curl:
Start the application:npm run start:dev
Send OTP:
Replace +12345678900 with a real phone number (use a test number if using your test API key).
Expected Response (Validation Error – e.g., invalid number format):
JSON
{"statusCode":400,"message":["phoneNumber must be a valid phone number"],"error":"Bad Request"}
Verify OTP:
Replace some-verification-id-from-messagebird with the ID you received, and 123456 with the code sent to your phone (or the test code provided by MessageBird if using test keys).
{"statusCode":400,"message":"Invalid or expired OTP token.","error":"Bad Request"}
5. Integrating with Third-Party Services (MessageBird)
Core integration points:
Configuration: Store API key securely in .env (use Test key for dev, Live key for prod) and load via @nestjs/config. Ensure the .env file is never committed to version control (add it to .gitignore).
SDK Initialization: Initialize the messagebird SDK in the OtpService constructor using the API key from ConfigService. The type assertion as unknown as MessageBird.MessageBird is included due to potential SDK typing nuances.
API Calls: Use verify.create and verify.verify methods within OtpService to interact with MessageBird.
Dashboard Navigation for API Key: MessageBird Dashboard → Developers → API access (REST) → Copy/Create Test and Live API Keys.
Fallback Mechanisms: The current implementation relies directly on MessageBird. For production robustness:
Retries: Implement retry logic (see Section 6) within OtpService around the MessageBird calls, especially for transient network errors (distinguish from user errors like invalid tokens).
Alternative Providers: Consider integrating a secondary SMS provider as a fallback if MessageBird experiences prolonged outages (adds complexity).
Voice Fallback: MessageBird Verify supports type: 'tts' (Text-to-Speech). Modify sendOtp to offer this if SMS fails or as a preference.
MessageBird Pricing Considerations:
SMS costs vary by destination country
Test API keys don't incur charges
Monitor usage to stay within budget
Consider volume pricing tiers for scaling
Geographic Coverage:
MessageBird supports SMS delivery to 200+ countries. Check country-specific restrictions and compliance requirements (e.g., sender ID registration, content filtering) before deploying internationally.
6. Implementing Error Handling and Logging
NestJS provides robust mechanisms for handling errors and logging.
Error Handling Strategy:
Validation Errors: The global ValidationPipe handles these automatically, returning 400 Bad Request.
Service-Level Errors:OtpService throws specific HttpException subclasses (BadRequestException, InternalServerErrorException) based on MessageBird API responses or internal issues. Specific MessageBird error codes (e.g., 10, 21) map to BadRequestException.
Unhandled Errors: NestJS catches unhandled exceptions and returns 500 Internal Server Error.
Custom Exception Filters:
Create custom exception filters to format error responses consistently or integrate with error tracking services like Sentry. Example:
Use centralized logging solutions (CloudWatch, Datadog, Splunk)
Retry Mechanisms (Conceptual Example):
Add retries to OtpService calls to improve resilience against transient network issues.
TypeScript
// Simplified conceptual retry logic for OtpService methods// Consider using a library like 'nestjs-retry' or 'async-retry' for production// Define logger at a scope accessible by isTransientError if needed, or pass itconst logger = newLogger('RetryLogic');
asyncfunction callWithRetry<T>(action: () =>Promise<T>, maxAttempts: number, logger: Logger): Promise<T> {
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
returnawaitaction(); // Attempt the action
} catch (error) {
logger.error(`Attempt ${attempts} failed: ${error.message}`, error.stack);
// Check if the error is potentially transient AND if more attempts remainif (isTransientError(error) && attempts < maxAttempts) {
const delay = 1000 * Math.pow(2, attempts - 1); // Exponential backoff
logger.warn(`Retrying after ${delay}ms…`);
awaitnewPromise(resolve =>setTimeout(resolve, delay));
continue; // Go to the next iteration
} else {
// If not transient or max attempts reached, re-throw the errorthrow error;
}
}
}
// This line should theoretically not be reached if maxAttempts > 0thrownewInternalServerErrorException('Action failed after maximum retry attempts.');
}
// **Placeholder Function - Requires Real Implementation**// This function MUST be implemented to identify errors that justify a retry// (e.g., network timeouts, specific 5xx errors from MessageBird if identifiable).// Do NOT retry on errors like 'invalid phone number' or 'invalid token'.functionisTransientError(error: any): boolean {
logger.warn('`isTransientError` check is currently a placeholder and always returns false. Implement real transient error detection logic.');
// Example checks (modify based on actual error structure and codes):// if (error?.status === 503) return true; // Service Unavailable// if (error?.code === 'ETIMEDOUT' || error?.code === 'ENETUNREACH') return true; // Network issues// if (error?.errors?.[0]?.code === SOME_MESSAGEBIRD_TRANSIENT_CODE) return true;returnfalse; // Default: Do not retry
}
// Usage within OtpService (Example for sendOtp):// Assume OtpService class context for 'this.logger', 'this.messagebird' etc./*
async sendOtp(phoneNumber: string): Promise<string> {
const normalizedPhoneNumber = phoneNumber.replace(/[\s\-()]/g, '');
this.logger.log(`Sending OTP to normalized number: ${normalizedPhoneNumber}`);
const action = () => new Promise<string>((resolve, reject) => {
const params: MessageBird.VerifyCreateParams = {
originator: 'VerifyApp',
template: 'Your verification code is %token.',
type: 'sms',
};
this.messagebird.verify.create(normalizedPhoneNumber, params, (err, response) => {
if (err) return reject(err); // Reject promise on error to be caught by retry logic
if (response && typeof response.id === 'string') {
resolve(response.id);
} else {
reject(new InternalServerErrorException('Failed to get a valid verification ID from MessageBird.'));
}
});
});
try {
// Wrap the promise-based SDK call within the retry function
// Important: The retry function handles the actual SDK error mapping (BadRequest vs InternalServer)
return await callWithRetry(action, 3, this.logger);
} catch (error) {
// Map final error after retries (if any occurred)
this.logger.error('Final error after retries (or on first attempt if non-transient):', JSON.stringify(error));
if (error instanceof HttpException) {
throw error; // Re-throw exceptions already mapped (like BadRequest)
}
// Map specific MessageBird errors after retries failed
if (error.errors && error.errors.length > 0) {
const mbError = error.errors[0];
if (mbError.code === 21) {
throw new BadRequestException(`Invalid phone number: ${mbError.description}`);
}
throw new BadRequestException(`Failed to send OTP: ${mbError.description}`);
}
// Fallback generic error
throw new InternalServerErrorException('Failed to initiate OTP verification after multiple attempts.');
}
}
// Apply similar retry logic structure to verifyOtp if needed.
*/
Caveat: The isTransientError function is critical and provided as a non-working placeholder. You must implement logic to correctly identify which errors (based on codes, status, or types) should trigger a retry. Retrying on user errors (like invalid number) is incorrect. The usage example within sendOtp is commented out to avoid making the code block non-functional due to the placeholder isTransientError and context assumptions.
Circuit Breaker Pattern:
Protect your application from cascading failures when MessageBird is down. Use the circuit breaker pattern (libraries like opossum or nestjs-circuit-breaker) to temporarily stop sending requests to a failing service and provide fallback responses.
7. (Optional) Creating a Database Schema and Data Layer (Prisma Example)
This section is optional and only relevant if you need to store user data or link verified numbers.
Initialize Prisma:
If not done in Section 1, install Prisma dependencies and initialize:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
phoneNumber String @unique
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
verifications Verification[]
}
model Verification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
phoneNumber String
verificationId String @unique
status String // 'pending', 'verified', 'failed', 'expired'
attempts Int @default(0)
createdAt DateTime @default(now())
expiresAt DateTime
verifiedAt DateTime?
}
Run Migrations:
Bash
npx prisma migrate dev --name init
npx prisma generate
Integration Example:
Track verification attempts for audit trails, implement cooldown periods between OTP requests, and link verified phone numbers to user accounts:
TypeScript
// In OtpService after successful verification:awaitthis.prisma.verification.update({
where: { verificationId: id },
data: {
status: 'verified',
verifiedAt: newDate(),
},
});
awaitthis.prisma.user.update({
where: { phoneNumber },
data: { isVerified: true },
});
Create a NestJS service that integrates with the MessageBird Verify API using their Node.js SDK. This service should handle sending the OTP to the user's phone number via SMS after validating the number format. The service will interact with MessageBird's API to generate and send the OTP, returning a verification ID to your application.
MessageBird's Verify API is a service for sending and verifying one-time passwords (OTPs) via SMS, commonly used for two-factor authentication (2FA). The API handles generating the OTP, delivering it to the user's phone number, and verifying user-submitted tokens, enhancing application security by adding an extra layer of verification beyond traditional passwords.
NestJS provides a structured and scalable framework for building server-side applications in Node.js, making it ideal for implementing OTP verification. It offers features like dependency injection, configuration management, and a modular architecture, making development more efficient and easier to maintain. Its support for TypeScript further improves code quality.
You can install the MessageBird Node.js SDK using either npm or yarn with the command npm install messagebird or yarn add messagebird. This SDK simplifies interaction with the MessageBird API, allowing your NestJS application to easily send SMS messages for OTP verification.
Store your MessageBird API key (test key for development, live key for production) in a .env file in your project root. Use the @nestjs/config module to load these environment variables securely into your NestJS application. Never commit .env to version control.
Class-validator, along with class-transformer, facilitates request data validation in NestJS. By defining Data Transfer Objects (DTOs) and decorating properties with validation rules, you ensure data integrity and prevent invalid data from reaching your service logic. This adds a layer of security and reliability.
Create a controller in your OTP module, define a POST /otp/verify endpoint, and inject the OtpService. Use the @Body() decorator and a DTO (VerifyOtpDto) to validate incoming data containing the verification ID and token. The controller calls the service's verifyOtp method, then returns the status ('verified' or an error) back to the client.
In your OTP controller, create a POST /otp/send endpoint and inject the OtpService. Use the @Body() decorator with a DTO (SendOtpDto) to validate the incoming phone number. The controller should then call the service's sendOtp method, returning the verification ID to the client.
The verification ID, returned by MessageBird's verify.create call, is a unique identifier associated with a specific OTP request. It links the OTP sent to the user with the subsequent verification process. This ID is then used along with the user-submitted OTP to complete the verification.
Implement comprehensive error handling in your OtpService by mapping MessageBird API error codes to appropriate HTTP exceptions. For example, an invalid token should return a 400 Bad Request, and an unexpected API failure should return a 500 Internal Server Error. Log errors with details to facilitate debugging.
Use class-validator's @IsPhoneNumber() decorator within the SendOtpDto to validate the phone number format before it reaches your service logic. Handle any remaining format issues or MessageBird-specific number errors (like code 21) in the OtpService by throwing a BadRequestException with a clear message.
Only use your live MessageBird API key in your production environment. During development and testing, use the test key. This prevents accidental charges and allows you to use MessageBird's test phone numbers. Remember to switch to the live key for real OTP delivery when deploying to production.
Implement phone number normalization in your OtpService to handle variations in user input (spaces, dashes, parentheses). A basic replace function can handle simple cases, or consider using a dedicated phone number formatting library for more robust normalization across international formats.
Integrate MessageBird's Verify API into your NestJS application to implement robust SMS-based Two-Factor Authentication (2FA) or phone number verification using One-Time Passwords (OTPs). This guide shows you how to build a secure, scalable backend API that sends OTPs via SMS and verifies user-submitted tokens.
Project Overview and Goals
What You'll Build:
Problem This Solves:
Verify user phone numbers and add a 2FA layer to your application. This prevents fraudulent account creation, secures accounts against unauthorized access, and verifies transactions by confirming control over a registered phone number. Common use cases include e-commerce checkout verification, financial transaction confirmation, and account recovery flows.
Technologies You'll Use:
System Architecture:
Prerequisites:
Install these requirements before starting:
npm install -g @nestjs/cli(NestJS v10+ requires Node.js v16 or higher)Final Outcome:
Build a functional NestJS API that:
After successful verification, integrate this into your full authentication flow by issuing JWT tokens or creating user sessions.
1. Setting Up the Project
Initialize a new NestJS project and install the necessary dependencies.
Create Your NestJS Project: Run the NestJS CLI command to create a new project named
nestjs-messagebird-otp.Choose your preferred package manager (npm or yarn) when prompted.
Navigate to Your Project Directory:
cd nestjs-messagebird-otpInstall Required Dependencies: Install these essential packages:
@nestjs/config: Manages environment variablesmessagebird: Official MessageBird Node.js SDK (v4.0.1, last updated 2022 — monitor for alternatives if maintenance becomes a concern)class-validator,class-transformer: Validate request data using DTOs# Using npm npm install @nestjs/config messagebird class-validator class-transformer # Using yarn yarn add @nestjs/config messagebird class-validator class-transformerPackage Versions (as of 2025):
messagebird: v4.0.1 (last published 3 years ago — check for security vulnerabilities before production deployment)@nestjs/config: Compatible with NestJS v10+ and v11Security Note: The MessageBird SDK hasn't been updated since 2022. Before production deployment, check for known CVEs and consider alternatives like direct REST API integration or maintained community SDKs.
(Optional) Install Prisma Dependencies: If you plan to integrate with a database (e.g., to link verified numbers to users), install Prisma. This section is optional for the core OTP functionality.
# Using npm npm install prisma @prisma/client --save-dev # Using yarn yarn add prisma @prisma/client --devProject Structure: Key directories:
src/: Application source code (main.ts,app.module.ts, etc.)src/otp/: Module for OTP logic (you'll create this).env: Environment variables (you'll create this)(Optional) prisma/: Prisma schema and migrations2. Environment Configuration
Securely manage API keys using the
@nestjs/configmodule and a.envfile. For production, use secret managers like AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault instead of plain.envfiles.Create
.envfile: Create a file named.envin the project's root directory.Add MessageBird API Key: Obtain your API keys from the MessageBird Dashboard:
.envfile:Replace
YOUR_TEST_API_KEY_HEREwith your actual test key initially. Switch to the live key in your production environment.Purpose: This variable holds the secret key required to authenticate requests to the MessageBird API. Using test keys avoids costs and real messages during development.
(Optional) Add Database URL: If using Prisma (optional feature), add your database connection string:
Adjust the URL according to your database credentials and provider.
Purpose: This variable tells Prisma how to connect to your database.
Load
ConfigModule: Import and configureConfigModulein your main application module (src/app.module.ts) to make environment variables available throughout the app viaConfigService. Make it global and load.envvariables.// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { AppController } from './app.controller'; import { AppService } from './app.service'; import { OtpModule } from './otp/otp.module'; // We'll create this soon // import { PrismaModule } from './prisma.module'; // Import if using Prisma @Module({ imports: [ ConfigModule.forRoot({ // Load .env variables globally isGlobal: true, }), OtpModule, // Import our feature module // PrismaModule, // Add PrismaModule if using database (ensure it's defined) ], controllers: [AppController], providers: [AppService], }) export class AppModule {}Why
isGlobal: true? This makes theConfigServiceavailable in any module without needing to importConfigModulerepeatedly.3. Implementing Core Functionality (OTP Service)
Create a dedicated module and service to handle the logic of interacting with the MessageBird API.
Generate OTP Module and Service: Use the NestJS CLI to generate the
otpmodule and service.This creates
src/otp/otp.module.tsandsrc/otp/otp.service.ts(and spec file). NestJS automatically updatessrc/app.module.tsto importOtpModule.Implement
OtpService: Opensrc/otp/otp.service.ts. InjectConfigServiceto access the API key and instantiate the MessageBird client.// src/otp/otp.service.ts import { Injectable, InternalServerErrorException, BadRequestException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as MessageBird from 'messagebird'; // Import MessageBird SDK @Injectable() export class OtpService { private readonly logger = new Logger(OtpService.name); private messagebird: MessageBird.MessageBird; constructor(private configService: ConfigService) { const apiKey = this.configService.get<string>('MESSAGEBIRD_API_KEY'); if (!apiKey) { throw new Error('MessageBird API Key not found in environment variables.'); } // Initialize MessageBird client // Type assertion might be needed due to MessageBird SDK typings; this could change with SDK updates. this.messagebird = MessageBird(apiKey) as unknown as MessageBird.MessageBird; } /** * Sends an OTP code to the specified phone number via MessageBird Verify API. * @param phoneNumber The recipient's phone number in E.164 format (e.g., +14155552671) * @returns The verification ID from MessageBird. * @throws InternalServerErrorException if the API call fails unexpectedly. * @throws BadRequestException if MessageBird returns a specific user-related error (e.g., invalid number format - code 21). */ async sendOtp(phoneNumber: string): Promise<string> { // Basic normalization example: remove spaces, dashes, parentheses // For production, use libphonenumber-js for comprehensive E.164 format validation and normalization const normalizedPhoneNumber = phoneNumber.replace(/[\s\-()]/g, ''); this.logger.log(`Sending OTP to normalized number: ${normalizedPhoneNumber}`); const params: MessageBird.VerifyCreateParams = { originator: 'VerifyApp', // Or your App Name (max 11 chars alphanumeric) or verified phone number template: 'Your verification code is %token.', type: 'sms', // tokenLength: 6, // Default is 6, range: 6-10 (per MessageBird Verify API docs) // timeout: 30, // Default is 30 seconds, range: 30-172801 seconds (up to 2 days) // maxAttempts: 1, // Default is 1, range: 1-10 attempts }; return new Promise((resolve, reject) => { this.messagebird.verify.create(normalizedPhoneNumber, params, (err, response) => { if (err) { this.logger.error('MessageBird verify.create error:', JSON.stringify(err)); // Log the full error structure // Check for specific, actionable MessageBird error codes if (err.errors && err.errors.length > 0) { const mbError = err.errors[0]; this.logger.error(`MessageBird Error Code: ${mbError.code}, Description: ${mbError.description}`); // Example: Code 21 indicates an invalid phone number if (mbError.code === 21) { return reject(new BadRequestException(`Invalid phone number: ${mbError.description}`)); } // Add checks for other relevant codes if needed (e.g., originator issues) // Fallback for other specific MessageBird errors return reject(new BadRequestException(`Failed to send OTP: ${mbError.description}`)); } // Generic internal error if no specific code matched or error structure is unexpected return reject(new InternalServerErrorException('Failed to initiate OTP verification due to an unexpected error.')); } this.logger.log(`OTP request successful for ${normalizedPhoneNumber}. ID: ${response?.id}`); // Ensure response.id is treated as a string if (response && typeof response.id === 'string') { resolve(response.id); } else { this.logger.error('MessageBird verify.create response missing or invalid ID:', response); reject(new InternalServerErrorException('Failed to get a valid verification ID from MessageBird.')); } }); }); } /** * Verifies the OTP token submitted by the user against the verification ID. * @param id The verification ID received from sendOtp. * @param token The 6-digit token entered by the user. * @returns An object indicating successful verification. * @throws BadRequestException if the token is invalid or expired (e.g., MessageBird error code 10). * @throws InternalServerErrorException on other API errors. */ async verifyOtp(id: string, token: string): Promise<{ status: string }> { this.logger.log(`Verifying OTP for ID: ${id}`); return new Promise((resolve, reject) => { this.messagebird.verify.verify(id, token, (err, response) => { if (err) { this.logger.error(`MessageBird verify.verify error for ID ${id}:`, JSON.stringify(err)); // Handle specific errors, e.g., invalid token (code 10) if (err.errors && err.errors.length > 0) { const mbError = err.errors[0]; this.logger.error(`MessageBird Verify Error Code: ${mbError.code}, Description: ${mbError.description}`); if (mbError.code === 10) { // Code 10 typically means invalid token return reject(new BadRequestException('Invalid or expired OTP token.')); } // Fallback for other specific MessageBird errors during verify return reject(new BadRequestException(`Failed to verify OTP: ${mbError.description}`)); } // Generic internal error return reject(new InternalServerErrorException('Failed to verify OTP due to an unexpected error.')); } // Check the status if available if (response && response.status === 'verified') { this.logger.log(`OTP verification successful for ID: ${id}`); resolve({ status: 'verified' }); } else { // Log the actual status received if not 'verified' (e.g., 'expired', 'failed') this.logger.warn(`OTP verification for ID ${id} returned non-verified status: ${response?.status ?? 'unknown'}`); // Treat any non-verified status as a failure from the client's perspective reject(new BadRequestException('Invalid or expired OTP token.')); } }); }); } }Key Implementation Details:
new Promise? The MessageBird SDK uses callbacks. Wrap the callback logic in a Promise to work seamlessly with NestJS's async/await syntax.BadRequestExceptionfor known user-input issues or specific MessageBird error codes (like invalid number code 21, invalid token code 10). UseInternalServerErrorExceptionfor unexpected API failures or missing IDs. Logging the full error (JSON.stringify(err)) and specific codes helps diagnose issues.originatorshould ideally be a purchased virtual number from MessageBird or a short alphanumeric code (max 11 characters, check country-specific restrictions).templatemust include the%tokenplaceholder (verified from MessageBird Verify API documentation at https://developers.messagebird.com/api/verify/).phoneNumber.replace(/[\s\-()]/g, '')strips common characters before sending to MessageBird. For production, use a robust phone number library likelibphonenumber-jsfor comprehensive E.164 format validation and normalization, especially for international formats and country-specific validation.timeout: Range 30–172,801 seconds (default: 30s)tokenLength: Range 6–10 digits (default: 6)maxAttempts: Range 1–10 attempts (default: 1)type: Options aresms,flash,tts(text-to-speech), oremailCommon MessageBird Error Codes:
Register
ConfigServiceinOtpModule: SinceConfigModuleis global andConfigServiceis injected intoOtpService(which is part ofOtpModule), no explicit import ofConfigModuleor registration ofConfigServiceis needed withinOtpModuleitself.// src/otp/otp.module.ts import { Module } from '@nestjs/common'; import { OtpService } from './otp.service'; import { OtpController } from './otp.controller'; // We'll create this next @Module({ controllers: [OtpController], // Add controller here providers: [OtpService], exports: [OtpService], // Export if other modules need it }) export class OtpModule {}4. Building the API Layer (OTP Controller)
Create the controller with endpoints that clients can call. Protect these endpoints with rate limiting and authentication in production.
Generate OTP Controller:
This creates
src/otp/otp.controller.ts(and spec file) and adds it toOtpModule.Define Data Transfer Objects (DTOs): Create DTOs to define the expected request body structure and apply validation rules using
class-validator.Create
src/otp/dto/send-otp.dto.ts:// src/otp/dto/send-otp.dto.ts import { IsNotEmpty, IsPhoneNumber, IsString } from 'class-validator'; export class SendOtpDto { @IsNotEmpty() @IsString() @IsPhoneNumber(null) // Use null for generic E.164 format validation // Input might still contain formatting chars; normalization happens in the service. phoneNumber: string; }About @IsPhoneNumber: This validator accepts E.164 format phone numbers (e.g., +14155552671) but has limitations with formatting variations. Service-level normalization remains necessary to handle spaces, dashes, and parentheses that users might include.
Create
src/otp/dto/verify-otp.dto.ts:// src/otp/dto/verify-otp.dto.ts import { IsNotEmpty, IsString, Length } from 'class-validator'; export class VerifyOtpDto { @IsNotEmpty() @IsString() // Add validation if MessageBird always returns IDs of a specific format/length id: string; @IsNotEmpty() @IsString() @Length(6, 6) // Assuming default 6-digit token token: string; }Implement
OtpController: Opensrc/otp/otp.controller.ts. InjectOtpServiceand define the endpoints.// src/otp/otp.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common'; import { OtpService } from './otp.service'; import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; @Controller('otp') // Route prefix: /otp export class OtpController { private readonly logger = new Logger(OtpController.name); constructor(private readonly otpService: OtpService) {} @Post('send') // Endpoint: POST /otp/send @HttpCode(HttpStatus.OK) // Return 200 OK on success // If ValidationPipe is global, @UsePipes is not needed here // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) async sendOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ id: string }> { this.logger.log(`Received request to send OTP to ${sendOtpDto.phoneNumber}`); const verificationId = await this.otpService.sendOtp(sendOtpDto.phoneNumber); return { id: verificationId }; } @Post('verify') // Endpoint: POST /otp/verify @HttpCode(HttpStatus.OK) // Return 200 OK on successful verification // If ValidationPipe is global, @UsePipes is not needed here // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ status: string }> { this.logger.log(`Received request to verify OTP for ID: ${verifyOtpDto.id}`); return this.otpService.verifyOtp(verifyOtpDto.id, verifyOtpDto.token); } }Endpoint Details:
@Controller('otp'): Sets the base route for all methods in this controller to/otp@Post('send'),@Post('verify'): Define POST endpoints at/otp/sendand/otp/verify@Body(): Injects the request bodyValidationPipe: (Applied globally below) Automatically validates the incoming request body against the DTO@HttpCode(HttpStatus.OK): Ensures a200 OKstatus is returned on successProduction Security:
@nestjs/throttlerpackage)Enable
ValidationPipeGlobally (Recommended): Enable the validation pipe globally insrc/main.tsfor cleaner controllers.// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe, Logger } from '@nestjs/common'; // Import Logger & ValidationPipe async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); // Enable global validation pipe app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not in DTO transform: true, // Transform payload to DTO instances forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are sent transformOptions: { enableImplicitConversion: true, // Allow auto-conversion of types }, })); // Enable CORS - By default allows all origins. // **IMPORTANT**: Restrict origins in production for security! app.enableCors(); // Example for production: // app.enableCors({ origin: 'https://your-frontend-app.com' }); logger.log('CORS enabled. Remember to restrict origins in production.'); const port = process.env.PORT || 3000; await app.listen(port); logger.log(`Application listening on port ${port}`); } bootstrap();With the global pipe enabled, remove the
@UsePipes(...)decorators from the controller methods (as shown in the controller example above).Production Deployment Checklist:
helmetfor security headersTesting Endpoints with
curl:Start the application:
npm run start:devSend OTP: Replace
+12345678900with a real phone number (use a test number if using your test API key).curl -X POST http://localhost:3000/otp/send \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+12345678900" }'Expected Response (Success):
{ "id": "some-verification-id-from-messagebird" }Expected Response (Validation Error – e.g., invalid number format):
{ "statusCode": 400, "message": [ "phoneNumber must be a valid phone number" ], "error": "Bad Request" }Verify OTP: Replace
some-verification-id-from-messagebirdwith the ID you received, and123456with the code sent to your phone (or the test code provided by MessageBird if using test keys).curl -X POST http://localhost:3000/otp/verify \ -H "Content-Type: application/json" \ -d '{ "id": "some-verification-id-from-messagebird", "token": "123456" }'Expected Response (Success):
{ "status": "verified" }Expected Response (Incorrect Token):
{ "statusCode": 400, "message": "Invalid or expired OTP token.", "error": "Bad Request" }5. Integrating with Third-Party Services (MessageBird)
Core integration points:
.env(use Test key for dev, Live key for prod) and load via@nestjs/config. Ensure the.envfile is never committed to version control (add it to.gitignore).messagebirdSDK in theOtpServiceconstructor using the API key fromConfigService. The type assertionas unknown as MessageBird.MessageBirdis included due to potential SDK typing nuances.verify.createandverify.verifymethods withinOtpServiceto interact with MessageBird.OtpServicearound the MessageBird calls, especially for transient network errors (distinguish from user errors like invalid tokens).type: 'tts'(Text-to-Speech). ModifysendOtpto offer this if SMS fails or as a preference.MessageBird Pricing Considerations:
Geographic Coverage: MessageBird supports SMS delivery to 200+ countries. Check country-specific restrictions and compliance requirements (e.g., sender ID registration, content filtering) before deploying internationally.
6. Implementing Error Handling and Logging
NestJS provides robust mechanisms for handling errors and logging.
Error Handling Strategy:
ValidationPipehandles these automatically, returning400 Bad Request.OtpServicethrows specificHttpExceptionsubclasses (BadRequestException,InternalServerErrorException) based on MessageBird API responses or internal issues. Specific MessageBird error codes (e.g., 10, 21) map toBadRequestException.500 Internal Server Error.Custom Exception Filters: Create custom exception filters to format error responses consistently or integrate with error tracking services like Sentry. Example:
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; import { Response } from 'express'; @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const status = exception.getStatus(); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), message: exception.message, }); } }Logging:
Logger(@nestjs/common) inOtpService,OtpController, andmain.ts.OtpServicelogs specific MessageBird errors.pino,nestjs-pino) for easier parsing by log aggregation toolsRetry Mechanisms (Conceptual Example): Add retries to
OtpServicecalls to improve resilience against transient network issues.// Simplified conceptual retry logic for OtpService methods // Consider using a library like 'nestjs-retry' or 'async-retry' for production // Define logger at a scope accessible by isTransientError if needed, or pass it const logger = new Logger('RetryLogic'); async function callWithRetry<T>(action: () => Promise<T>, maxAttempts: number, logger: Logger): Promise<T> { let attempts = 0; while (attempts < maxAttempts) { attempts++; try { return await action(); // Attempt the action } catch (error) { logger.error(`Attempt ${attempts} failed: ${error.message}`, error.stack); // Check if the error is potentially transient AND if more attempts remain if (isTransientError(error) && attempts < maxAttempts) { const delay = 1000 * Math.pow(2, attempts - 1); // Exponential backoff logger.warn(`Retrying after ${delay}ms…`); await new Promise(resolve => setTimeout(resolve, delay)); continue; // Go to the next iteration } else { // If not transient or max attempts reached, re-throw the error throw error; } } } // This line should theoretically not be reached if maxAttempts > 0 throw new InternalServerErrorException('Action failed after maximum retry attempts.'); } // **Placeholder Function - Requires Real Implementation** // This function MUST be implemented to identify errors that justify a retry // (e.g., network timeouts, specific 5xx errors from MessageBird if identifiable). // Do NOT retry on errors like 'invalid phone number' or 'invalid token'. function isTransientError(error: any): boolean { logger.warn('`isTransientError` check is currently a placeholder and always returns false. Implement real transient error detection logic.'); // Example checks (modify based on actual error structure and codes): // if (error?.status === 503) return true; // Service Unavailable // if (error?.code === 'ETIMEDOUT' || error?.code === 'ENETUNREACH') return true; // Network issues // if (error?.errors?.[0]?.code === SOME_MESSAGEBIRD_TRANSIENT_CODE) return true; return false; // Default: Do not retry } // Usage within OtpService (Example for sendOtp): // Assume OtpService class context for 'this.logger', 'this.messagebird' etc. /* async sendOtp(phoneNumber: string): Promise<string> { const normalizedPhoneNumber = phoneNumber.replace(/[\s\-()]/g, ''); this.logger.log(`Sending OTP to normalized number: ${normalizedPhoneNumber}`); const action = () => new Promise<string>((resolve, reject) => { const params: MessageBird.VerifyCreateParams = { originator: 'VerifyApp', template: 'Your verification code is %token.', type: 'sms', }; this.messagebird.verify.create(normalizedPhoneNumber, params, (err, response) => { if (err) return reject(err); // Reject promise on error to be caught by retry logic if (response && typeof response.id === 'string') { resolve(response.id); } else { reject(new InternalServerErrorException('Failed to get a valid verification ID from MessageBird.')); } }); }); try { // Wrap the promise-based SDK call within the retry function // Important: The retry function handles the actual SDK error mapping (BadRequest vs InternalServer) return await callWithRetry(action, 3, this.logger); } catch (error) { // Map final error after retries (if any occurred) this.logger.error('Final error after retries (or on first attempt if non-transient):', JSON.stringify(error)); if (error instanceof HttpException) { throw error; // Re-throw exceptions already mapped (like BadRequest) } // Map specific MessageBird errors after retries failed if (error.errors && error.errors.length > 0) { const mbError = error.errors[0]; if (mbError.code === 21) { throw new BadRequestException(`Invalid phone number: ${mbError.description}`); } throw new BadRequestException(`Failed to send OTP: ${mbError.description}`); } // Fallback generic error throw new InternalServerErrorException('Failed to initiate OTP verification after multiple attempts.'); } } // Apply similar retry logic structure to verifyOtp if needed. */Caveat: The
isTransientErrorfunction is critical and provided as a non-working placeholder. You must implement logic to correctly identify which errors (based on codes, status, or types) should trigger a retry. Retrying on user errors (like invalid number) is incorrect. The usage example withinsendOtpis commented out to avoid making the code block non-functional due to the placeholderisTransientErrorand context assumptions.Circuit Breaker Pattern: Protect your application from cascading failures when MessageBird is down. Use the circuit breaker pattern (libraries like
opossumornestjs-circuit-breaker) to temporarily stop sending requests to a failing service and provide fallback responses.7. (Optional) Creating a Database Schema and Data Layer (Prisma Example)
This section is optional and only relevant if you need to store user data or link verified numbers.
Initialize Prisma: If not done in Section 1, install Prisma dependencies and initialize:
# Install if needed: npm install prisma @prisma/client --save-dev npx prisma init --datasource-provider postgresqlEnsure your
.envhas the correctDATABASE_URL.Create User and Verification Schema:
Run Migrations:
Integration Example: Track verification attempts for audit trails, implement cooldown periods between OTP requests, and link verified phone numbers to user accounts:
// In OtpService after successful verification: await this.prisma.verification.update({ where: { verificationId: id }, data: { status: 'verified', verifiedAt: new Date(), }, }); await this.prisma.user.update({ where: { phoneNumber }, data: { isVerified: true }, });Source Citations: