sms compliance

Sent logo
Sent TeamMar 8, 2026 / sms compliance / NestJS

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.

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.