code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Build Bulk SMS Broadcasting with Sinch and NestJS: Complete TypeScript Implementation Guide

Learn how to implement production-ready bulk SMS broadcasting in NestJS using Sinch's messaging API. Complete tutorial covering dependency injection, validation, error handling, rate limiting, and deployment with TypeScript code examples.

Build Bulk SMS Broadcasting with Sinch and NestJS: Complete TypeScript Implementation Guide

Send SMS messages reliably and at scale for applications needing notifications, marketing outreach, or user verification. While numerous providers exist, integrate them efficiently with careful planning, robust error handling, and 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. You'll learn 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'll build 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 You're Building

Create a dedicated NestJS microservice (or module within a larger application) that exposes a secure API endpoint. This endpoint accepts a list of recipient phone numbers and a message body, then utilizes the Sinch REST API to send the message to all specified recipients in a single batch request.

Problems You'll Solve

  • Centralized SMS Logic: Encapsulate all Sinch interaction logic in one place, making it maintainable and reusable.
  • Scalable Sending: Leverage Sinch's batch API for efficient bulk messaging.
  • Simplified Integration: Provide a clean API interface for other services or frontends to trigger SMS sending without needing direct Sinch credentials or implementation details.
  • Robustness: Include 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 (DI), and built-in support for features like configuration and validation make it ideal for SMS messaging integration. Current stable version: v11.1.6 (released August 2025) includes improved ConsoleLogger with better formatting for nested objects and JSON logging support, microservices enhancements, and performance improvements. Reference: NestJS Releases
  • Sinch SMS API: A powerful REST API for sending and managing SMS messages globally. Focus on its batch sending capabilities using the /xms/v1/{service_plan_id}/batches endpoint. The batch API supports up to 1,000 recipients per request (increased from 100 in October 2019). Reference: Sinch SMS API Batches
  • 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

mermaid
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 (v22 LTS recommended – Active LTS until October 2025, then Maintenance LTS until April 2027. Reference: Node.js Release Schedule)
  • npm or yarn 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. Set Up Your NestJS Project

Start by creating a new NestJS project using the Nest CLI.

  1. Install NestJS CLI: If you don't have it installed globally, run:

    bash
    npm install -g @nestjs/cli
    # or
    yarn global add @nestjs/cli
  2. Create New Project: Navigate to your desired development directory in your terminal and run:

    bash
    nest new sinch-bulk-sms-service

    Choose your preferred package manager (npm or yarn) when prompted.

  3. Navigate into Project Directory:

    bash
    cd sinch-bulk-sms-service
  4. Initial Project Structure: The Nest CLI generates a standard project structure:

    text
    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

    Build upon this structure by adding modules for configuration, Sinch integration, and messaging endpoints.

  5. Install Necessary Dependencies: Install modules for configuration management, making HTTP requests, and validation.

    bash
    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. Configure Environment and Credentials

Proper configuration management is crucial, especially for handling sensitive API credentials. Use @nestjs/config to load environment variables from a .env file.

  1. Create .env and .env.example files: In the project root directory, create two files:

    • .env: Store your actual secrets and configuration. Do not commit this file to Git.
    • .env.example: Serves as a template showing required variables. Commit this file.

    .env.example:

    dotenv
    # Sinch API Configuration
    # Regional base URLs: https://us.sms.api.sinch.com (US), https://eu.sms.api.sinch.com (EU)
    # Choose based on data residency requirements. 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:

    dotenv
    # Sinch API Configuration
    # Regional base URLs: https://us.sms.api.sinch.com (US), https://eu.sms.api.sinch.com (EU)
    # Choose based on data residency requirements. Do NOT include /xms/v1/{service_plan_id} here.
    SINCH_API_URL=https://us.sms.api.sinch.com # Or eu.sms.api.sinch.com for EU region
    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
  2. Obtain Your 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 "API Credentials," "REST API," or similar. Here you'll find:
      • Service Plan ID: A unique identifier for your service plan. Use 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. Use this for the SINCH_API_TOKEN variable.
    • Base URL: The base URL depends on your account's region and data residency requirements:
      • US region: https://us.sms.api.sinch.com
      • EU region: https://eu.sms.api.sinch.com
      • Additional regions: Australia (https://au.sms.api.sinch.com), Brazil (https://br.sms.api.sinch.com), Canada (https://ca.sms.api.sinch.com)
      • The base URL excludes the /xms/v1/ path and your Service Plan ID – your application code adds those parts. Select the region where your transactional data will be stored; you can send global traffic regardless of selection. Reference: Sinch SMS API Base URLs
    • From Number: Navigate to "Numbers" in your dashboard to see active virtual numbers or configured Alphanumeric Sender IDs. Choose the one for sending messages. Ensure it's in E.164 format (E.164 is the ITU-T international telephone numbering standard format defined in ITU-T Recommendation E.164, e.g., +12025550101 for numbers). Reference: E.164 Standard. Use this for the SINCH_FROM_NUMBER variable.
    • Reference: https://developers.sinch.com/docs/sms/api-reference/ (official documentation)
  3. Update .gitignore: Ensure .env is listed in your .gitignore file to prevent accidentally committing secrets:

    .gitignore:

    text
    # .gitignore
    node_modules
    dist
    .env
  4. Load Configuration in AppModule: Modify src/app.module.ts to import and configure the ConfigModule.

    src/app.module.ts:

    typescript
    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], // Remove these later if not needed
      providers: [AppService], // Remove these later if not needed
    })
    export class AppModule {}

    Setting isGlobal: true means you don't need to import ConfigModule into other modules explicitly to use ConfigService.

3. Implement the Sinch Service

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.

  1. Generate Sinch Module and Service:

    bash
    nest g module sinch
    nest g service sinch

    This creates src/sinch/sinch.module.ts and src/sinch/sinch.service.ts.

  2. Configure HttpModule: Use @nestjs/axios (which wraps Axios) to make HTTP calls. Configure it asynchronously within the SinchModule to inject the ConfigService and set the base regional URL and authentication headers dynamically.

    src/sinch/sinch.module.ts:

    typescript
    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 (5000 ms = 5 seconds)
          }),
          inject: [ConfigService], // Inject ConfigService into the factory
        }),
      ],
      providers: [SinchService],
      exports: [SinchService], // Export service to use in other modules
    })
    export class SinchModule {}
    • HttpModule.registerAsync: Allows dynamic configuration using dependencies like ConfigService.
    • baseURL: Sets the root regional URL (e.g., https://us.sms.api.sinch.com) for all requests made via this HttpModule instance. The specific API path (/xms/v1/...) is added in the service.
    • headers: Sets default headers, including the crucial Authorization bearer token.
    • exports: Makes SinchService available for injection into other modules (like your upcoming MessagingModule).
  3. Implement SinchService Logic: Now, we implement the core method to send bulk SMS messages.

    src/sinch/sinch.service.ts:

    typescript
    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
    // Reference: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches
    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). Maximum 1,000 recipients per batch.
       * @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 (recipients.length > 1000) {
          throw new Error('Recipient list exceeds maximum of 1,000 recipients per batch.');
        }
        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)}`;
    
                        // 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 (controller catches it)
            throw error;
        }
      }
    }
    • Logger: Uses NestJS's built-in Logger.
    • Constructor: Injects HttpService and ConfigService. Fetches required config values (SINCH_FROM_NUMBER, SINCH_SERVICE_PLAN_ID, SINCH_API_URL) early and throws InternalServerErrorException if any are missing.
    • sendBulkSms Method:
      • Takes recipients array and message body.
      • Performs basic input checks including maximum recipient limit of 1,000 (throwing generic Error here is acceptable as the controller catches it).
      • Constructs the payload object.
      • Constructs the API endpointPath dynamically using the servicePlanId fetched in the constructor (/xms/v1/${this.servicePlanId}/batches).
      • Uses this.httpService.post with the relative endpointPath. The base URL (SINCH_API_URL) is automatically prepended by the HttpModule.
      • Uses firstValueFrom to convert the RxJS Observable to a Promise.
      • Uses .pipe() with RxJS operators:
        • map: Extracts the data from the successful Axios response.
        • catchError: Handles Axios errors. Logs detailed information and throws a NestJS InternalServerErrorException 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.
  4. Import SinchModule into AppModule: Make the SinchModule (and thus SinchService) available to the application.

    src/app.module.ts:

    typescript
    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.

  1. Generate Messaging Module and Controller:

    bash
    nest g module messaging
    nest g controller messaging

    This creates src/messaging/messaging.module.ts and src/messaging/messaging.controller.ts.

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

    typescript
    import {
      IsArray,
      ArrayNotEmpty,
      ArrayMinSize,
      ArrayMaxSize,
      IsString,
      IsPhoneNumber, // Validates E.164 format (approximately)
      MinLength,
      MaxLength,
    } from 'class-validator';
    
    export class BulkSmsDto {
      @IsArray()
      @ArrayNotEmpty()
      @ArrayMinSize(1)
      @ArrayMaxSize(1000, { message: 'Maximum 1,000 recipients allowed per batch. Reference: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches' })
      @IsPhoneNumber(undefined, { each: true, message: 'Each recipient must be a valid E.164 phone number (e.g., +12025550101). Reference: https://www.itu.int/rec/T-REC-E.164/en' })
      recipients: string[];
    
      @IsString()
      @MinLength(1, { message: 'Message body cannot be empty.' })
      @MaxLength(2000, { message: 'Message body too long. Sinch allows max 2000 characters. GSM-7: 160 chars (single) or 153 chars/segment (concatenated). Unicode: 70 chars (single) or 67 chars/segment (concatenated). Reference: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches' })
      messageBody: string;
    }
    • @IsArray, @ArrayNotEmpty, @ArrayMinSize(1): Ensure recipients is a non-empty array.
    • @ArrayMaxSize(1000): Enforces Sinch's maximum batch size of 1,000 recipients (increased from 100 in October 2019 per Sinch release notes).
    • @IsPhoneNumber(undefined, { each: true, ... }): Validates each element in the recipients array. 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(2000): Ensure messageBody is a non-empty string within Sinch's limits. Sinch supports:
      • GSM-7 encoding: 160 characters (single message), 153 characters per segment when concatenated (7-byte header overhead)
      • Unicode (UCS-2) encoding: 70 characters (single message), 67 characters per segment when concatenated
      • Maximum: 2000 characters total
      • Reference: Sinch SMS API Batches
  3. Implement MessagingController: Define the API endpoint (POST /messages/bulk) that accepts the DTO and uses SinchService to send the messages.

    src/messaging/messaging.controller.ts:

    typescript
    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') || error.message.includes('exceeds maximum')) {
              // 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-throwing InternalServerErrorException from the service, or throwing a new one for unexpected errors).
    • Returns a meaningful success response including the Sinch batchId.
  4. Import SinchModule into MessagingModule: The MessagingController depends on SinchService.

    src/messaging/messaging.module.ts:

    typescript
    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 {}
  5. Import MessagingModule into AppModule: Make the MessagingModule known to the main application module. Add rate limiting. Remove default controller/service if desired.

    src/app.module.ts:

    typescript
    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 registered ThrottlerGuard globally.

5. Error Handling and Logging

We've incorporated logging and error handling. Let's review the strategy.

  • Logging:

    • Uses NestJS's built-in Logger (v11+ includes improved ConsoleLogger with better formatting for nested objects, maps, sets, and JSON logging support).
    • Logs occur in SinchService (API calls, request/response details, errors) and MessagingController (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, and Sinch Response Data from SinchService when troubleshooting failed batches.
  • 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, exceeding 1,000 recipient limit) throw Error.
    • Sinch API Errors (SinchService): catchError intercepts Axios errors, logs details, and throws InternalServerErrorException containing Sinch status and response data.
    • Controller Errors (MessagingController): Catches errors from SinchService. Maps service input errors to BadRequestException. Re-throws NestJS exceptions from the service (like InternalServerErrorException). Catches other unexpected errors and throws InternalServerErrorException.
    • Consistency: Uses standard NestJS HTTP exceptions for clear API responses.
    • Rate Limiting: Note that Sinch batches queue in FIFO (First-In-First-Out) order and send at your plan's rate limit. A batch with 10 recipients counts as 10 messages for rate limit calculation. Rate limits are plan-specific; consult your account manager for your sending rate and ensure batches don't exceed thresholds. Reference: Sinch Rate Limits
  • 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 or yarn add async-retry @types/async-retry.
    • Caution: Only retry on retriable errors (network issues, 5xx). Don't retry 4xx errors. Implement exponential backoff.

    Example Sketch (using async-retry in SinchService):

    typescript
    // 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 (recipients.length > 1000) throw new Error('Recipient list exceeds maximum of 1,000 recipients per batch.');
         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 call sendBulkSmsWithRetry instead of sendBulkSms if you implement this retry logic.

6. Validate Requests with DTOs and ValidationPipe

You created the BulkSmsDto with class-validator decorators. Now, enable the ValidationPipe globally.

  1. Enable ValidationPipe Globally: Modify src/main.ts.

    src/main.ts:

    typescript
    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 (Cross-Origin Resource Sharing) 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();
    • Import ValidationPipe and Logger.
    • Use app.useGlobalPipes() to apply the ValidationPipe.
    • whitelist: true: Automatically removes properties from the request body not defined in the DTO (Data Transfer Object).
    • 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.
    • Import ConfigService and Logger to get the port from environment variables and log the startup message.
    • Enable CORS (Cross-Origin Resource Sharing) if your frontend runs on a different domain. Adjust origins for production security.

Frequently Asked Questions

What is the maximum batch size for Sinch SMS messages?

Sinch supports sending SMS messages to multiple recipients in a single batch request with a maximum of 1,000 recipients per batch (increased from 100 in October 2019). While batches can contain up to 1,000 recipients, practical limits depend on your service plan's rate limits. A batch with 10 recipients counts as 10 messages toward your rate limit calculation. Sinch batches queue in FIFO (First-In-First-Out) order and send at your plan-specific rate limit. Consult your Sinch account manager for your specific sending rate and structure batches accordingly. Reference: Sinch SMS API Release Notes (October 2019 update)

Which Node.js version should I use with NestJS for Sinch SMS integration?

Use Node.js v22 LTS for optimal compatibility and long-term support. Node.js v22 entered Active LTS in October 2024 and remains in Active LTS until October 2025, then transitions to Maintenance LTS until April 2027. This version provides security updates, performance improvements, and stability for production applications. NestJS v11.1.6 (released August 2025) works seamlessly with Node.js v22, providing improved ConsoleLogger with better formatting for nested objects and JSON logging support, microservices enhancements, and performance optimizations. Reference: Node.js Release Schedule

How do I format phone numbers for Sinch SMS API?

Format all phone numbers in E.164 international format as defined by ITU-T Recommendation E.164: a plus sign (+) followed by the country code and subscriber number with no spaces or special characters. Examples: +12025550101 (US), +447911123456 (UK), +61412345678 (Australia). The E.164 standard defines a general format for international telephone numbers with a maximum of 15 digits. The @IsPhoneNumber validator in the BulkSmsDto checks for E.164 format compliance on each recipient. Sinch requires E.164 formatting for accurate message routing across international carriers. Sinch accepts phone numbers with or without the leading +, but all responses will be without the + prefix. Reference: ITU-T E.164 Standard

What are the SMS character limits for Sinch messages?

Sinch supports up to 2,000 characters per message. Character limits depend on encoding: GSM-7 encoding allows 160 characters for single messages and 153 characters per segment for concatenated messages (7-byte header overhead for message concatenation). Unicode (UCS-2) encoding allows 70 characters for single messages and 67 characters per segment for concatenated messages. Messages are transmitted as 140 8-bit octets at a time. GSM-7 uses 7-bit encoding (140×8÷7=160 characters), while UCS-2 uses 16-bit encoding (140÷2=70 characters). The BulkSmsDto includes a @MaxLength(2000) validator to enforce Sinch's character limit. For similar character handling in other messaging platforms, see our guide on Twilio NestJS bulk messaging. Reference: Sinch SMS API Batches

How does NestJS dependency injection work with the Sinch service?

NestJS uses constructor-based dependency injection to provide instances of services to controllers and other services. The SinchService is marked with the @Injectable() decorator, registered in SinchModule providers, and exported for use in other modules. The MessagingController declares SinchService in its constructor parameters, and NestJS automatically injects the singleton instance. This pattern promotes loose coupling, testability, and maintainable code architecture. NestJS's DI system is built on top of TypeScript decorators and metadata reflection, managing the instantiation and lifecycle of providers throughout the application.

How should I handle Sinch API errors in production?

Implement multi-layer error handling: catch Axios errors in SinchService using RxJS catchError, log detailed error information (status code, response data), and throw appropriate NestJS exceptions (InternalServerErrorException for 5xx errors, BadRequestException for validation errors). In the controller, catch service exceptions and map them to HTTP status codes. For production, implement retry logic with exponential backoff for transient failures (network issues, 5xx errors) using libraries like async-retry. Never retry 4xx client errors except possibly 429 rate limit errors with proper backoff. Monitor and alert on error patterns using centralized logging services (Datadog, New Relic, Sentry).

How do I test the Sinch SMS integration locally?

Create a test endpoint in MessagingController with hardcoded test recipients (your own phone numbers) and test messages. Start your NestJS application with npm run start:dev, then send POST requests to http://localhost:3000/messages/bulk using tools like curl, Postman, or Thunder Client. Example curl command: curl -X POST http://localhost:3000/messages/bulk -H "Content-Type: application/json" -d '{"recipients":["+12025550101"],"messageBody":"Test message"}'. Monitor console logs for Sinch API responses and check your phone for message delivery. Sinch charges for all sent messages including test messages. Consider using Sinch's dry run endpoint (POST /xms/v1/{service_plan_id}/batches/dry_run) to test without actually sending messages.

What's the best way to deploy a NestJS Sinch SMS service to production?

Deploy to platforms like Heroku, AWS Elastic Beanstalk, Google Cloud Run, or DigitalOcean App Platform. Build your application with npm run build, which compiles TypeScript to JavaScript in the dist/ directory. Set environment variables (SINCH_API_URL, SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_FROM_NUMBER, PORT) in your platform's configuration panel. Use process managers like PM2 for traditional VPS deployments. Implement health check endpoints for load balancers. Enable structured JSON logging and integrate with monitoring services like Datadog, New Relic, or Sentry for production observability. Configure proper HTTPS/TLS termination and implement security headers.

How do I secure my NestJS Sinch SMS API endpoint?

Implement multiple security layers: apply rate limiting using nestjs-throttler (configured in AppModule with TTL and request limits per IP), add authentication middleware (JWT tokens, API keys, or OAuth), validate all inputs using class-validator decorators in DTOs, enable CORS with specific allowed origins (not wildcard * in production), use HTTPS for all production traffic, store sensitive credentials in environment variables (never commit to Git), implement request signing for internal services, audit logs for suspicious activity patterns, and consider implementing IP whitelisting for known internal services. Use NestJS guards and interceptors for additional request validation and transformation.

How can I optimize costs when sending bulk SMS with Sinch?

Optimize costs by batching messages efficiently to reduce API calls (use the full 1,000 recipient limit when appropriate), monitoring delivery reports to identify failed messages and invalid numbers, implementing message deduplication to prevent sending duplicates to the same recipient, caching valid phone numbers to avoid repeated validation, using alphanumeric sender IDs where supported (often cheaper than long codes), scheduling non-urgent messages during off-peak hours if your plan has variable pricing, monitoring character count to avoid unnecessary message segmentation (stay under 160 chars for GSM-7 or 70 for Unicode to avoid multi-part message charges), and regularly reviewing your Sinch service plan to ensure it matches your usage patterns. Use Sinch's dry run endpoint to test message segmentation before sending.

Frequently Asked Questions

How to send bulk SMS with NestJS?

Use NestJS with the Sinch SMS API to create a microservice that handles bulk messaging. This involves setting up a NestJS project, integrating the Sinch API, and creating an API endpoint to manage sending messages to multiple recipients.

What is Sinch SMS API used for?

The Sinch SMS API is a service that allows developers to send and manage SMS messages programmatically. It offers features like batch sending, making it suitable for applications requiring bulk SMS functionality, like notifications or marketing.

Why use NestJS for bulk SMS?

NestJS provides a structured and scalable framework for building server-side applications. Its modular architecture, dependency injection, and features like configuration management and validation make integrating with APIs like Sinch more efficient and maintainable.

When to use a bulk SMS service?

Bulk SMS services are ideal when you need to send the same message to many recipients simultaneously. Common use cases include sending notifications, marketing promotions, or one-time passwords for verification.

How to get Sinch API credentials?

You can obtain your Sinch API credentials, including Service Plan ID and API Token, by logging into the Sinch Customer Dashboard. Navigate to the SMS product section to find your API credentials and Base URL.

What is a Sinch Service Plan ID?

The Sinch Service Plan ID is a unique identifier for your specific Sinch service plan. It is required for making API calls and should be kept confidential, similar to your API Token. It's part of the API endpoint path.

How to set up a NestJS project for SMS?

To set up a NestJS project for SMS messaging, use the Nest CLI to create a new project. Then, install necessary dependencies like `@nestjs/config`, `@nestjs/axios`, `class-validator`, and `nestjs-throttler`.

What is the role of Axios in the project?

Axios, used via the `@nestjs/axios` package, is responsible for making HTTP requests to the Sinch API. It handles sending the SMS payload and receiving the responses, making it a core part of the integration process.

How to manage configuration in NestJS?

Configuration management in NestJS is handled using the `@nestjs/config` module, which allows loading environment variables from a `.env` file. Sensitive data like API keys are stored in `.env` and not committed to Git for security.

What is the purpose of class-validator?

`class-validator` and `class-transformer` are used for validating incoming request data in NestJS. They enable you to define DTOs with decorators to ensure data integrity before processing it.

How to handle Sinch API errors?

Error handling for the Sinch API involves using try-catch blocks and catching Axios errors. Logging error details, like response status and error messages, is essential for debugging and monitoring. Consider using a retry mechanism with exponential backoff.

What is nestjs-throttler used for?

`nestjs-throttler` is used for implementing rate limiting in NestJS. This helps prevent abuse and ensures service stability by limiting the number of requests an IP address can make within a specific time frame.

What does a 202 Accepted status code mean?

A 202 Accepted status code indicates that the request has been accepted for processing but has not yet completed. This is typically used for asynchronous operations, as is the case when submitting an SMS batch to Sinch. The final result may take time.

How to validate phone numbers in a bulk SMS service?

Use the `@IsPhoneNumber` decorator from `class-validator` within your DTO to perform basic phone number validation. Note that it provides an approximate check and true validation may require further lookups. It checks for a format that generally looks like E.164.

Can I customize the retry logic for Sinch API calls?

Yes, you can customize the retry logic by using a library like `async-retry` and configuring options like the number of retries, backoff factor, and error conditions for retrying. Be cautious to only retry on retriable errors, and do not retry on 4xx client errors (except perhaps 429 rate limit errors with care and backoff). Ensure you implement exponential backoff with jitter to improve reliability in distributed systems.