code examples
code examples
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:
- Sending SMS Messages: Exposing an API endpoint to trigger sending SMS messages via the Infobip platform.
- Tracking Delivery Status: Receiving asynchronous delivery status updates (callbacks) from Infobip for each sent message.
- 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.
npm install -g @nestjs/cli1.2 Create New Project: Generate a new NestJS project.
nest new nestjs-infobip-sms
cd nestjs-infobip-sms1.3 Install Dependencies: We need the Infobip SDK, configuration management, validation pipes, and Prisma.
npm install @infobip-api/sdk @nestjs/config class-validator class-transformer prisma @prisma/client
npm install --save-dev @types/node prisma1.4 Initialize Prisma:
Set up Prisma with SQLite (you can choose postgresql or mysql if preferred).
npx prisma init --datasource-provider sqliteThis 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.
# .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 anngroktunnel 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).
// 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/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 ourMessageStatusenum. Defaults toPENDING.callbackData: A crucial field. We'll generate a unique value (like our internalidor another UUID) and send it to Infobip. Infobip will include thiscallbackDatain 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 uniquecallbackDataandinfobipMessageId.
2.2 Create and Apply Migration: Generate the SQL migration file and apply it to your database.
# 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 deployThis 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.
nest g module prisma --flat
nest g service prisma --flatModify src/prisma.service.ts:
// 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:
// 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:
// 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:
nest g module sms
nest g service sms/services/sms --no-spec
nest g controller sms/controllers/sms --no-spec3.2 Configure Infobip Client:
We'll instantiate the Infobip client within the SmsService.
// 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:
- Initialization: In
onModuleInit, we fetch Infobip credentials and the app's base URL fromConfigServiceand initialize theInfobipclient. Error handling ensures configuration is present. sendSingleSmsMethod:- Generates a unique
callbackDatausingrandomUUID(). - Creates a record in the
SmsMessagetable withstatus: PENDINGand the uniquecallbackDatabefore 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 toapplication/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 viamapInfobipStatus). - On failure, logs the error (including Infobip's response data if available) and updates the database record status to
REJECTEDor similar. - Returns our internal ID and Infobip's ID for reference.
- Generates a unique
mapInfobipStatus: A helper to translate Infobip's status group names (like ""PENDING"", ""DELIVERED"") into ourMessageStatusenum values.handleDeliveryReport: (Implementation detailed in Section 5) Processes incoming delivery reports from Infobip, finds the corresponding message usingcallbackData, checks for idempotency, and updates the message status in the database.
Register Service and Controller:
Update src/sms/sms.module.ts:
// 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:
// 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.
// 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.
// 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/smsfor this controller.@Post('send'): Defines the sub-routePOST /sms/send.@UsePipes(new ValidationPipe(...)): (If not global) Automatically validates the incoming request body against theSendSmsDto.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 thesendSmsDtoparameter.@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.sendSingleSmswith data from the DTO. - Returns a success response including the internal ID and the ID from Infobip.
@Post('callback/infobip'): Defines the sub-routePOST /sms/callback/infobipfor 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 againstInfobipCallbackDto.@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.
// 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:
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.
// 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:
- Checks for the presence of
callbackData. - Finds the corresponding
SmsMessagein the database usingcallbackData. - 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. - If the status is new or the message wasn't in a final state, it maps the Infobip status to the internal
MessageStatusenum. - Updates the
SmsMessagerecord in the database with the new status. - Logs errors encountered during processing.
Testing the Callback:
- Start
ngrok: Expose your local NestJS port (e.g., 3000) to the internet.bashngrok http 3000 - Copy
ngrokURL: Note thehttps://forwarding URL provided byngrok(e.g.,https://<unique-subdomain>.ngrok.io). - Update
.env: SetAPP_BASE_URLin your.envfile to thisngrokURL.dotenvAPP_BASE_URL=""https://<unique-subdomain>.ngrok.io"" - Restart NestJS: Ensure your application picks up the new
APP_BASE_URL. - Send an SMS: Use the
POST /sms/sendendpoint again. ThenotifyUrlsent to Infobip will now point to yourngroktunnel. - Monitor Logs: Watch your NestJS application logs and the
ngrokconsole (http://localhost:4040). You should see Infobip making aPOSTrequest to/sms/callback/infobipshortly after the message reaches a final status (likeDELIVEREDorUNDELIVERABLE). - Check Database: Verify that the
statusfield for the correspondingSmsMessagerecord 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.