Skip to content

Build a Bulk SMS Broadcasting Service with NestJS and Infobip API

Complete guide to building a production-ready bulk SMS broadcasting service using NestJS, TypeScript, and Infobip API. Includes authentication, validation, rate limiting, error handling, and retry logic.

SMS compliance16 min read

This guide provides a complete walkthrough for building a robust bulk SMS broadcasting service using NestJS and the Infobip API. We will create a backend application capable of accepting lists of phone numbers and a message, then efficiently dispatching these messages via Infobip's communication platform.

This solution addresses the need for applications to send notifications, alerts, or marketing messages to multiple recipients simultaneously, leveraging Infobip's scalable infrastructure. We'll focus on creating a reliable, maintainable, and secure service suitable for production environments.

Prerequisites:

  • Node.js 14+ (LTS version recommended) and npm or yarn
  • An active Infobip account with API access
  • Basic understanding of TypeScript, Node.js, NestJS, and REST APIs
  • Docker (optional, for containerized deployment)
  • A code editor (e.g., VS Code)
  • A tool for making API requests (e.g., curl, Postman)

Technology Stack:

  • NestJS: A progressive Node.js framework for building efficient, reliable server-side applications. Its modular architecture and built-in features (dependency injection, validation, configuration) accelerate development.
  • Infobip API & Node.js SDK (@infobip-api/sdk): Provides the interface to Infobip's SMS sending capabilities. We use the official SDK for ease of integration.
  • TypeScript: Enhances JavaScript with static typing for better code quality and maintainability.
  • Dotenv / @nestjs/config: For managing environment variables securely.
  • class-validator / class-transformer: For robust request data validation.
  • @nestjs/throttler: For rate limiting API requests.
  • Winston / NestJS Logger / nestjs-pino: For structured logging.
  • helmet: For basic security headers.
  • p-retry: For implementing retry logic on API calls.

System Architecture:

(Note: A graphical diagram illustrating this flow is recommended.)

The system involves a client (like Postman or another application) sending a POST request to the /broadcast endpoint of the NestJS API Gateway (specifically, the BroadcastController). This controller validates the request and calls the InfobipService. The InfobipService handles the core logic, using the Infobip Node.js SDK (@infobip-api/sdk) to interact with the external Infobip API, which ultimately sends the SMS messages. Configuration is managed via .env files loaded by @nestjs/config, and various dependencies like validation, rate limiting, security headers, and retry mechanisms support the core functionality. The API Gateway returns a response (e.g., 202 Accepted with a bulkId) back to the client.

Final Outcome:

By the end of this guide, you will have a NestJS application with a single API endpoint (POST /broadcast) that:

  1. Accepts a list of phone numbers and a message payload.
  2. Validates the input data.
  3. Rate-limits incoming requests.
  4. Uses the Infobip Node.js SDK to send the message to all specified recipients in a single API call (bulk send).
  5. Handles potential errors gracefully, including retries.
  6. Provides structured logs for monitoring.
  7. Returns the bulkId provided by Infobip for tracking purposes.

1. Setting up the Project

Let's initialize our NestJS project and install the necessary dependencies.

  1. Install NestJS CLI (if you haven't already):

    Bash
    npm install -g @nestjs/cli
    # OR
    yarn global add @nestjs/cli
    
  2. Create a new NestJS project:

    Bash
    nest new infobip-bulk-sender
    

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

  3. Navigate into the project directory:

    Bash
    cd infobip-bulk-sender
    
  4. Install required dependencies:

    • @infobip-api/sdk: The official Infobip SDK for Node.js.
    • @nestjs/config: For managing environment variables (integrates well with dotenv).
    • @nestjs/throttler: For implementing rate limiting.
    • class-validator & class-transformer: Peer dependencies for @nestjs/common's built-in ValidationPipe.
    • helmet: Middleware for setting security-related HTTP headers.
    • p-retry: Utility for retrying asynchronous operations.
    • dotenv: Loads environment variables from a .env file.
    Bash
    npm install @infobip-api/sdk @nestjs/config @nestjs/throttler class-validator class-transformer helmet p-retry dotenv
    # OR
    yarn add @infobip-api/sdk @nestjs/config @nestjs/throttler class-validator class-transformer helmet p-retry dotenv
    
  5. Set up Environment Variables: Create a .env file in the project root for your Infobip credentials and application settings. Never commit this file to version control.

    ini
    # .env
    
    # Application Port
    PORT=3000
    
    # Infobip API Credentials
    # Obtain from your Infobip account dashboard (e.g., under API Keys)
    # Base URL might look like: youraccount.api.infobip.com
    INFOBIP_BASE_URL=your_infobip_base_url.api.infobip.com
    INFOBIP_API_KEY=your_infobip_api_key
    
    # Default Sender ID (Alphanumeric, Short Code, or Long Number)
    # Ensure this is registered/approved in your Infobip account if required
    INFOBIP_SENDER_ID=YourSenderID
    

    Also, create a .env.example file to track required variables:

    ini
    # .env.example
    
    PORT=3000
    INFOBIP_BASE_URL=
    INFOBIP_API_KEY=
    INFOBIP_SENDER_ID=
    

    Add .env to your .gitignore file if it's not already there.

  6. Configure Core Modules (app.module.ts): Update src/app.module.ts to import and configure the ConfigModule and ThrottlerModule.

    TypeScript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
    import { APP_GUARD } from '@nestjs/core';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    // We will create these modules/services later
    import { InfobipModule } from './infobip/infobip.module';
    import { BroadcastModule } from './broadcast/broadcast.module';
    
    @Module({
      imports: [
        // Load environment variables from .env file
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
          envFilePath: '.env',
        }),
        // Configure rate limiting: 10 requests per 60 seconds per IP
        ThrottlerModule.forRoot([{
          ttl: 60000, // Time-to-live in milliseconds (60 seconds)
          limit: 10,   // Max requests per TTL interval
        }]),
        // Import our feature modules (to be created)
        InfobipModule,
        BroadcastModule,
      ],
      controllers: [AppController],
      providers: [
        AppService,
        // Apply the ThrottlerGuard globally to all routes
        {
          provide: APP_GUARD,
          useClass: ThrottlerGuard,
        },
      ],
    })
    export class AppModule {}
    
    • ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }): Loads variables from .env and makes ConfigService available everywhere without needing to import ConfigModule in other modules.
    • ThrottlerModule.forRoot(...): Sets up default rate limiting rules. We allow 10 requests per minute per IP address. This protects our API from abuse.
    • { provide: APP_GUARD, useClass: ThrottlerGuard }: Applies the rate limiting guard globally.
  7. Update Main Application File (main.ts): Modify src/main.ts to enable the ValidationPipe globally, use helmet, enable CORS, and use the configured port.

    TypeScript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ConfigService } from '@nestjs/config';
    import { Logger, ValidationPipe } from '@nestjs/common';
    import helmet from 'helmet'; // Import helmet
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      const configService = app.get(ConfigService);
      const port = configService.get<number>('PORT', 3000); // Default to 3000 if PORT not set
    
      // Enable Helmet for basic security headers
      app.use(helmet());
    
      // Enable CORS if needed (adjust origins for production)
      app.enableCors({
         origin: '*', // Replace with specific origins in production
         methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
         credentials: true,
      });
    
      // Apply validation pipe globally
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not defined in DTO
        forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are sent
        transform: true, // Automatically transform payloads to DTO instances
        transformOptions: {
          enableImplicitConversion: true, // Allow basic type conversions
        },
      }));
    
      // Use NestJS logger (Consider replacing with PinoLogger if using nestjs-pino)
      const logger = new Logger('Bootstrap');
    
      await app.listen(port);
      logger.log(`Application listening on port ${port}`);
      logger.log(`Infobip Base URL configured: ${configService.get('INFOBIP_BASE_URL')}`); // Log for verification
    }
    bootstrap();
    
    • app.get(ConfigService): Retrieves the ConfigService instance.
    • configService.get<number>('PORT', 3000): Gets the PORT environment variable, defaulting to 3000.
    • app.use(helmet()): Adds various HTTP headers to improve security (e.g., X-Frame-Options, HSTS). We installed helmet in Step 4.
    • app.enableCors(): Enables Cross-Origin Resource Sharing. Configure appropriately for your frontend's origin in production.
    • app.useGlobalPipes(new ValidationPipe(...)): Enables automatic request validation based on DTOs decorated with class-validator. This is crucial for API robustness.

2. Implementing Core Functionality (Infobip Service)

We'll encapsulate the Infobip SDK interaction within a dedicated NestJS service.

  1. Generate the Infobip Module and Service:

    Bash
    nest generate module infobip
    nest generate service infobip --no-spec
    

    This creates src/infobip/infobip.module.ts and src/infobip/infobip.service.ts. The --no-spec flag skips generating test files for now.

  2. Implement the InfobipService: Edit src/infobip/infobip.service.ts.

    TypeScript
    // src/infobip/infobip.service.ts
    import { Injectable, Logger, InternalServerErrorException, BadGatewayException } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { Infobip, AuthType } from '@infobip-api/sdk';
    import pRetry from 'p-retry'; // Import p-retry for retries
    
    @Injectable()
    export class InfobipService {
      private readonly logger = new Logger(InfobipService.name);
      private infobipClient: Infobip;
      private defaultSenderId: string;
    
      constructor(private configService: ConfigService) {
        const apiKey = this.configService.get<string>('INFOBIP_API_KEY');
        const baseUrl = this.configService.get<string>('INFOBIP_BASE_URL');
        this.defaultSenderId = this.configService.get<string>('INFOBIP_SENDER_ID');
    
        if (!apiKey || !baseUrl || !this.defaultSenderId) {
          this.logger.error('Infobip API Key, Base URL, or Sender ID missing in configuration.');
          throw new InternalServerErrorException('Infobip service configuration is incomplete.');
        }
    
        try {
          this.infobipClient = new Infobip({
            baseUrl: baseUrl,
            apiKey: apiKey,
            authType: AuthType.ApiKey, // Using API Key authentication
          });
          this.logger.log('Infobip client initialized successfully.');
        } catch (error) {
          this.logger.error('Failed to initialize Infobip client', error.stack);
          throw new InternalServerErrorException('Failed to initialize Infobip client');
        }
      }
    
      /**
       * Sends a single SMS message to multiple recipients using the Infobip API, with retries.
       * @param recipients - Array of phone numbers in E.164 format (e.g., '447123456789').
       * @param messageText - The text content of the SMS message.
       * @param senderId - Optional sender ID to override the default.
       * @returns The response data from the Infobip API, containing bulkId and message statuses.
       * @throws BadGatewayException if the Infobip API call fails after retries.
       */
      async sendBulkSms(recipients: string[], messageText: string, senderId?: string): Promise<any> {
        if (!recipients || recipients.length === 0) {
          this.logger.warn('Attempted to send bulk SMS with no recipients.');
          // Consider throwing BadRequestException or returning an appropriate structure
          return { success: false, message: 'No recipients provided.' };
        }
    
        // Deduplicate recipients
        const uniqueRecipients = [...new Set(recipients)];
        if (uniqueRecipients.length < recipients.length) {
            this.logger.warn(`Removed ${recipients.length - uniqueRecipients.length} duplicate recipients.`);
        }
    
        // Maps the `recipients` array to the `destinations` format required by Infobip ([{ to: string }, ...])
        const destinations = uniqueRecipients.map(recipient => ({ to: recipient }));
        const effectiveSenderId = senderId || this.defaultSenderId;
    
        const payload = {
          messages: [
            {
              destinations: destinations,
              from: effectiveSenderId,
              text: messageText,
            },
          ],
          // bulkId: `my-custom-bulk-${Date.now()}`, // Optional: Provide a custom bulk ID
        };
    
        this.logger.log(`Attempting to send bulk SMS to ${uniqueRecipients.length} unique recipients via Infobip...`);
        this.logger.debug(`Payload: ${JSON.stringify(payload)}`); // Log payload only in debug mode
    
        try {
             const runInfobipCall = async () => {
                this.logger.debug('Executing Infobip API call attempt...');
                // This is the operation we want to retry
                const response = await this.infobipClient.channels.sms.send(payload);
                // Optional: Check response.data for partial failures that might warrant a retry,
                // though typically Infobip accepts the bulk request or rejects it entirely.
                // If the SDK call succeeds, Infobip has accepted the request.
                return response;
             };
    
             // Wrap the API call with p-retry
             const response = await pRetry(runInfobipCall, {
                retries: 3, // Number of retries (total 4 attempts)
                minTimeout: 1000, // Initial delay in ms (1 second)
                factor: 2, // Exponential backoff factor (1s, 2s, 4s)
                onFailedAttempt: error => {
                    this.logger.warn(
                      `Infobip API call attempt ${error.attemptNumber} failed. Retries left: ${error.retriesLeft}. Error: ${error.message}`
                    );
                    // Decide if retry is worthwhile. Don't retry client errors (4xx).
                    // Infobip SDK error structure might vary, inspect 'error.response' if available.
                    const statusCode = error.response?.status || error.code; // Example check
                    if (statusCode && statusCode >= 400 && statusCode < 500) {
                       this.logger.error(`Client-side error detected (${statusCode}). Aborting retries.`);
                       throw error; // Prevent further retries for client errors
                    }
                },
             });
    
            this.logger.log(`Infobip API call successful. Bulk ID: ${response.data.bulkId}`);
            this.logger.verbose(`Infobip Full Response: ${JSON.stringify(response.data)}`); // Log full response verbosely
    
            // You might want to check response.data.messages for individual statuses
            // For simplicity, we return the whole data object here.
            return response.data;
    
        } catch (error) {
             // This catch block now handles errors after all retries have failed,
             // or if a non-retryable error occurred.
             this.logger.error(`Failed to send bulk SMS via Infobip after retries. Error: ${error.message}`, error.stack);
             // Log specific Infobip error details if available
             if (error.response?.data) {
                 this.logger.error(`Infobip Error Details: ${JSON.stringify(error.response.data)}`);
                 // Extract a more specific message if possible
                 const errorMessage = error.response.data?.requestError?.serviceException?.text || error.message;
                 throw new BadGatewayException(`Infobip API Error: ${errorMessage}`);
             }
             // Rethrow a generic error if no specific details are available
             throw new BadGatewayException(`Failed to communicate with Infobip API after retries: ${error.message}`);
        }
      }
    
      // --- Alternative Approaches (Conceptual) ---
      // 1. Sending Personalized Messages in Bulk:
      //    Structure the payload with multiple `messages` objects if needed.
      //    async sendPersonalizedBulkSms(messages: { recipient: string; text: string }[]) { ... }
    
      // 2. Handling Very Large Lists (Queueing):
      //    Use BullMQ or similar for asynchronous processing (See Section 6).
    }
    
    • Constructor: Initializes the Infobip client using credentials from ConfigService.
    • sendBulkSms Method:
      • Includes basic recipient deduplication.
      • Maps recipients to the correct destinations format [{ to: string }, ...].
      • Constructs the Infobip payload.
      • Wraps the this.infobipClient.channels.sms.send call within pRetry for resilience against transient errors.
      • Logs attempts, success, and detailed errors.
      • Throws BadGatewayException if the call fails after all retries.
  3. Update the InfobipModule: Make sure the InfobipService is provided and exported.

    TypeScript
    // src/infobip/infobip.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
    import { InfobipService } from './infobip.service';
    
    @Module({
      imports: [ConfigModule], // Make ConfigService available within this module
      providers: [InfobipService],
      exports: [InfobipService], // Export service for other modules to use
    })
    export class InfobipModule {}
    

3. Building the API Layer (Broadcast Controller)

Now, let's create the API endpoint.

  1. Generate the Broadcast Module and Controller:

    Bash
    nest generate module broadcast
    nest generate controller broadcast --no-spec
    
  2. Create a Data Transfer Object (DTO) for Validation: Create src/broadcast/dto/create-broadcast.dto.ts. (Optional: For Swagger documentation, install @nestjs/swagger swagger-ui-express and configure it in main.ts)

    Bash
    # Optional: Install Swagger dependencies
    npm install @nestjs/swagger swagger-ui-express
    
    TypeScript
    // src/broadcast/dto/create-broadcast.dto.ts
    import { ApiProperty } from '@nestjs/swagger'; // Optional: for Swagger
    import { IsArray, IsNotEmpty, IsString, ArrayMinSize, IsPhoneNumber, IsOptional } from 'class-validator'; // Import IsOptional
    
    export class CreateBroadcastDto {
      @ApiProperty({
        description: 'Array of recipient phone numbers in E.164 format',
        example: ['+447123456789', '+14155552671'], // Use E.164 format examples
        type: [String],
      })
      @IsArray()
      @ArrayMinSize(1)
      // Apply IsPhoneNumber validation to each element in the array.
      // Note: class-validator's IsPhoneNumber provides basic structure validation.
      // E.164 format: + followed by country code and national number (max 15 digits total).
      // For production-grade validation across all regions, use libphonenumber-js
      // (npm install libphonenumber-js) with a custom validator that calls
      // parsePhoneNumber() and checks .isValid(). The basic validator below
      // ensures phone numbers start with + and contain only digits.
      @IsPhoneNumber(undefined, { each: true, message: 'Each recipient must be a valid phone number in E.164 format (e.g., +447123456789)' })
      recipients: string[];
    
      @ApiProperty({
        description: 'The text message content to send',
        example: 'Hello from our service!',
      })
      @IsString()
      @IsNotEmpty()
      message: string;
    
      @ApiProperty({
        description: 'Optional: Sender ID to use for this broadcast, overriding the default.',
        example: 'InfoSMS',
        required: false,
      })
      @IsString()
      @IsNotEmpty()
      @IsOptional() // Make senderId optional
      senderId?: string;
    }
    
    • Uses class-validator decorators (@IsArray, @IsString, @IsNotEmpty, @ArrayMinSize, @IsPhoneNumber, @IsOptional).
    • @IsPhoneNumber: Provides basic validation. See Section 6 for more on robust validation.
    • @IsOptional: Makes senderId optional.
    • @ApiProperty: Added for potential Swagger integration.
  3. Implement the BroadcastController: Edit src/broadcast/broadcast.controller.ts.

    TypeScript
    // src/broadcast/broadcast.controller.ts
    import { Controller, Post, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common';
    import { Throttle } from '@nestjs/throttler';
    import { InfobipService } from '../infobip/infobip.service';
    import { CreateBroadcastDto } from './dto/create-broadcast.dto'; // Correct import path
    
    @Controller('broadcast') // Route prefix: /broadcast
    export class BroadcastController {
      private readonly logger = new Logger(BroadcastController.name);
    
      constructor(
          private readonly infobipService: InfobipService,
      ) {}
    
      @Post()
      @HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted
      @Throttle({ default: { limit: 5, ttl: 60000 } }) // Override global throttle: 5 requests/min
      async sendBroadcast(@Body() createBroadcastDto: CreateBroadcastDto) {
        this.logger.log(`Received broadcast request for ${createBroadcastDto.recipients.length} recipients.`);
    
        try {
          // Call the service method with validated DTO data
          const result = await this.infobipService.sendBulkSms(
            createBroadcastDto.recipients,
            createBroadcastDto.message,
            createBroadcastDto.senderId, // Pass optional senderId
          );
    
          // Handle case where service returns early (e.g., no recipients)
          // Check for the specific structure returned in that case
          if (result && result.success === false) {
              // Could return 400 Bad Request here, or stick with 202 but include message
              this.logger.warn(`Broadcast request rejected by service: ${result.message}`);
              // Adjust status code if needed, e.g., HttpStatus.BAD_REQUEST
              return {
                  statusCode: HttpStatus.BAD_REQUEST, // Example: Return 400
                  message: result.message || 'Broadcast request could not be processed.',
                  // No bulkId in this case
              };
          }
    
          this.logger.log(`Broadcast request processed successfully by Infobip. Bulk ID: ${result.bulkId}`);
    
          // Return relevant information
          return {
            message: 'Broadcast request accepted for processing by Infobip.',
            bulkId: result.bulkId,
            // messages: result.messages // Optionally return individual message statuses
          };
        } catch (error) {
          // Errors thrown by InfobipService (e.g., BadGatewayException) will be caught here
          this.logger.error(`Broadcast request failed: ${error.message}`, error.stack);
          // Rethrow the error to let NestJS default exception filter handle it
          // (e.g., converts BadGatewayException to a 502 response)
          throw error;
        }
      }
    }
    
    • @Controller('broadcast'): Defines the base route.
    • @Post(): Handles POST requests.
    • @HttpCode(HttpStatus.ACCEPTED): Sets default success status to 202.
    • @Throttle(...): Overrides global rate limit for this endpoint.
    • @Body() createBroadcastDto: CreateBroadcastDto: Validates the request body using the DTO and global ValidationPipe.
    • Injects InfobipService.
    • Calls infobipService.sendBulkSms.
    • Returns the bulkId on success.
    • Rethrows errors from the service layer.
  4. Update the BroadcastModule: Import InfobipModule.

    TypeScript
    // src/broadcast/broadcast.module.ts
    import { Module } from '@nestjs/common';
    import { BroadcastController } from './broadcast.controller';
    import { InfobipModule } from '../infobip/infobip.module'; // Import InfobipModule
    
    @Module({
      imports: [
        InfobipModule, // Make InfobipService available
      ],
      controllers: [BroadcastController],
      providers: [],
    })
    export class BroadcastModule {}
    
  5. Testing the API Endpoint: Start the application:

    Bash
    npm run start:dev
    # OR
    yarn start:dev
    

    Use curl or Postman:

    Using curl: (Replace placeholders)

    Bash
    curl -X POST http://localhost:3000/broadcast \
         -H "Content-Type: application/json" \
         -d '{
              "recipients": ["+14155550100", "+447123456789"],
              "message": "Test broadcast from NestJS!",
              "senderId": "OptionalSender"
            }'
    

    Expected Success Response (202 Accepted):

    JSON
    {
      "message": "Broadcast request accepted for processing by Infobip.",
      "bulkId": "some-unique-bulk-id-from-infobip"
    }
    

    Example Validation Error Response (400 Bad Request): (If sending invalid data, e.g., missing message)

    JSON
    {
      "statusCode": 400,
      "message": [
        "message should not be empty",
        "message must be a string"
      ],
      "error": "Bad Request"
    }
    

4. Integrating with Infobip (Details & Configuration)

Recap of the integration specifics handled in InfobipService.

  • Obtaining Credentials:

    1. Log in to your Infobip Portal.
    2. Navigate to Developers -> API Keys.
    3. Create/copy your API Key.
    4. Note your account-specific Base URL (e.g., xxxxx.api.infobip.com).
    5. Check Channels and Numbers -> Sender Names for your INFOBIP_SENDER_ID. Ensure it's registered/approved if needed. Free trials often have restrictions.
  • Environment Variables: Ensure these are correctly set in .env:

    • INFOBIP_BASE_URL
    • INFOBIP_API_KEY
    • INFOBIP_SENDER_ID
  • Secure Handling:

    • Use .env locally (add to .gitignore).
    • Use platform-specific secrets management (AWS Secrets Manager, Kubernetes Secrets, etc.) in production. Never hardcode secrets.
  • Fallback Mechanisms (Retry):

    • The InfobipService now uses p-retry to automatically retry failed API calls (due to network issues or temporary Infobip 5xx errors) with exponential backoff.
    • A circuit breaker (e.g., using opossum) could be added for prolonged outages, preventing repeated calls to a known-failing service.

5. Error Handling, Logging, and Retry Mechanisms

Robustness is key.

  • Error Handling Strategy:

    • Validation Errors (400): Handled by ValidationPipe.
    • Rate Limiting Errors (429): Handled by @nestjs/throttler.
    • Configuration Errors (500): Handled in InfobipService constructor.
    • Infobip API Errors (502): Caught in InfobipService after retries, logged, and rethrown as BadGatewayException.
    • Unexpected Errors (500): Handled by NestJS default exception filter.
  • Logging:

    • Uses built-in Logger (@nestjs/common). Logs info, errors, warnings, debug, verbose messages.
    • Structured Logging (Recommended): For production, use nestjs-pino for JSON logs compatible with aggregation systems (Datadog, Splunk, ELK).
      • Install:
        Bash
        npm install nestjs-pino pino-http pino-pretty # pino-pretty for dev console
        
      • Setup (Conceptual):
        TypeScript
        // src/main.ts
        import { Logger } from 'nestjs-pino';
        
        // ... in bootstrap()
        app.useLogger(app.get(Logger)); // Use PinoLogger instance globally
        
        TypeScript
        // src/app.module.ts
        import { LoggerModule } from 'nestjs-pino';
        
        @Module({
          imports: [
            // Configure Pino logger (e.g., level, prettyPrint for dev)
            LoggerModule.forRoot({
              pinoHttp: {
                level: process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
                transport: process.env.NODE_ENV !== 'production'
                  ? { target: 'pino-pretty' }
                  : undefined,
                // Add custom serializers, redaction paths etc.
              },
            }),
            // ... other modules
          ],
        })
        export class AppModule {}
        
        Replace new Logger(...) calls with dependency injection or ensure the global logger is used correctly.
  • Retry Mechanisms:

    • Implemented in InfobipService using p-retry (installed in Section 1, used in Section 2).
    • Retries up to 3 times with exponential backoff (1s, 2s, 4s delays).
    • Logs failed attempts.
    • Avoids retrying client-side (4xx) errors.

6. Database Schema and Data Layer (Recommendation)

While not implemented here, persistence is crucial for production.

  • Why a Database? Track broadcast history, statuses, manage recipients, schedule messages, store delivery reports (via webhooks).

  • Recommendation: Use PostgreSQL/MySQL with Prisma or TypeORM.

  • Conceptual Prisma Schema:

    Prisma
    // schema.prisma
    datasource db {
      provider = ""postgresql"" // or ""mysql"", ""sqlite""
      url      = env(""DATABASE_URL"")
    }
    
    generator client {
      provider = ""prisma-client-js""
    }
    
    model Broadcast {
      id          String   @id @default(cuid())
      bulkId      String?  @unique // Infobip bulk ID
      messageText String
      senderId    String
      status      String   @default(""PENDING"") // PENDING, SENT, FAILED, PARTIAL
      createdAt   DateTime @default(now())
      updatedAt   DateTime @updatedAt
      recipients  RecipientStatus[]
    }
    
    model RecipientStatus {
       id           String   @id @default(cuid())
       broadcast    Broadcast @relation(fields: [broadcastId], references: [id])
       broadcastId  String
       phoneNumber  String   // E.164 format
       messageId    String?  // Infobip message ID for this recipient
       status       String   // e.g., PENDING, SENT, DELIVERED, FAILED, REJECTED
       statusReason String?
       updatedAt    DateTime @updatedAt
    
       @@unique([broadcastId, phoneNumber])
    }
    
  • Implementation: Integrate Prisma/TypeORM into NestJS, create a service to handle database operations (create broadcast record, update statuses), and call this service from the BroadcastController and potentially a webhook handler for delivery reports. This is beyond the scope of this initial guide but essential for a complete solution. Consider using a message queue (like BullMQ) for large broadcasts to decouple API requests from database writes and Infobip calls.

Frequently asked questions

Create a NestJS application with the Infobip SDK (@infobip-api/sdk) and configure an API endpoint to accept recipient phone numbers and a message. The Infobip service will handle sending the messages via the Infobip API using the provided credentials. You can manage these credentials and application settings through .env files for security and maintainability. The API Gateway returns a response back to the client with a BulkId for tracking purposes.

The Infobip Node.js SDK (@infobip-api/sdk) simplifies interaction with the Infobip API. It provides methods to send SMS messages and manage other communication channels, abstracting away the complexities of direct API calls. Use the official SDK for ease of integration and better maintenance.

NestJS provides a robust, structured framework with features like dependency injection, validation, and modularity, resulting in a more maintainable and scalable application. It accelerates development and handles automatic validation, and has structured logging for monitoring.

Consider a message queue like BullMQ for high-volume SMS broadcasting. Queues handle asynchronous processing, ensuring your API remains responsive while messages are sent in the background. This decoupling enhances reliability, especially for very large recipient lists.

Yes, the provided code allows for a custom sender ID. Pass the desired sender ID in the request payload alongside the recipients and message. If no custom ID is provided, a default sender ID is used from the configuration. This is particularly helpful for branding and recognition.

Store your Infobip API key, base URL, and sender ID in a .env file in your project's root directory. Use the @nestjs/config package to load these variables into your NestJS application securely. Avoid hardcoding these sensitive credentials directly in your code. Add this file to your .gitignore.

Rate limiting, implemented with @nestjs/throttler, prevents abuse and protects your application from excessive requests. The provided code limits requests to 10 per minute per IP by default, preventing overload and ensuring service availability.

The system implements error handling for various scenarios: validation errors (400), rate limiting (429), configuration errors (500), and Infobip API errors (502). p-retry automatically retries failed calls to the Infobip API with exponential backoff, and logs error details for debugging purposes.

The project utilizes NestJS, Infobip API with its Node.js SDK, TypeScript, dotenv, class-validator, class-transformer, @nestjs/throttler, Winston/NestJS Logger, helmet, and p-retry. This comprehensive stack ensures robustness, security, and maintainability.

The example code uses the 'p-retry' library for retry logic. This handles transient errors gracefully. Failed Infobip API calls are automatically retried a certain number of times with increasing delays between attempts, enhancing the reliability of message delivery. You should inspect error responses, especially the response code or error type, to determine whether to initiate a retry.

The BroadcastController receives requests at the /broadcast endpoint, validates them, and passes the data to the InfobipService. It also handles rate limiting and returns the appropriate response to the client, including error handling.

While not implemented in the basic guide, a database is crucial for production. PostgreSQL or MySQL are recommended, along with an ORM like Prisma or TypeORM to manage and track broadcast history, recipient statuses, delivery reports, and other data persistently.

The provided code uses @IsPhoneNumber from class-validator, which offers a basic level of validation. For comprehensive validation across all regions and formats, consider a dedicated library like libphonenumber-js combined with a custom validator to enforce E.164 compliance. This ensures accurate and reliable delivery.

You need Node.js (LTS), npm or yarn, an active Infobip account, and basic understanding of TypeScript, Node.js, and NestJS. After installing required dependencies and setting up your environment variables, configure the core modules, specifically app.module.ts, then update your main application file (main.ts). Ensure CORS is enabled if needed, and apply automatic request validation based on data transfer objects (DTOs).

A successful response returns a 202 Accepted status code with a JSON body containing a success message and the bulkId provided by Infobip. This bulkId allows tracking the status and delivery reports of the sent messages. The client can utilize this ID for further inquiries and management.