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.
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).
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.
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.
.env
# .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.tsimport { 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 globallyenvFilePath: '.env', // Specifies the env file path
}),
// Add other modules here (e.g., SmsModule, PrismaModule)
],
controllers: [AppController],
providers: [AppService],
})
exportclassAppModule {}
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.tsimport { Injectable, OnModuleInit, INestApplication } from'@nestjs/common';
import { PrismaClient } from'@prisma/client';
@Injectable()
exportclassPrismaServiceextendsPrismaClientimplementsOnModuleInit {
asynconModuleInit() {
// Optional: Connect to the database when the module initializesawaitthis.$connect();
}
// Optional: Disconnect gracefully on shutdownasyncenableShutdownHooks(app: INestApplication) {
process.on('beforeExit', async () => {
await app.close();
awaitthis.$disconnect();
});
}
}
Make PrismaService available globally by exporting it from PrismaModule:
TypeScript
// src/prisma.module.tsimport { Module, Global } from'@nestjs/common';
import { PrismaService } from'./prisma.service';
@Global() // Make PrismaService available globally@Module({
providers: [PrismaService],
exports: [PrismaService], // Export PrismaService for injection
})
exportclassPrismaModule {}
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.tsimport { 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 callbackDataimport { InfobipCallbackDto, InfobipResultDto } from'../dto/infobip-report.dto'; // Import Callback DTO@Injectable()
exportclassSmsServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(SmsService.name);
privateinfobipClient: Infobip;
privateappBaseUrl: string;
constructor(privateconfigService: ConfigService,
privateprisma: 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.',
);
thrownewError('Missing Infobip or App configuration.');
}
this.infobipClient = newInfobip({
baseUrl: baseUrl,
apiKey: apiKey,
authType: AuthType.ApiKey,
});
this.logger.log('Infobip client initialized.');
}
// --- Method to Send SMS ---asyncsendSingleSms(recipient: string, text: string, sender = 'InfoSMS') {
this.logger.log(`Attempting to send SMS to ${recipient}`);
// 1. Generate unique callback data for correlationconst uniqueCallbackData = randomUUID();
// 2. Create initial record in DB *before* sendinglet smsRecord;
try {
smsRecord = awaitthis.prisma.smsMessage.create({
data: {
recipient: recipient,
text: text,
senderId: sender,
status: MessageStatus.PENDING, // Initial statuscallbackData: 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 recordthrownewError('Database error while preparing SMS.');
}
// 3. Prepare Infobip Payloadconst payload = {
messages: [
{
destinations: [{ to: recipient }],
from: sender,
text: text,
// CRUCIAL: Tell Infobip where to send the delivery reportnotifyUrl: `${this.appBaseUrl}/sms/callback/infobip`, // Our callback endpointnotifyContentType: 'application/json',
// CRUCIAL: Include our unique IDcallbackData: uniqueCallbackData,
},
],
};
// 4. Send SMS via Infobip APItry {
const infobipResponse =
awaitthis.infobipClient.channels.sms.send(payload);
const messageResponse = infobipResponse.data.messages[0]; // Assuming single message responsethis.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 APIconst updatedRecord = awaitthis.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 failureawaitthis.prisma.smsMessage.update({
where: { id: smsRecord.id },
data: {
status: MessageStatus.REJECTED, // Or another appropriate failure status
},
});
// Rethrow or return error informationthrownewError(`Infobip API Error: ${error.message}`);
}
}
// --- Helper to map Infobip status groups to our enum ---privatemapInfobipStatus(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':
returnMessageStatus.PENDING;
case'SENT': // Note: Infobip 'SENT' might just mean accepted by them, not delivered. PENDING might be safer initial state.returnMessageStatus.SENT;
case'DELIVERED':
returnMessageStatus.DELIVERED;
case'UNDELIVERABLE':
returnMessageStatus.UNDELIVERABLE;
case'EXPIRED':
returnMessageStatus.EXPIRED;
case'REJECTED':
returnMessageStatus.REJECTED;
default:
this.logger.warn(`Unknown Infobip status group: ${infobipStatusGroup}`);
returnMessageStatus.UNKNOWN;
}
// Note: Adapt this based on the specific statuses you care about from Infobip's documentation.
}
// --- Method to Handle Delivery Callbacks ---asynchandleDeliveryReport(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 correlateif (!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 yetcontinue;
}
try {
const message = awaitthis.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 retriesconst 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 sameif (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 updateif (!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})`,
);
awaitthis.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 from ConfigService and initialize the Infobip client. Error handling ensures configuration is present.
sendSingleSms Method:
Generates a unique callbackData using randomUUID().
Creates a record in the SmsMessage table with status: PENDING and the unique callbackDatabefore 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.
mapInfobipStatus: A helper to translate Infobip's status group names (like ""PENDING"", ""DELIVERED"") into our MessageStatus enum values.
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.tsimport { 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
})
exportclassSmsModule {}
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.tsimport { IsNotEmpty, IsString, IsPhoneNumber, MaxLength, IsOptional } from'class-validator';
exportclassSendSmsDto {
@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 sendersender?: 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.tsimport { 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: /smsexportclassSmsController {
privatereadonly logger = newLogger(SmsController.name);
constructor(privatereadonlysmsService: SmsService) {}
@Post('send') // Endpoint: POST /sms/send@UsePipes(newValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) // Enable validation locally if not global@HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted - processing startedasyncsendSms(@Body() sendSmsDto: SendSmsDto) {
this.logger.log(`Received request to send SMS to ${sendSmsDto.recipient}`);
try {
const result = awaitthis.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 IDsreturn {
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 Filtersthrow error;
}
}
// --- Callback Endpoint ---@Post('callback/infobip') // Endpoint: POST /sms/callback/infobip@HttpCode(HttpStatus.NO_CONTENT) // Acknowledge receipt, no body needed@UsePipes(newValidationPipe({ validateCustomDecorators: true, transform: true, whitelist: true })) // Validate callback payloadasynchandleInfobipCallback(@Body() reportDto: InfobipCallbackDto) {
this.logger.log(`Received Infobip callback request.`);
// Delegate to serviceawaitthis.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.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe, Logger } from'@nestjs/common';
import { PrismaService } from'./prisma/prisma.service'; // Import PrismaServiceasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const logger = newLogger('Bootstrap');
// Enable CORS if your client is on a different domain
app.enableCors();
// Global Validation Pipe
app.useGlobalPipes(
newValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true, // Automatically transform payloads to DTO instancestransformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}),
);
// Graceful shutdown hooks for Prismaconst 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.tsimport { 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 documentationclassInfobipPriceDto {
@IsNumber()
pricePerMessage: number;
@IsString()
currency: string;
}
classInfobipStatusDto {
@IsNumber()
groupId: number;
@IsString()
groupName: string; // e.g., ""DELIVERED"", ""UNDELIVERABLE"", ""PENDING""@IsNumber()
id: number;
@IsString()
name: string; // More specific status name@IsString()
description: string;
}
classInfobipErrorDto {
@IsNumber()
groupId: number;
@IsString()
groupName: string;
@IsNumber()
id: number;
@IsString()
name: string;
@IsString()
description: string;
@IsBoolean()
permanent: boolean;
}
classInfobipResultDto {
@IsString()
bulkId: string;
@IsString()
messageId: string;
@IsString()
to: string;
@IsDate()
@Type(() =>Date) // Transform string date from JSON to Date objectsentAt: 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 itcallbackData?: string;
}
// Main DTO for the callback payloadexportclassInfobipCallbackDto {
@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 SmsMessage in the database using callbackData.
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 MessageStatus enum.
Updates the SmsMessage record 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.
Bash
ngrok http 3000
Copy ngrok URL: Note the https:// forwarding URL provided by ngrok (e.g., https://<unique-subdomain>.ngrok.io).
Update .env: Set APP_BASE_URL in your .env file to this ngrok URL.
Restart NestJS: Ensure your application picks up the new APP_BASE_URL.
Send an SMS: Use the POST /sms/send endpoint again. The notifyUrl sent to Infobip will now point to your ngrok tunnel.
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).
Check Database: Verify that the status field for the corresponding SmsMessage record has been updated based on the callback.
Frequently asked questions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
@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:
Prerequisites:
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.
1.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.
1.4 Initialize Prisma: Set up Prisma with SQLite (you can choose
postgresqlormysqlif preferred).This creates a
prismadirectory with aschema.prismafile and a.envfile.1.5 Configure Environment Variables: Modify the
.envfile 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.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/configmodule 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/configto load variables from.env, making them accessible viaConfigServicethroughout 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.prismaand define a model to represent an SMS message.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.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
SmsMessagetable in yourdev.dbSQLite file (or your configured database).2.3 Create Prisma Service: Create a reusable Prisma service according to NestJS best practices.
Modify
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
PrismaServiceavailable globally by exporting it fromPrismaModule:// 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
PrismaModuleintoAppModule:// 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
PrismaServicecan 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:
3.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:
onModuleInit, we fetch Infobip credentials and the app's base URL fromConfigServiceand initialize theInfobipclient. Error handling ensures configuration is present.sendSingleSmsMethod:callbackDatausingrandomUUID().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.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.infobipClient.channels.sms.send().infobipMessageId,infobipBulkId, and the initial status returned by Infobip (mapped viamapInfobipStatus).REJECTEDor similar.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
SmsModuleintosrc/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
sendSingleSmsservice 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.smsService.sendSingleSmswith data from the DTO.@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.smsService.handleDeliveryReport.4.3 Enable Validation Globally (Optional but Recommended): Instead of
@UsePipeson every handler, enable it globally insrc/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
curlor 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 theSmsMessagetable withstatus: PENDING(or SENT/REJECTED depending on immediate API response) and the correctinfobipMessageIdandcallbackData.5. Handling Infobip Delivery Callbacks
This is the core of tracking delivery status. Infobip will send a POST request to the
notifyUrlwe 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
handleDeliveryReportwas added tosrc/sms/services/sms.service.tsin Section 3.2. It performs the following steps for each result in the callback:callbackData.SmsMessagein the database usingcallbackData.DELIVERED,UNDELIVERABLE, etc.) and the incoming status is the same, it logs and skips the update to prevent redundant processing.MessageStatusenum.SmsMessagerecord in the database with the new status.Testing the Callback:
ngrok: Expose your local NestJS port (e.g., 3000) to the internet.ngrokURL: Note thehttps://forwarding URL provided byngrok(e.g.,https://<unique-subdomain>.ngrok.io)..env: SetAPP_BASE_URLin your.envfile to thisngrokURL.APP_BASE_URL.POST /sms/sendendpoint again. ThenotifyUrlsent to Infobip will now point to yourngroktunnel.ngrokconsole (http://localhost:4040). You should see Infobip making aPOSTrequest to/sms/callback/infobipshortly after the message reaches a final status (likeDELIVEREDorUNDELIVERABLE).statusfield for the correspondingSmsMessagerecord has been updated based on the callback.