code examples

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

Implementing Infobip SMS with Delivery Callbacks in NestJS

A guide on building a NestJS application to send SMS via Infobip and track delivery status using callbacks, including setup, database integration, and API implementation.

Developer Guide: Implementing Reliable Infobip SMS with Delivery Callbacks in NestJS

This guide provides a complete walkthrough for building a robust system in NestJS (using Node.js) to send SMS messages via the Infobip API and reliably track their delivery status using Infobip's callback mechanism. We'll cover everything from project setup to production deployment considerations.

Last Updated: September 5, 2024

Project Overview and Goals

This project aims to create a NestJS application capable of:

  1. Sending SMS Messages: Exposing an API endpoint to trigger sending SMS messages via the Infobip platform.
  2. Tracking Delivery Status: Receiving asynchronous delivery status updates (callbacks) from Infobip for each sent message.
  3. Persisting Message State: Storing message details and their delivery status updates in a database for querying and auditing.

Problem Solved: Sending an SMS is often a ""fire and forget"" action. However, knowing if and when a message was actually delivered is crucial for many applications (e.g., OTP verification, critical alerts, appointment reminders). Relying solely on the initial API response isn't enough, as delivery can be delayed or fail downstream. Infobip's callbacks provide this essential feedback loop.

Technologies Used:

  • Node.js: The JavaScript runtime environment.
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and built-in features (dependency injection, validation, configuration) make it ideal for this task.
  • Infobip API & Node.js SDK: The official SDK simplifies interactions with Infobip's SMS API.
  • Prisma: A modern database toolkit (ORM) for Node.js and TypeScript, used for database schema management and data access.
  • SQLite (or PostgreSQL/MySQL): The database for storing message information. SQLite is used for simplicity in this guide, but Prisma makes switching easy.
  • @nestjs/config: For managing environment variables securely.
  • class-validator & class-transformer: For robust request validation.
  • @nestjs/throttler: For rate limiting API endpoints (See Section 7).
  • @nestjs/terminus: For application health checks (See Section 11).

System Architecture:

+-------------+ +----------------------+ +-----------------+ | Your Client | ----> | NestJS API | ----> | Infobip API | | (Web/Mobile)| | (Send SMS Endpoint) | | (Send Message) | +-------------+ +----------------------+ +-----------------+ | ^ | Stores | Receives v | +-------------+ | Database | | (Message | | Status) | +-------------+ ^ | | Updates | Sends | v +----------------------+ +-----------------+ | NestJS API | <---- | Infobip Callback| | (Callback Endpoint) | | (Delivery Status)| +----------------------+ +-----------------+

Prerequisites:

  • Node.js (v18 or later recommended - ideally a current LTS version) and npm/yarn installed.
  • An active Infobip account (https://www.infobip.com/).
  • Basic understanding of TypeScript, REST APIs, and asynchronous programming.
  • A publicly accessible URL for receiving Infobip callbacks during development (we'll use ngrok, a service that creates secure tunnels to your localhost, for this).

Final Outcome: A NestJS application with endpoints to send SMS and receive delivery reports, storing the status persistently.

1. Setting up the Project

Let's initialize our NestJS project and configure the basic structure.

1.1 Install NestJS CLI: If you don't have it, install the NestJS CLI globally.

bash
npm install -g @nestjs/cli

1.2 Create New Project: Generate a new NestJS project.

bash
nest new nestjs-infobip-sms
cd nestjs-infobip-sms

1.3 Install Dependencies: We need the Infobip SDK, configuration management, validation pipes, and Prisma.

bash
npm install @infobip-api/sdk @nestjs/config class-validator class-transformer prisma @prisma/client
npm install --save-dev @types/node prisma

1.4 Initialize Prisma: Set up Prisma with SQLite (you can choose postgresql or mysql if preferred).

bash
npx prisma init --datasource-provider sqlite

This creates a prisma directory with a schema.prisma file and a .env file.

1.5 Configure Environment Variables: Modify the .env file created by Prisma. Add your Infobip credentials and an application base URL (needed for constructing the callback URL).

Important: Replace the placeholder values below (YOUR_INFOBIP_API_KEY, YOUR_INFOBIP_BASE_URL) with your actual credentials from Infobip.

dotenv
# .env

# --- Database ---
# Prisma automatically sets this based on your choice in `npx prisma init`
DATABASE_URL=""file:./dev.db""

# --- Infobip ---
# Obtain from your Infobip account dashboard (API Keys section)
INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY""
# Find your Base URL on the Infobip dashboard homepage or API documentation
INFOBIP_BASE_URL=""YOUR_INFOBIP_BASE_URL"" # e.g., yxpv6d.api.infobip.com

# --- Application ---
# The base URL where your NestJS app will be publicly accessible.
# Use ngrok URL during development, e.g., https://your-ngrok-subdomain.ngrok.io
APP_BASE_URL=""http://localhost:3000""
  • INFOBIP_API_KEY: Your unique key for authenticating with the Infobip API. Generate one in your Infobip account under ""API Keys"". Keep this secret.
  • INFOBIP_BASE_URL: The specific API endpoint URL assigned to your Infobip account. Find this on your Infobip dashboard.
  • APP_BASE_URL: The root URL where your application can be reached by Infobip's servers to send callbacks. During local development, this will be an ngrok tunnel URL. In production, it's your server's public domain/IP.

1.6 Load Environment Variables: Integrate the @nestjs/config module into your main application module (src/app.module.ts).

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// Import other modules later

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // Makes ConfigService available globally
      envFilePath: '.env', // Specifies the env file path
    }),
    // Add other modules here (e.g., SmsModule, PrismaModule)
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Project Structure Explanation:

  • src/: Contains your application code (modules, controllers, services).
  • prisma/: Contains your database schema (schema.prisma) and migrations.
  • .env: Stores environment-specific configuration and secrets.
  • node_modules/: Project dependencies.
  • package.json: Project metadata and dependencies list.
  • tsconfig.json: TypeScript compiler options.

We use @nestjs/config to load variables from .env, making them accessible via ConfigService throughout the application, avoiding hardcoding secrets.

2. Creating a Database Schema and Data Layer

We need a way to store information about the SMS messages we send and their delivery status.

2.1 Define Prisma Schema: Open prisma/schema.prisma and define a model to represent an SMS message.

prisma
// prisma/schema.prisma

generator client {
  provider = ""prisma-client-js""
}

datasource db {
  provider = ""sqlite"" // Or ""postgresql"", ""mysql""
  url      = env(""DATABASE_URL"")
}

// Enum for delivery status based on Infobip's common statuses
enum MessageStatus {
  PENDING       // Initial state before sending or confirmation
  SENT          // Successfully sent to Infobip API
  DELIVERED     // Confirmed delivery to handset
  UNDELIVERABLE // Failed delivery after retries
  EXPIRED       // Message validity period expired
  REJECTED      // Rejected by carrier or Infobip
  UNKNOWN       // Status could not be determined
}

model SmsMessage {
  id               String        @id @default(uuid()) // Our internal unique ID
  recipient        String        // Phone number SMS was sent to
  senderId         String?       // 'from' field used (e.g., 'InfoSMS')
  text             String        // Message content
  infobipMessageId String?       @unique // ID from Infobip API response (can be null initially)
  infobipBulkId    String?       // Bulk ID if sent as part of a batch
  status           MessageStatus @default(PENDING) // Current delivery status
  callbackData     String        @unique // Unique data sent to Infobip to correlate callbacks
  sentAt           DateTime      @default(now()) // When we initiated the send
  statusUpdatedAt  DateTime      @updatedAt // When the status was last updated

  @@index([status])
  @@index([recipient])
}
  • id: Our system's unique identifier (UUID).
  • recipient: The destination phone number.
  • senderId: The 'From' name/number used.
  • text: The message body.
  • infobipMessageId: The unique ID returned by Infobip for tracking a specific message to a recipient. Made unique to prevent accidental duplicates if callbacks somehow repeat.
  • infobipBulkId: ID returned by Infobip when sending multiple messages in one request.
  • status: Tracks the delivery lifecycle using our MessageStatus enum. Defaults to PENDING.
  • callbackData: A crucial field. We'll generate a unique value (like our internal id or another UUID) and send it to Infobip. Infobip will include this callbackData in the delivery report POST request back to us, allowing easy correlation. Made unique for reliable lookup.
  • sentAt: Timestamp of when our system processed the send request.
  • statusUpdatedAt: Timestamp automatically updated by Prisma whenever the record changes.
  • Indices: Added for faster lookups on status, recipient, and the unique callbackData and infobipMessageId.

2.2 Create and Apply Migration: Generate the SQL migration file and apply it to your database.

bash
# Generate migration files based on schema changes
npx prisma migrate dev --name init-sms-message

# (Optional) Apply migrations to a production database later
# npx prisma migrate deploy

This creates the SmsMessage table in your dev.db SQLite file (or your configured database).

2.3 Create Prisma Service: Create a reusable Prisma service according to NestJS best practices.

bash
nest g module prisma --flat
nest g service prisma --flat

Modify src/prisma.service.ts:

typescript
// src/prisma.service.ts
import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    // Optional: Connect to the database when the module initializes
    await this.$connect();
  }

  // Optional: Disconnect gracefully on shutdown
  async enableShutdownHooks(app: INestApplication) {
    process.on('beforeExit', async () => {
      await app.close();
      await this.$disconnect();
    });
  }
}

Make PrismaService available globally by exporting it from PrismaModule:

typescript
// src/prisma.module.ts
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global() // Make PrismaService available globally
@Module({
  providers: [PrismaService],
  exports: [PrismaService], // Export PrismaService for injection
})
export class PrismaModule {}

Finally, import PrismaModule into AppModule:

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module'; // Import PrismaModule
// Import other modules later (e.g., SmsModule)

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    PrismaModule, // Add PrismaModule here
    // Add other modules here
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Now PrismaService can be injected into any service that needs database access.

3. Implementing Core Functionality: Sending SMS

Let's create a dedicated module and service for handling SMS logic.

3.1 Create SMS Module and Service:

bash
nest g module sms
nest g service sms/services/sms --no-spec
nest g controller sms/controllers/sms --no-spec

3.2 Configure Infobip Client: We'll instantiate the Infobip client within the SmsService.

typescript
// src/sms/services/sms.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Infobip, AuthType } from '@infobip-api/sdk';
import { PrismaService } from '../../prisma/prisma.service';
import { MessageStatus, Prisma } from '@prisma/client';
import { randomUUID } from 'crypto'; // For generating unique callbackData
import { InfobipCallbackDto, InfobipResultDto } from '../dto/infobip-report.dto'; // Import Callback DTO

@Injectable()
export class SmsService implements OnModuleInit {
  private readonly logger = new Logger(SmsService.name);
  private infobipClient: Infobip;
  private appBaseUrl: string;

  constructor(
    private configService: ConfigService,
    private prisma: PrismaService,
  ) {}

  onModuleInit() {
    const apiKey = this.configService.get<string>('INFOBIP_API_KEY');
    const baseUrl = this.configService.get<string>('INFOBIP_BASE_URL');
    this.appBaseUrl = this.configService.get<string>('APP_BASE_URL');

    if (!apiKey || !baseUrl || !this.appBaseUrl) {
      this.logger.error(
        'Infobip API Key, Base URL, or App Base URL missing in configuration.',
      );
      throw new Error('Missing Infobip or App configuration.');
    }

    this.infobipClient = new Infobip({
      baseUrl: baseUrl,
      apiKey: apiKey,
      authType: AuthType.ApiKey,
    });
    this.logger.log('Infobip client initialized.');
  }

  // --- Method to Send SMS ---
  async sendSingleSms(recipient: string, text: string, sender = 'InfoSMS') {
    this.logger.log(`Attempting to send SMS to ${recipient}`);

    // 1. Generate unique callback data for correlation
    const uniqueCallbackData = randomUUID();

    // 2. Create initial record in DB *before* sending
    let smsRecord;
    try {
      smsRecord = await this.prisma.smsMessage.create({
        data: {
          recipient: recipient,
          text: text,
          senderId: sender,
          status: MessageStatus.PENDING, // Initial status
          callbackData: uniqueCallbackData, // Store the unique ID
        },
      });
      this.logger.log(`Created initial SMS record ${smsRecord.id}`);
    } catch (error) {
      this.logger.error(
        `Failed to create initial SMS record for ${recipient}: ${error.message}`,
        error.stack,
      );
      // Rethrow or handle appropriately - cannot proceed without DB record
      throw new Error('Database error while preparing SMS.');
    }

    // 3. Prepare Infobip Payload
    const payload = {
      messages: [
        {
          destinations: [{ to: recipient }],
          from: sender,
          text: text,
          // CRUCIAL: Tell Infobip where to send the delivery report
          notifyUrl: `${this.appBaseUrl}/sms/callback/infobip`, // Our callback endpoint
          notifyContentType: 'application/json',
          // CRUCIAL: Include our unique ID
          callbackData: uniqueCallbackData,
        },
      ],
    };

    // 4. Send SMS via Infobip API
    try {
      const infobipResponse =
        await this.infobipClient.channels.sms.send(payload);
      const messageResponse = infobipResponse.data.messages[0]; // Assuming single message response

      this.logger.log(
        `Infobip response received for ${recipient}: Status ${messageResponse.status.groupName}, Message ID ${messageResponse.messageId}`,
      );

      // 5. Update DB record with Infobip's message ID and initial status from API
      const updatedRecord = await this.prisma.smsMessage.update({
        where: { id: smsRecord.id },
        data: {
          infobipMessageId: messageResponse.messageId,
          infobipBulkId: infobipResponse.data.bulkId,
          // Update status based on initial Infobip acceptance (e.g., PENDING or potentially REJECTED if immediate failure)
          status: this.mapInfobipStatus(messageResponse.status.groupName),
        },
      });

      this.logger.log(
        `Updated SMS record ${updatedRecord.id} with Infobip Message ID ${updatedRecord.infobipMessageId}`,
      );

      return {
        internalMessageId: updatedRecord.id,
        infobipMessageId: updatedRecord.infobipMessageId,
        infobipStatus: messageResponse.status.groupName,
      };
    } catch (error) {
      this.logger.error(
        `Failed to send SMS via Infobip for record ${smsRecord.id}: ${error.message}`,
        error.response?.data || error.stack, // Log Infobip error details if available
      );

      // Update DB record to indicate failure
      await this.prisma.smsMessage.update({
        where: { id: smsRecord.id },
        data: {
          status: MessageStatus.REJECTED, // Or another appropriate failure status
        },
      });

      // Rethrow or return error information
      throw new Error(`Infobip API Error: ${error.message}`);
    }
  }

  // --- Helper to map Infobip status groups to our enum ---
  private mapInfobipStatus(infobipStatusGroup: string): MessageStatus {
    // It is crucial to verify this mapping against the current Infobip documentation for status groups to ensure accurate tracking.
    switch (infobipStatusGroup?.toUpperCase()) {
      case 'PENDING':
        return MessageStatus.PENDING;
      case 'SENT': // Note: Infobip 'SENT' might just mean accepted by them, not delivered. PENDING might be safer initial state.
        return MessageStatus.SENT;
      case 'DELIVERED':
        return MessageStatus.DELIVERED;
      case 'UNDELIVERABLE':
        return MessageStatus.UNDELIVERABLE;
      case 'EXPIRED':
        return MessageStatus.EXPIRED;
      case 'REJECTED':
        return MessageStatus.REJECTED;
      default:
        this.logger.warn(`Unknown Infobip status group: ${infobipStatusGroup}`);
        return MessageStatus.UNKNOWN;
    }
    // Note: Adapt this based on the specific statuses you care about from Infobip's documentation.
  }

  // --- Method to Handle Delivery Callbacks ---
  async handleDeliveryReport(reportDto: InfobipCallbackDto) {
    this.logger.log(`Processing ${reportDto.results.length} delivery report(s) from Infobip.`);

    for (const result of reportDto.results) {
        // --- Find the matching message using callbackData ---
        // CallbackData is the most reliable way to correlate
        if (!result.callbackData) {
          this.logger.warn(`Received delivery report without callbackData for messageId ${result.messageId}. Cannot correlate reliably.`);
          // Optional: Fallback to trying messageId, but less reliable if IDs collide or weren't stored yet
          continue;
        }

        try {
          const message = await this.prisma.smsMessage.findUnique({
            where: { callbackData: result.callbackData },
          });

          if (!message) {
            this.logger.warn(`Received delivery report with callbackData ${result.callbackData}, but no matching message found in DB. MessageId: ${result.messageId}. Maybe already processed or test data?`);
            continue; // Skip if no matching record found
          }

          // --- Idempotency Check ---
          // Avoid processing the same final status update multiple times if Infobip retries
          const newStatus = this.mapInfobipStatus(result.status.groupName);
          const isFinalStatus = [
              MessageStatus.DELIVERED,
              MessageStatus.UNDELIVERABLE,
              MessageStatus.EXPIRED,
              MessageStatus.REJECTED,
          ].includes(message.status);

          // Check if the status in DB is already final AND the incoming status is the same
          if (isFinalStatus && message.status === newStatus) {
              this.logger.log(`Message ${message.id} (CallbackData: ${result.callbackData}) already has final status ${message.status}. Ignoring duplicate report.`);
              continue; // Skip processing this result
          }

          // If the status is new or not final, proceed with update
          if (!isFinalStatus || message.status !== newStatus) {
            this.logger.log(
              `Updating message ${message.id} (CallbackData: ${result.callbackData}) from ${message.status} to ${newStatus}. Infobip Status: ${result.status.groupName} (${result.status.name})`,
            );

            await this.prisma.smsMessage.update({
              where: { id: message.id },
              data: {
                status: newStatus,
                // Optionally store more details from the report if needed
                // e.g., infobipDetailedStatus: result.status.name,
                //       errorCode: result.error?.name,
                //       statusUpdatedAt: result.doneAt // Use Infobip's timestamp if preferred
              },
            });
            this.logger.log(`Successfully updated message ${message.id}.`);
          }
        } catch (error) {
          this.logger.error(
            `Error processing delivery report for callbackData ${result.callbackData} (MessageId: ${result.messageId}): ${error.message}`,
            error.stack,
          );
          // Decide if you need to retry or just log the error.
          // Returning 204 to Infobip even on error prevents retries unless the error is temporary network issue.
        }
    } // End loop through results
  } // End handleDeliveryReport method
}

Explanation:

  1. Initialization: In onModuleInit, we fetch Infobip credentials and the app's base URL from ConfigService and initialize the Infobip client. Error handling ensures configuration is present.
  2. sendSingleSms Method:
    • Generates a unique callbackData using randomUUID().
    • Creates a record in the SmsMessage table with status: PENDING and the unique callbackData before calling Infobip. This ensures we have a record to update even if the Infobip call fails or the callback is delayed.
    • Constructs the Infobip payload, critically including:
      • notifyUrl: The absolute URL of our callback endpoint (Section 4). Infobip will POST delivery reports here.
      • notifyContentType: Set to application/json.
      • callbackData: The unique ID we generated.
    • Calls infobipClient.channels.sms.send().
    • On success, updates the corresponding database record with the infobipMessageId, infobipBulkId, and the initial status returned by Infobip (mapped via mapInfobipStatus).
    • On failure, logs the error (including Infobip's response data if available) and updates the database record status to REJECTED or similar.
    • Returns our internal ID and Infobip's ID for reference.
  3. mapInfobipStatus: A helper to translate Infobip's status group names (like ""PENDING"", ""DELIVERED"") into our MessageStatus enum values.
  4. handleDeliveryReport: (Implementation detailed in Section 5) Processes incoming delivery reports from Infobip, finds the corresponding message using callbackData, checks for idempotency, and updates the message status in the database.

Register Service and Controller: Update src/sms/sms.module.ts:

typescript
// src/sms/sms.module.ts
import { Module } from '@nestjs/common';
import { SmsService } from './services/sms.service';
import { SmsController } from './controllers/sms.controller';
// PrismaModule is global, ConfigModule is global - no need to import here

@Module({
  controllers: [SmsController],
  providers: [SmsService],
  exports: [SmsService], // Export if needed by other modules
})
export class SmsModule {}

Import SmsModule into src/app.module.ts:

typescript
// src/app.module.ts
// ... other imports
import { SmsModule } from './sms/sms.module'; // Import SmsModule

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    PrismaModule,
    SmsModule, // Add SmsModule here
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

4. Implementing the API Layer (Sending SMS)

Now, let's expose an endpoint to trigger the sendSingleSms service method.

4.1 Create Request DTO: Define a Data Transfer Object (DTO) for the request body with validation.

typescript
// src/sms/dto/send-sms.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, MaxLength, IsOptional } from 'class-validator';

export class SendSmsDto {
  @IsNotEmpty()
  @IsPhoneNumber(null) // Basic phone number validation (adapt region if needed)
  @IsString()
  recipient: string;

  @IsNotEmpty()
  @IsString()
  @MaxLength(160) // Standard SMS limit (adjust if using concatenation)
  text: string;

  @IsString()
  @MaxLength(11) // Alphanumeric sender ID limit
  @IsOptional() // Make optional if you have a default sender
  sender?: string; // Optional sender ID. If not provided, a default (e.g., 'InfoSMS') might be used by the service.
}

4.2 Implement Controller Endpoint: Add a POST endpoint to src/sms/controllers/sms.controller.ts.

typescript
// src/sms/controllers/sms.controller.ts
import { Controller, Post, Body, ValidationPipe, UsePipes, Logger, HttpCode, HttpStatus } from '@nestjs/common';
import { SmsService } from '../services/sms.service';
import { SendSmsDto } from '../dto/send-sms.dto';
import { InfobipCallbackDto } from '../dto/infobip-report.dto'; // Import Callback DTO

@Controller('sms') // Route prefix: /sms
export class SmsController {
  private readonly logger = new Logger(SmsController.name);

  constructor(private readonly smsService: SmsService) {}

  @Post('send') // Endpoint: POST /sms/send
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) // Enable validation locally if not global
  @HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted - processing started
  async sendSms(@Body() sendSmsDto: SendSmsDto) {
    this.logger.log(`Received request to send SMS to ${sendSmsDto.recipient}`);
    try {
      const result = await this.smsService.sendSingleSms(
        sendSmsDto.recipient,
        sendSmsDto.text,
        sendSmsDto.sender, // Pass sender if provided (service handles default)
      );
      this.logger.log(`SMS queued for sending with internal ID: ${result.internalMessageId}`);
      // Return relevant IDs
      return {
        message: 'SMS submitted successfully.',
        internalMessageId: result.internalMessageId,
        infobipMessageId: result.infobipMessageId,
      };
    } catch (error) {
      this.logger.error(`Error in /sms/send endpoint: ${error.message}`, error.stack);
      // Let NestJS default error handling or implement custom Exception Filters
      throw error;
    }
  }

  // --- Callback Endpoint ---
  @Post('callback/infobip') // Endpoint: POST /sms/callback/infobip
  @HttpCode(HttpStatus.NO_CONTENT) // Acknowledge receipt, no body needed
  @UsePipes(new ValidationPipe({ validateCustomDecorators: true, transform: true, whitelist: true })) // Validate callback payload
  async handleInfobipCallback(@Body() reportDto: InfobipCallbackDto) {
      this.logger.log(`Received Infobip callback request.`);
      // Delegate to service
      await this.smsService.handleDeliveryReport(reportDto);
      // Return 204 No Content to Infobip automatically due to @HttpCode
  }
}

Explanation:

  • @Controller('sms'): Defines the base route /sms for this controller.
  • @Post('send'): Defines the sub-route POST /sms/send.
  • @UsePipes(new ValidationPipe(...)): (If not global) Automatically validates the incoming request body against the SendSmsDto.
    • whitelist: true: Strips properties not defined in the DTO.
    • forbidNonWhitelisted: true: Throws an error if extra properties are present.
  • @Body() sendSmsDto: SendSmsDto: Injects the validated request body into the sendSmsDto parameter.
  • @HttpCode(HttpStatus.ACCEPTED): Sets the default success response code to 202 for /send, indicating the request was accepted for processing but completion is asynchronous.
  • Calls smsService.sendSingleSms with data from the DTO.
  • Returns a success response including the internal ID and the ID from Infobip.
  • @Post('callback/infobip'): Defines the sub-route POST /sms/callback/infobip for receiving delivery reports.
  • @HttpCode(HttpStatus.NO_CONTENT): Sets the response code to 204 for /callback/infobip. This is standard practice for webhook acknowledgements – it tells Infobip we received it successfully without needing to send back a body.
  • @UsePipes(...) on Callback: Validates the incoming callback payload against InfobipCallbackDto.
  • @Body() reportDto: InfobipCallbackDto: Injects the validated callback payload.
  • Delegates processing to smsService.handleDeliveryReport.

4.3 Enable Validation Globally (Optional but Recommended): Instead of @UsePipes on every handler, enable it globally in src/main.ts.

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common';
import { PrismaService } from './prisma/prisma.service'; // Import PrismaService

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const logger = new Logger('Bootstrap');

  // Enable CORS if your client is on a different domain
  app.enableCors();

  // Global Validation Pipe
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true, // Automatically transform payloads to DTO instances
      transformOptions: {
        enableImplicitConversion: true, // Allow basic type conversions
      },
    }),
  );

  // Graceful shutdown hooks for Prisma
  const prismaService = app.get(PrismaService);
  await prismaService.enableShutdownHooks(app);

  const port = process.env.PORT || 3000;
  await app.listen(port);
  logger.log(`Application listening on port ${port}`);
  logger.log(`API Base URL: ${process.env.APP_BASE_URL || `http://localhost:${port}`}`);
  logger.log(`Infobip Callback URL: ${process.env.APP_BASE_URL || `http://localhost:${port}`}/sms/callback/infobip`);

}
bootstrap();

Testing the Sending Endpoint:

Use curl or Postman to send a POST request:

bash
curl -X POST http://localhost:3000/sms/send \
-H ""Content-Type: application/json"" \
-d '{
  ""recipient"": ""+12345678900"",
  ""text"": ""Hello from NestJS and Infobip!"",
  ""sender"": ""MyApp""
}'

# Expected Response (202 Accepted):
# {
#  ""message"": ""SMS submitted successfully."",
#  ""internalMessageId"": ""..."", # UUID generated by your app
#  ""infobipMessageId"": ""..."" # ID from Infobip
# }

Check your database (dev.db) - you should see a new record in the SmsMessage table with status: PENDING (or SENT/REJECTED depending on immediate API response) and the correct infobipMessageId and callbackData.

5. Handling Infobip Delivery Callbacks

This is the core of tracking delivery status. Infobip will send a POST request to the notifyUrl we provided (/sms/callback/infobip).

5.1 Define Callback Payload DTO:

Important: The following DTO structure is an example. Always verify against the latest official Infobip API documentation for delivery report callbacks, as formats can change.

typescript
// src/sms/dto/infobip-report.dto.ts
import { Type } from 'class-transformer';
import { IsArray, IsDate, IsEnum, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, ValidateNested, IsBoolean } from 'class-validator';

// Define nested objects based on Infobip's documentation

class InfobipPriceDto {
  @IsNumber()
  pricePerMessage: number;

  @IsString()
  currency: string;
}

class InfobipStatusDto {
  @IsNumber()
  groupId: number;

  @IsString()
  groupName: string; // e.g., ""DELIVERED"", ""UNDELIVERABLE"", ""PENDING""

  @IsNumber()
  id: number;

  @IsString()
  name: string; // More specific status name

  @IsString()
  description: string;
}

class InfobipErrorDto {
  @IsNumber()
  groupId: number;

  @IsString()
  groupName: string;

  @IsNumber()
  id: number;

  @IsString()
  name: string;

  @IsString()
  description: string;

  @IsBoolean()
  permanent: boolean;
}


class InfobipResultDto {
  @IsString()
  bulkId: string;

  @IsString()
  messageId: string;

  @IsString()
  to: string;

  @IsDate()
  @Type(() => Date) // Transform string date from JSON to Date object
  sentAt: Date;

  @IsDate()
  @Type(() => Date)
  doneAt: Date; // Time of final status update

  @IsNumber()
  smsCount: number; // Number of SMS segments

  @IsOptional() // Price might not always be included
  @ValidateNested()
  @Type(() => InfobipPriceDto)
  price?: InfobipPriceDto;

  @ValidateNested()
  @Type(() => InfobipStatusDto)
  status: InfobipStatusDto;

  @IsOptional()
  @ValidateNested()
  @Type(() => InfobipErrorDto)
  error?: InfobipErrorDto;

  @IsString()
  @IsOptional() // Should be present if we sent it
  callbackData?: string;
}

// Main DTO for the callback payload
export class InfobipCallbackDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => InfobipResultDto)
  results: InfobipResultDto[];
}

Note: Refer to the latest Infobip documentation for the exact callback payload structure, as it can vary. This is a common example structure.

5.2 Implement Callback Handler in Service: The implementation for handleDeliveryReport was added to src/sms/services/sms.service.ts in Section 3.2. It performs the following steps for each result in the callback:

  1. Checks for the presence of callbackData.
  2. Finds the corresponding SmsMessage in the database using callbackData.
  3. Performs an idempotency check: If the message is already in a final state (DELIVERED, UNDELIVERABLE, etc.) and the incoming status is the same, it logs and skips the update to prevent redundant processing.
  4. If the status is new or the message wasn't in a final state, it maps the Infobip status to the internal MessageStatus enum.
  5. Updates the SmsMessage record in the database with the new status.
  6. Logs errors encountered during processing.

Testing the Callback:

  1. Start ngrok: Expose your local NestJS port (e.g., 3000) to the internet.
    bash
    ngrok http 3000
  2. Copy ngrok URL: Note the https:// forwarding URL provided by ngrok (e.g., https://<unique-subdomain>.ngrok.io).
  3. Update .env: Set APP_BASE_URL in your .env file to this ngrok URL.
    dotenv
    APP_BASE_URL=""https://<unique-subdomain>.ngrok.io""
  4. Restart NestJS: Ensure your application picks up the new APP_BASE_URL.
  5. Send an SMS: Use the POST /sms/send endpoint again. The notifyUrl sent to Infobip will now point to your ngrok tunnel.
  6. Monitor Logs: Watch your NestJS application logs and the ngrok console (http://localhost:4040). You should see Infobip making a POST request to /sms/callback/infobip shortly after the message reaches a final status (like DELIVERED or UNDELIVERABLE).
  7. Check Database: Verify that the status field for the corresponding SmsMessage record has been updated based on the callback.

Frequently Asked Questions

How to send SMS messages with Infobip and NestJS?

Create a NestJS controller with a POST endpoint that uses the Infobip Node.js SDK. This endpoint should handle user input (recipient and message content), construct the appropriate Infobip API request, and send the SMS message.

What is the Infobip callback mechanism for SMS delivery?

Infobip's callback mechanism provides asynchronous delivery status updates for sent SMS messages. This allows your application to track if and when a message was delivered, rather than relying solely on the initial send API response.

Why use delivery callbacks when sending SMS with Infobip?

Delivery callbacks are crucial for knowing the final status of an SMS message. This information is essential for critical applications like two-factor authentication, appointment reminders, and emergency alerts, as network issues can cause delays or failures after the initial 'send' confirmation.

When should I implement Infobip SMS delivery callbacks?

Implement delivery callbacks when reliable delivery tracking is crucial for the functionality of your application. This is particularly important when sending time-sensitive or transactional messages, such as one-time passwords or delivery confirmations.

How to set up Infobip SMS delivery callbacks in NestJS?

Create a dedicated controller endpoint (`/sms/callback/infobip`) in your NestJS app to receive POST requests from Infobip. This endpoint should process the callback data, validate its structure using a DTO, and then update the corresponding message status in your database.

What is the 'callbackData' field used for in Infobip SMS?

The `callbackData` field allows you to include a unique identifier (such as a UUID generated by your system) that Infobip will return in its delivery callbacks. This allows you to reliably correlate the callback data with the original message sent.

How to handle Infobip delivery report callbacks in NestJS?

Parse the callback data (which includes delivery status, message ID, and your 'callbackData') using class-transformer. Use the 'callbackData' to locate the corresponding message in your database and then update the status accordingly. Implement idempotency checks to prevent duplicate processing if Infobip retries callbacks.

What is the role of Prisma in an Infobip SMS integration with NestJS?

Prisma, an ORM, is used for database schema management and data access. You define the database schema (including an SmsMessage model to store information about each sent message) and Prisma generates efficient queries. This allows you to save message details, update their status after receiving callbacks, and easily access historical data.

What is the purpose of setting up an ngrok tunnel for Infobip callbacks during development?

Ngrok creates a secure public URL that tunnels to your local development server. Since Infobip needs to send callbacks to a publicly accessible URL, ngrok allows you to receive these callbacks while developing locally without deploying your application to a public server.

How to test Infobip delivery callbacks with ngrok and NestJS?

Use ngrok to create a public URL pointing to your local NestJS server. Update your .env file's APP_BASE_URL to the ngrok URL. Send a test SMS through your application and monitor your logs and the ngrok dashboard to confirm the callback is received and processed. Check your database to see updated message status from callbacks.

What technologies are recommended for building an Infobip SMS integration in NestJS?

The article recommends using Node.js with NestJS, the Infobip API and Node.js SDK, Prisma as an ORM, SQLite or PostgreSQL/MySQL for the database, and tools like @nestjs/config, class-validator, @nestjs/throttler, and @nestjs/terminus.

What is the system architecture for an Infobip SMS integration with delivery callbacks?

The client interacts with the NestJS API to send SMS messages. The NestJS API uses the Infobip API to send messages, stores message status in a database, and receives callbacks from Infobip at a designated endpoint to update message status.

How to make Prisma service available globally in NestJS?

Decorate the PrismaModule with @Global() and export PrismaService in its exports array. This allows any service that needs database access to inject PrismaService without additional imports.

What is the purpose of 'notifyUrl' in the Infobip API request payload?

The `notifyUrl` is the callback URL of your NestJS application that Infobip will use to send delivery reports (callbacks) asynchronously. This URL must be publicly accessible, often handled by ngrok during development and a public domain/IP in production.

Can I use a different database provider other than SQLite for storing SMS messages?

Yes, Prisma supports other database providers like PostgreSQL and MySQL. Simply adjust the provider and URL configuration in your prisma/schema.prisma file and .env file. The rest of the setup remains largely the same.