Sending SMS messages reliably and at scale is a common requirement for applications needing notifications, marketing outreach, or user verification. While numerous providers exist, integrating them efficiently requires careful planning, robust error handling, and a scalable architecture.
This guide provides a complete, step-by-step walkthrough for building a production-ready bulk SMS sending service using the NestJS framework and the Sinch SMS API. We'll cover everything from initial project setup and configuration to implementing core sending logic, handling API responses, ensuring security, and preparing for deployment.
By the end of this tutorial, you will have a functional NestJS application capable of accepting requests to send SMS messages to multiple recipients via the Sinch API, complete with logging, validation, and error handling.
Project Overview and Goals
What We're Building
We will create a dedicated NestJS microservice (or module within a larger application) that exposes a secure API endpoint. This endpoint will accept a list of recipient phone numbers and a message body, then utilize the Sinch REST API to send the message to all specified recipients in a single batch request.
Problems Solved
- Centralized SMS Logic: Encapsulates all Sinch interaction logic in one place, making it maintainable and reusable.
- Scalable Sending: Leverages Sinch's batch API for efficient bulk messaging.
- Simplified Integration: Provides a clean API interface for other services or frontends to trigger SMS sending without needing direct Sinch credentials or implementation details.
- Robustness: Includes essential features like configuration management, validation, logging, and error handling.
Technologies Used
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture, dependency injection, and built-in support for features like configuration and validation make it ideal.
- Sinch SMS API: A powerful REST API for sending and managing SMS messages globally. We'll focus on its batch sending capabilities.
- Node.js: The underlying JavaScript runtime.
- TypeScript: Enhances JavaScript with static typing for better code quality and maintainability.
- Axios (via
@nestjs/axios
): For making HTTP requests to the Sinch API. @nestjs/config
: For managing environment variables and configuration.class-validator
&class-transformer
: For robust request data validation.nestjs-throttler
: For basic rate limiting.
System Architecture
graph LR
Client[Client Application / API Consumer] -->|1. POST /messages/bulk (recipients, message)| NestJS_API[NestJS Bulk SMS Service];
NestJS_API -->|2. Validate Request| NestJS_API;
NestJS_API -->|3. Call SinchService.sendBulkSms| SinchService[Sinch Service Module];
SinchService -->|4. POST /xms/v1/{servicePlanId}/batches (Sinch Payload)| SinchAPI[Sinch SMS REST API];
SinchAPI -->|5. Batch Response (ID, Status)| SinchService;
SinchService -->|6. Processed Response| NestJS_API;
NestJS_API -->|7. API Response (Success/Error)| Client;
subgraph NestJS Bulk SMS Service
direction LR
MessagingController[Messaging Controller] --> ValidationPipe[Validation Pipe]
ValidationPipe --> SinchService
SinchService --> |Uses| HttpService[Http Module (Axios)]
SinchService --> |Uses| ConfigService[Config Module]
SinchService --> |Uses| LoggerService[Logger Module]
end
style NestJS_API fill:#f9f,stroke:#333,stroke-width:2px
style SinchAPI fill:#ccf,stroke:#333,stroke-width:2px
Prerequisites
- Node.js (LTS version recommended, e.g., v18 or v20)
npm
oryarn
package manager- A Sinch account (https://dashboard.sinch.com/signup)
- A provisioned Sinch phone number or Alphanumeric Sender ID
- Your Sinch Service Plan ID and API Token
- A code editor (e.g., VS Code)
- Basic understanding of TypeScript, Node.js, REST APIs, and NestJS fundamentals.
1. Setting up the NestJS Project
Let's start by creating a new NestJS project using the Nest CLI.
-
Install NestJS CLI: If you don't have it installed globally, run:
npm install -g @nestjs/cli # or yarn global add @nestjs/cli
-
Create New Project: Navigate to your desired development directory in your terminal and run:
nest new sinch-bulk-sms-service
Choose your preferred package manager (
npm
oryarn
) when prompted. -
Navigate into Project Directory:
cd sinch-bulk-sms-service
-
Initial Project Structure: The Nest CLI generates a standard project structure:
sinch-bulk-sms-service/ ├── node_modules/ ├── src/ │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts ├── test/ ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── nest-cli.json ├── package.json ├── README.md ├── tsconfig.build.json └── tsconfig.json
We will build upon this structure by adding modules for configuration, Sinch integration, and messaging endpoints.
-
Install Necessary Dependencies: We need modules for configuration management, making HTTP requests, and validation.
npm install @nestjs/config @nestjs/axios axios class-validator class-transformer nestjs-throttler # or yarn add @nestjs/config @nestjs/axios axios class-validator class-transformer nestjs-throttler
2. Configuration and Environment
Proper configuration management is crucial, especially for handling sensitive API credentials. We'll use @nestjs/config
to load environment variables from a .env
file.
-
Create
.env
and.env.example
files: In the project root directory, create two files:.env
: This file will store your actual secrets and configuration. Do not commit this file to Git..env.example
: This file serves as a template showing required variables. Commit this file.
.env.example
:# Sinch API Configuration # Use the correct regional base URL (e.g., https://us.sms.api.sinch.com, https://eu.sms.api.sinch.com). Do NOT include /xms/v1/{service_plan_id} here. SINCH_API_URL=https://us.sms.api.sinch.com SINCH_SERVICE_PLAN_ID= SINCH_API_TOKEN= SINCH_FROM_NUMBER= # Application Configuration PORT=3000
.env
:# Sinch API Configuration # Use the correct regional base URL. Do NOT include /xms/v1/{service_plan_id} here. SINCH_API_URL=https://us.sms.api.sinch.com # Or your region's URL SINCH_SERVICE_PLAN_ID=YOUR_ACTUAL_SERVICE_PLAN_ID SINCH_API_TOKEN=YOUR_ACTUAL_API_TOKEN SINCH_FROM_NUMBER=YOUR_SINCH_VIRTUAL_NUMBER_OR_SENDER_ID # e.g., +12025550101 # Application Configuration PORT=3000
-
How to Obtain Sinch Credentials:
- Login: Go to the Sinch Customer Dashboard.
- Navigate to SMS API: Find the SMS product section, often under ""Products"" > ""SMS"".
- API Credentials: Look for a section named ""API Credentials"", ""REST API"", or similar. Here you should find:
- Service Plan ID: A unique identifier for your service plan. You'll need this for the
SINCH_SERVICE_PLAN_ID
variable. - API Token: An authentication token (sometimes called API Secret or Auth Token). Generate one if needed. Treat this like a password. You'll need this for the
SINCH_API_TOKEN
variable.
- Service Plan ID: A unique identifier for your service plan. You'll need this for the
- Base URL: The base URL for the API depends on your account's region (e.g.,
us.sms.api.sinch.com
,eu.sms.api.sinch.com
). Find this in the API documentation specific to your account or within the credentials section. This value is for theSINCH_API_URL
variable and should not include the/xms/v1/
path or your Service Plan ID – the application code will add those parts. - From Number: Navigate to the ""Numbers"" section of your dashboard to see your active virtual numbers or configured Alphanumeric Sender IDs. Choose the one you want to send messages from. Ensure it's in the correct format (e.g., E.164 like
+12025550101
for numbers). This is for theSINCH_FROM_NUMBER
variable.
-
Update
.gitignore
: Ensure.env
is listed in your.gitignore
file to prevent accidentally committing secrets:.gitignore
:# .gitignore node_modules dist .env
-
Load Configuration in
AppModule
: Modifysrc/app.module.ts
to import and configure theConfigModule
.src/app.module.ts
:import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; // Import other modules here later @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Makes ConfigModule available globally envFilePath: '.env', // Specifies the env file path }), // Add other modules (SinchModule, MessagingModule) here later ], controllers: [AppController], // We might remove these later providers: [AppService], // We might remove these later }) export class AppModule {}
Setting
isGlobal: true
means we don't need to importConfigModule
into other modules explicitly to useConfigService
.
3. Implementing the Sinch Service
Let's create a dedicated module and service to handle all interactions with the Sinch API. This section covers the core integration, including authentication, request/response handling, and error management.
-
Generate Sinch Module and Service:
nest g module sinch nest g service sinch
This creates
src/sinch/sinch.module.ts
andsrc/sinch/sinch.service.ts
. -
Configure
HttpModule
: We'll use@nestjs/axios
(which wraps Axios) to make HTTP calls. We configure it asynchronously within theSinchModule
to inject theConfigService
and set the base regional URL and authentication headers dynamically.src/sinch/sinch.module.ts
:import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { HttpModule } from '@nestjs/axios'; import { SinchService } from './sinch.service'; @Module({ imports: [ ConfigModule, // Import if not global, needed for ConfigService injection HttpModule.registerAsync({ imports: [ConfigModule], // Import ConfigModule here too useFactory: async (configService: ConfigService) => ({ baseURL: configService.get<string>('SINCH_API_URL'), // Base regional URL from .env headers: { 'Authorization': `Bearer ${configService.get<string>('SINCH_API_TOKEN')}`, // Auth header 'Content-Type': 'application/json', }, timeout: 5000, // Optional: Set request timeout }), inject: [ConfigService], // Inject ConfigService into the factory }), ], providers: [SinchService], exports: [SinchService], // Export service to be used in other modules }) export class SinchModule {}
HttpModule.registerAsync
: Allows dynamic configuration using dependencies likeConfigService
.baseURL
: Sets the root regional URL (e.g.,https://us.sms.api.sinch.com
) for all requests made via thisHttpModule
instance. The specific API path (/xms/v1/...
) will be added in the service.headers
: Sets default headers, including the crucialAuthorization
bearer token.exports
: MakesSinchService
available for injection into other modules (like our upcomingMessagingModule
).
-
Implement
SinchService
Logic: Now, we implement the core method to send bulk SMS messages.src/sinch/sinch.service.ts
:import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom, map, catchError } from 'rxjs'; import { AxiosError } from 'axios'; // Interface matching the expected Sinch API response for batch send // NOTE: Verify this against the official Sinch API documentation for the /batches endpoint. interface SinchBatchResponse { id: string; to: string[]; from: string; canceled: boolean; body: string; type: string; created_at: string; modified_at: string; delivery_report: string; // Add other relevant fields from Sinch docs if needed } @Injectable() export class SinchService { private readonly logger = new Logger(SinchService.name); private readonly sinchFromNumber: string; private readonly servicePlanId: string; private readonly sinchApiUrl: string; constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, ) { // Fetch config values once during instantiation this.sinchFromNumber = this.configService.get<string>('SINCH_FROM_NUMBER'); this.servicePlanId = this.configService.get<string>('SINCH_SERVICE_PLAN_ID'); this.sinchApiUrl = this.configService.get<string>('SINCH_API_URL'); // Base URL if (!this.sinchFromNumber || !this.servicePlanId || !this.sinchApiUrl) { this.logger.error('Sinch configuration incomplete. Check SINCH_FROM_NUMBER, SINCH_SERVICE_PLAN_ID, and SINCH_API_URL environment variables.'); // Use specific NestJS exception throw new InternalServerErrorException('Sinch configuration is incomplete. Check environment variables.'); } } /** * Sends a bulk SMS message using the Sinch batch API. * @param recipients - Array of phone numbers in E.164 format (e.g., +12025550101). * @param messageBody - The text content of the SMS. * @returns The Sinch batch response upon successful submission. * @throws Error (or specific NestJS Exception) if the API call fails or configuration is missing. */ async sendBulkSms(recipients: string[], messageBody: string): Promise<SinchBatchResponse> { if (!recipients || recipients.length === 0) { // Consider throwing BadRequestException if called directly from controller context, // but generic Error is okay for service layer internal validation. throw new Error('Recipient list cannot be empty.'); } if (!messageBody) { throw new Error('Message body cannot be empty.'); } const payload = { from: this.sinchFromNumber, to: recipients, body: messageBody, // Add other optional parameters here if needed, e.g.: // delivery_report: 'summary', // type: 'mt_text', // Default for text messages }; // Construct the full path including the service plan ID const endpointPath = `/xms/v1/${this.servicePlanId}/batches`; const fullUrl = `${this.sinchApiUrl}${endpointPath}`; // For logging this.logger.log(`Sending bulk SMS to ${recipients.length} recipients via Sinch`); this.logger.debug(`Sinch API Endpoint: ${fullUrl}`); this.logger.debug(`Sinch Payload: ${JSON.stringify(payload)}`); try { // Use firstValueFrom to convert Observable to Promise const response = await firstValueFrom( // Make POST request to the dynamically constructed path this.httpService.post<SinchBatchResponse>(endpointPath, payload).pipe( map((axiosResponse) => { this.logger.log(`Sinch API call successful. Batch ID: ${axiosResponse.data.id}`); return axiosResponse.data; // Return the data part of the response }), catchError((error: AxiosError) => { this.logger.error(`Sinch API Error: ${error.message}`, error.stack); this.logger.error(`Sinch Response Status: ${error.response?.status}`); this.logger.error(`Sinch Response Data: ${JSON.stringify(error.response?.data)}`); // Throw a NestJS exception for better handling upstream const status = error.response?.status || 500; const errorMessage = `Sinch API request failed with status ${status}: ${JSON.stringify(error.response?.data || error.message)}`; // Could map specific statuses (401, 403 etc.) to specific exceptions if needed // For now, wrap in InternalServerErrorException throw new InternalServerErrorException(errorMessage); }), ), ); return response; } catch (error) { // Log if the error wasn't already logged in catchError (e.g., input validation errors) if (!(error instanceof InternalServerErrorException)) { this.logger.error(`Failed to send bulk SMS (Pre-request or unexpected): ${error.message}`, error.stack); } // Ensure the error propagates up (might be caught by controller) throw error; } } }
- Logger: Uses NestJS's built-in
Logger
. - Constructor: Injects
HttpService
andConfigService
. Fetches required config values (SINCH_FROM_NUMBER
,SINCH_SERVICE_PLAN_ID
,SINCH_API_URL
) early and throwsInternalServerErrorException
if any are missing. sendBulkSms
Method:- Takes recipients array and message body.
- Performs basic input checks (throwing generic
Error
here is acceptable as the controller should catch it). - Constructs the
payload
object. - Constructs the API
endpointPath
dynamically using theservicePlanId
fetched in the constructor (/xms/v1/${this.servicePlanId}/batches
). - Uses
this.httpService.post
with the relativeendpointPath
. The base URL (SINCH_API_URL
) is automatically prepended by theHttpModule
. - Uses
firstValueFrom
to convert the RxJS Observable to a Promise. - Uses
.pipe()
with RxJS operators:map
: Extracts thedata
from the successful Axios response.catchError
: Handles Axios errors. It logs detailed information and now throws a NestJSInternalServerErrorException
wrapping the Sinch error details.
- Includes a final
catch
block to handle errors thrown before the HTTP call (like input validation) or other unexpected issues.
- Logger: Uses NestJS's built-in
-
Import
SinchModule
intoAppModule
: Make theSinchModule
(and thusSinchService
) available to the application.src/app.module.ts
:import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { SinchModule } from './sinch/sinch.module'; // Import SinchModule // Import other modules here later @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), SinchModule, // Add SinchModule here // Add MessagingModule later ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
4. Building the API Layer
Now, let's create the controller and DTO (Data Transfer Object) to expose an endpoint for triggering the bulk SMS send.
-
Generate Messaging Module and Controller:
nest g module messaging nest g controller messaging
This creates
src/messaging/messaging.module.ts
andsrc/messaging/messaging.controller.ts
. -
Create Bulk SMS DTO: Data Transfer Objects define the expected shape of request bodies and enable automatic validation using
class-validator
.Create a file
src/messaging/dto/bulk-sms.dto.ts
:src/messaging/dto/bulk-sms.dto.ts
:import { IsArray, ArrayNotEmpty, ArrayMinSize, IsString, IsPhoneNumber, // Validates E.164 format (approximately) MinLength, MaxLength, } from 'class-validator'; export class BulkSmsDto { @IsArray() @ArrayNotEmpty() @ArrayMinSize(1) @IsPhoneNumber(undefined, { each: true, message: 'Each recipient must be a valid E.164 phone number (e.g., +12025550101)' }) recipients: string[]; @IsString() @MinLength(1, { message: 'Message body cannot be empty.' }) @MaxLength(1600, { message: 'Message body too long (max 1600 characters).' }) // Adjust based on practical limits/concatenation rules messageBody: string; }
@IsArray
,@ArrayNotEmpty
,@ArrayMinSize(1)
: Ensurerecipients
is a non-empty array.@IsPhoneNumber(undefined, { each: true, ... })
: Validates each element in therecipients
array. It checks for a format generally resembling E.164 (starts with '+', followed by digits). Note: This is a basic check; true phone number validity requires more complex lookups.@IsString
,@MinLength(1)
,@MaxLength(1600)
: EnsuremessageBody
is a non-empty string within reasonable length limits.
-
Implement
MessagingController
: Define the API endpoint (POST /messages/bulk
) that accepts the DTO and usesSinchService
to send the messages.src/messaging/messaging.controller.ts
:import { Controller, Post, Body, HttpCode, HttpStatus, Logger, InternalServerErrorException, BadRequestException } from '@nestjs/common'; import { SinchService } from '../sinch/sinch.service'; import { BulkSmsDto } from './dto/bulk-sms.dto'; @Controller('messages') // Route prefix: /messages export class MessagingController { private readonly logger = new Logger(MessagingController.name); constructor(private readonly sinchService: SinchService) {} @Post('bulk') // Route: POST /messages/bulk @HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted on successful submission async sendBulkSms(@Body() bulkSmsDto: BulkSmsDto) { this.logger.log(`Received request to send bulk SMS to ${bulkSmsDto.recipients.length} recipients.`); try { const result = await this.sinchService.sendBulkSms( bulkSmsDto.recipients, bulkSmsDto.messageBody, ); this.logger.log(`Successfully submitted bulk SMS batch. Batch ID: ${result.id}`); return { message: 'Bulk SMS batch submitted successfully.', batchId: result.id, submittedAt: result.created_at, }; } catch (error) { this.logger.error(`Failed to process bulk SMS request: ${error.message}`, error.stack); // Handle specific errors thrown by the service or re-throw appropriate HTTP exceptions if (error.message.includes('Recipient list cannot be empty') || error.message.includes('Message body cannot be empty')) { // These are service-level validation errors caught before API call throw new BadRequestException(error.message); } // If it's already a NestJS exception from SinchService (like InternalServerErrorException), re-throw it. if (error instanceof InternalServerErrorException || error instanceof BadRequestException) { throw error; } // For other generic errors, throw InternalServerErrorException throw new InternalServerErrorException('Failed to send bulk SMS. Please check logs for details.'); } } }
@Controller('messages')
: Sets the base route for this controller.@Post('bulk')
: Defines a POST endpoint at/messages/bulk
.@HttpCode(HttpStatus.ACCEPTED)
: Sets the default success status code to 202.@Body() bulkSmsDto: BulkSmsDto
: Injects and validates the request body.- Injects
SinchService
. - Calls
sinchService.sendBulkSms
. - Includes
try/catch
for robust error handling, mapping service errors to appropriate HTTP exceptions (BadRequestException
for input issues detected in the service, re-throwingInternalServerErrorException
from the service, or throwing a new one for unexpected errors). - Returns a meaningful success response including the Sinch
batchId
.
-
Import
SinchModule
intoMessagingModule
: TheMessagingController
depends onSinchService
.src/messaging/messaging.module.ts
:import { Module } from '@nestjs/common'; import { MessagingController } from './messaging.controller'; import { SinchModule } from '../sinch/sinch.module'; // Import SinchModule @Module({ imports: [SinchModule], // Make SinchService available here controllers: [MessagingController], providers: [], // No specific providers needed in this module }) export class MessagingModule {}
-
Import
MessagingModule
intoAppModule
: Make theMessagingModule
known to the main application module. Add rate limiting. Remove default controller/service if desired.src/app.module.ts
:import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { SinchModule } from './sinch/sinch.module'; import { MessagingModule } from './messaging/messaging.module'; // Import MessagingModule import { ThrottlerModule, ThrottlerGuard } from 'nestjs-throttler'; // Import Throttler import { APP_GUARD } from '@nestjs/core'; // Import APP_GUARD @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), // Configure Rate Limiting Globally ThrottlerModule.forRoot([{ ttl: 60000, // Time-to-live in milliseconds (e.g., 60 seconds) limit: 10, // Max requests per TTL per IP }]), SinchModule, MessagingModule, // Add MessagingModule here ], controllers: [], // Remove AppController if not needed providers: [ // Apply ThrottlerGuard globally to all endpoints { provide: APP_GUARD, useClass: ThrottlerGuard, }, // Remove AppService if not needed ], }) export class AppModule {}
- We added
ThrottlerModule
configuration and registeredThrottlerGuard
globally.
- We added
5. Error Handling and Logging
We've incorporated logging and error handling. Let's review the strategy.
-
Logging:
- Uses NestJS's built-in
Logger
. - Logs occur in
SinchService
(API calls, request/response details, errors) andMessagingController
(request lifecycle). - Best Practice: Enhance production logging (JSON format, centralized logging service). Use correlation IDs. Pay attention to log levels.
- Example Log Analysis: Check logs for
Sinch API Error
,Sinch Response Status
, andSinch Response Data
fromSinchService
when troubleshooting failed batches.
- Uses NestJS's built-in
-
Error Handling Strategy:
- Validation Errors (DTO): Handled automatically by the
ValidationPipe
(Section 6), returning 400 Bad Request. - Service Input Errors (
SinchService
): Basic checks (e.g., empty recipients) throwError
. - Sinch API Errors (
SinchService
):catchError
intercepts Axios errors, logs details, and throwsInternalServerErrorException
containing Sinch status and response data. - Controller Errors (
MessagingController
): Catches errors fromSinchService
. Maps service input errors toBadRequestException
. Re-throws NestJS exceptions from the service (likeInternalServerErrorException
). Catches other unexpected errors and throwsInternalServerErrorException
. - Consistency: Uses standard NestJS HTTP exceptions for clear API responses.
- Validation Errors (DTO): Handled automatically by the
-
Retry Mechanisms (Optional but Recommended):
- Consider retrying transient network issues or Sinch 5xx errors.
- Use libraries like
async-retry
. Install with types:npm i async-retry @types/async-retry
oryarn add async-retry @types/async-retry
. - Caution: Only retry on retriable errors (network issues, 5xx). Do not retry 4xx errors. Implement exponential backoff.
Example Sketch (using
async-retry
inSinchService
):// Inside src/sinch/sinch.service.ts // NOTE: Ensure you install async-retry and its types: // npm install async-retry @types/async-retry // or // yarn add async-retry @types/async-retry import * as retry from 'async-retry'; import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom, map, catchError } from 'rxjs'; import { AxiosError } from 'axios'; // ... (Keep SinchBatchResponse interface and other parts of the class) @Injectable() export class SinchService { // ... (Keep logger, properties, constructor, and original sendBulkSms) async sendBulkSmsWithRetry(recipients: string[], messageBody: string): Promise<SinchBatchResponse> { // ... input validation (same as sendBulkSms) ... if (!recipients || recipients.length === 0) throw new Error('Recipient list cannot be empty.'); if (!messageBody) throw new Error('Message body cannot be empty.'); const payload = { from: this.sinchFromNumber, to: recipients, body: messageBody, }; const endpointPath = `/xms/v1/${this.servicePlanId}/batches`; return retry(async (bail, attempt) => { this.logger.log(`Attempt ${attempt} to send bulk SMS via Sinch`); try { const response = await firstValueFrom( this.httpService.post<SinchBatchResponse>(endpointPath, payload).pipe( map(res => res.data), catchError((error: AxiosError) => { const status = error.response?.status; // Decide if we should retry or bail // Bail on 4xx client errors (except maybe 429 if you want to retry rate limits carefully) if (status && status >= 400 && status < 500 && status !== 429) { this.logger.warn(`Non-retriable Sinch API Error (Status ${status}). Bailing.`); // Construct the error to be thrown by bail const bailError = new InternalServerErrorException(`Sinch API request failed with status ${status}: ${JSON.stringify(error.response?.data || error.message)}`); bail(bailError); // bail throws a special error to stop retrying // bail() throws_ so we technically don't reach here_ but return undefined for type safety if needed. // Depending on strict TS settings_ you might need a `throw bailError` after `bail(bailError)`. return undefined as never; // Or throw bailError } this.logger.warn(`Retriable Sinch API Error (Status ${status || 'N/A'}) or Network Error. Retrying...`); // Throw the original AxiosError or a wrapped NestJS exception to trigger retry throw new InternalServerErrorException(`Sinch API request failed on attempt ${attempt}: ${error.message}`_ error.stack); })_ )_ ); this.logger.log(`Sinch API call successful on attempt ${attempt}. Batch ID: ${response.id}`); return response; } catch (error) { // Log error for the failed attempt before retry or final failure this.logger.error(`Attempt ${attempt} failed: ${error.message}`_ error.stack); // Re-throw error if bail wasn't called_ to potentially retry or fail finally throw error; } }_ { retries: 3_ // Number of retries factor: 2_ // Exponential backoff factor minTimeout: 1000_ // Initial timeout in ms onRetry: (error_ attempt) => { this.logger.warn(`Retrying Sinch API call (Attempt ${attempt}) due to error: ${error.message}`); }, }); } }
Remember to update the
MessagingController
to callsendBulkSmsWithRetry
instead ofsendBulkSms
if you implement this retry logic.
6. Request Validation (DTOs and ValidationPipe)
We created the BulkSmsDto
with class-validator
decorators. Now, enable the ValidationPipe
globally.
-
Enable
ValidationPipe
Globally: Modifysrc/main.ts
.src/main.ts
:import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe, Logger } from '@nestjs/common'; // Import ValidationPipe and Logger import { ConfigService } from '@nestjs/config'; // Import ConfigService async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); // Get ConfigService instance // Enable CORS if needed (adjust origins for production) app.enableCors(); // Apply ValidationPipe globally app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not in DTO forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present transform: true, // Automatically transform payloads to DTO instances transformOptions: { enableImplicitConversion: true, // Allow basic type conversions }, })); const port = configService.get<number>('PORT') || 3000; // Get port from env await app.listen(port); Logger.log(`Application is running on: http://localhost:${port}`, 'Bootstrap'); // Use Logger } bootstrap();
- We import
ValidationPipe
andLogger
. - We use
app.useGlobalPipes()
to apply theValidationPipe
. whitelist: true
: Automatically removes properties from the request body that are not defined in the DTO.forbidNonWhitelisted: true
: Throws an error if properties not defined in the DTO are present.transform: true
: Attempts to transform the incoming payload to match the DTO types (e.g., string to number if expected).transformOptions: { enableImplicitConversion: true }
: Helps with basic type conversions during transformation.- We also import
ConfigService
andLogger
to get the port from environment variables and log the startup message correctly.
- We import