Implementing Two-Way SMS Messaging with Infobip and NestJS
This guide provides a comprehensive walkthrough for building a production-ready NestJS application capable of both sending outbound SMS messages and receiving inbound SMS messages via Infobip. We will cover project setup, Infobip integration, database interaction using Prisma, webhook handling for incoming messages, error handling, security considerations, and deployment using Docker.
Project Goal: To create a robust NestJS service that:
- Sends SMS messages using the Infobip API and Node.js SDK.
- Receives incoming SMS messages sent to an Infobip number via webhooks.
- Stores a record of both inbound and outbound messages in a PostgreSQL database.
- Provides a simple API endpoint to trigger sending messages.
- Includes basic security, error handling, and logging.
Technologies Used:
- Node.js: Runtime environment.
- NestJS: Progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, built-in dependency injection, and TypeScript support.
- Infobip: Communications Platform as a Service (CPaaS) provider used for sending and receiving SMS messages. We'll use their official Node.js SDK.
- PostgreSQL: Robust open-source relational database for storing message logs.
- Prisma: Next-generation ORM for Node.js and TypeScript. Chosen for its type safety, auto-generated migrations, and intuitive API.
- Docker & Docker Compose: For containerizing the application and its database dependency, ensuring consistent environments.
- TypeScript: Language used for enhanced type safety and developer productivity.
System Architecture:
+-------------+ +---------------------+ +-----------------+ +-------------+
| User / API | | NestJS Backend | | Infobip API | | User's Phone|
| Client |------->| (Controller,Service)|<------>| (SMS Sending) |<------>| |
+-------------+ | | +-----------------+ +-------------+
| +---------------+ | ^
| | Infobip SDK | | | SMS
| +---------------+ | | Reply
| | |
| +---------------+ | +------------------+ |
| | Prisma Client |<------>| PostgreSQL DB | |
| +---------------+ | | (Message Logs) | v
| | +------------------+ +-----------------+
| +---------------+ | | Infobip |
| | Webhook Handler|<-------------------------------| (Inbound SMS via|
| | (Controller) | | Webhook) |
| +---------------+ | +-----------------+
+---------------------+
Prerequisites:
- Node.js (v16 or higher recommended) and npm/yarn.
- Docker and Docker Compose installed.
- An Infobip account (a free trial account works_ but has limitations noted later).
- Access to a PostgreSQL database (we'll run one via Docker).
- Basic understanding of TypeScript_ NestJS concepts_ APIs_ and databases.
- A tool for making API requests (like
curl
or Postman).
1. Setting up the Project
First_ we'll initialize a new NestJS project and install necessary dependencies.
1.1. Initialize NestJS Project:
Open your terminal and run the NestJS CLI command to create a new project:
# Ensure you have NestJS CLI installed: npm install -g @nestjs/cli
npx @nestjs/cli new nestjs-infobip-sms
cd nestjs-infobip-sms
This command scaffolds a new project with a standard structure.
1.2. Install Dependencies:
We need several packages for configuration_ Infobip integration_ database access_ and validation.
# Infobip SDK
npm install @infobip-api/sdk
# Prisma ORM
npm install @prisma/client
npm install prisma --save-dev
# NestJS Config Module (for environment variables)
npm install @nestjs/config
# Class Validator & Transformer (for DTO validation)
npm install class-validator class-transformer
# (Optional_ but recommended for webhook security)
npm install @nestjs/throttler # For rate limiting
# (Optional_ for real-time updates via WebSockets)
# npm install @nestjs/websockets @nestjs/platform-socket.io
1.3. Initialize Prisma:
Initialize Prisma in your project_ specifying PostgreSQL as the database provider.
npx prisma init --datasource-provider postgresql
This command creates:
- A
prisma
directory. - A
schema.prisma
file for defining your database schema. - A
.env
file for environment variables (including the defaultDATABASE_URL
).
1.4. Project Structure Overview:
Your initial src
directory will look something like this:
src/
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
We will add modules_ services_ controllers_ DTOs_ and configuration files as we build the application.
2. Environment Configuration
Securely managing configuration_ especially API keys_ is critical. We'll use NestJS's ConfigModule
and a .env
file.
2.1. Configure .env
File:
Open the .env
file created by Prisma and add your Infobip credentials and other necessary variables. Remember to add .env
to your .gitignore
file to avoid committing secrets.
# .env
# Database
# Replace user_ password_ host_ port_ dbname with your actual local/dev settings
# Example for local setup:
DATABASE_URL=""postgresql://user:password@localhost:5432/mydb?schema=public""
# Example for Docker setup will be shown later_ pointing to the 'db' service name.
# Infobip Credentials
# Get these from your Infobip account dashboard (API Keys section)
INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY""
# Find your specific Base URL in the Infobip dashboard (often like xyz.api.infobip.com)
INFOBIP_BASE_URL=""YOUR_INFOBIP_BASE_URL""
# Application Port
PORT=3000
# Webhook Security (A simple shared secret)
# Generate a strong random string for this
INFOBIP_WEBHOOK_SECRET=""YOUR_STRONG_RANDOM_SECRET""
Create a .env.example
file mirroring .env
but with placeholder values_ and commit this example file to your repository.
2.2. Integrate ConfigModule
:
Import and configure ConfigModule
in your main application module (src/app.module.ts
) to make environment variables accessible throughout the application.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
import { AppController } from './app.controller';
import { AppService } from './app.service';
// We will create these modules later
// import { PrismaModule } from './prisma/prisma.module';
// import { InfobipModule } from './infobip/infobip.module';
// import { MessagingModule } from './messaging/messaging.module';
// import { WebhookModule } from './webhook/webhook.module';
@Module({
imports: [
ConfigModule.forRoot({ // Configure ConfigModule
isGlobal: true_ // Make config available globally
envFilePath: '.env'_ // Specify the env file path
})_
// PrismaModule_ // Will uncomment later
// InfobipModule_ // Will uncomment later
// MessagingModule_ // Will uncomment later
// WebhookModule_ // Will uncomment later
]_
controllers: [AppController]_ // Keep default controller for now
providers: [AppService]_ // Keep default service for now
})
export class AppModule {}
Explanation:
ConfigModule.forRoot()
loads variables from the.env
file.isGlobal: true
makes theConfigService
available application-wide without needing to importConfigModule
into every feature module.
3. Creating a Database Schema and Data Layer (Prisma)
We need a database table to log the SMS messages we send and receive.
3.1. Define the Prisma Schema:
Open prisma/schema.prisma
and define the Message
model.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // Reads from .env file
}
// Define the Message model
model Message {
id String @id @default(cuid()) // Unique identifier
externalId String? @unique // Infobip message ID (optional for initial save_ unique once set)
direction Direction // INBOUND or OUTBOUND
sender String // Sender phone number or identifier
recipient String // Recipient phone number
text String // Message content
status String? // Status from Infobip (e.g._ PENDING_ DELIVERED_ FAILED)
rawResponse Json? // Store raw Infobip response/webhook payload if needed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Enum for message direction
enum Direction {
INBOUND
OUTBOUND
}
Explanation:
Message
model stores relevant details about each SMS.externalId
: Stores the uniquemessageId
provided by Infobip. It's optional initially (?
) because we might save the outbound message before getting the ID_ and nullable because inbound messages might not always have one initially in certain error states. Marked@unique
as Infobip IDs should be unique.direction
: Uses anenum
for clarity.rawResponse
: Useful for debugging_ stores the full JSON payload from Infobip.
3.2. Run Database Migration:
Apply the schema changes to your database using Prisma Migrate. This command generates SQL and executes it.
# This creates a migration file and applies it to the database
npx prisma migrate dev --name init-message-model
Prisma will prompt you to name the migration (e.g._ init-message-model
) and then create the necessary SQL migration file in the prisma/migrations
directory and apply it to the database specified in your DATABASE_URL
. Ensure your PostgreSQL server is running. If running via Docker Compose (shown later)_ you'll run migrations after the container starts.
3.3. Create Prisma Service:
Create a reusable Prisma service following NestJS best practices.
# Create module and service files
mkdir src/prisma
touch src/prisma/prisma.module.ts
touch src/prisma/prisma.service.ts
// src/prisma/prisma.service.ts
import { Injectable_ OnModuleInit_ OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit_ OnModuleDestroy {
async onModuleInit() {
// Connect to the database when the module is initialized
await this.$connect();
}
async onModuleDestroy() {
// Disconnect from the database when the application shuts down
await this.$disconnect();
}
}
// src/prisma/prisma.module.ts
import { Global_ Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Make PrismaService available globally
@Module({
providers: [PrismaService]_
exports: [PrismaService]_ // Export the service 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
// ... other imports
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true_
envFilePath: '.env'_
})_
PrismaModule_ // Add PrismaModule here
// InfobipModule_
// MessagingModule_
// WebhookModule_
]_
controllers: [AppController]_
providers: [AppService]_
})
export class AppModule {}
4. Integrating with Infobip (Sending SMS)
Now_ let's set up the Infobip SDK to send SMS messages.
4.1. Create Infobip Module and Service:
We'll encapsulate Infobip logic within its own module.
mkdir src/infobip
touch src/infobip/infobip.module.ts
touch src/infobip/infobip.service.ts
4.2. Implement Infobip Service:
This service will initialize the Infobip client and provide a method for sending SMS.
// src/infobip/infobip.service.ts
import { Injectable_ Logger_ OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Infobip_ AuthType } from '@infobip-api/sdk';
@Injectable()
export class InfobipService implements OnModuleInit {
private readonly logger = new Logger(InfobipService.name);
private infobipClient: Infobip;
constructor(private configService: ConfigService) {}
onModuleInit() {
const apiKey = this.configService.get<string>('INFOBIP_API_KEY');
const baseUrl = this.configService.get<string>('INFOBIP_BASE_URL');
if (!apiKey || !baseUrl) {
this.logger.error('Infobip API Key or Base URL not configured. SMS functionality disabled.');
// You might throw an error here if Infobip is essential for startup
return;
}
try {
this.infobipClient = new Infobip({
baseUrl: baseUrl,
apiKey: apiKey,
authType: AuthType.ApiKey,
});
this.logger.log('Infobip client initialized successfully.');
} catch (error) {
this.logger.error('Failed to initialize Infobip client:', error);
}
}
async sendSms(to: string, text: string, from?: string): Promise<any> {
if (!this.infobipClient) {
this.logger.error('Infobip client not initialized. Cannot send SMS.');
throw new Error('Infobip service is not available.');
}
// **IMPORTANT: Sender ID (`from`)**
// If 'from' is not provided, use a default or one from config.
// - Alphanumeric Sender IDs (e.g., 'InfoSMS', 'MyApp'): Cannot receive replies. Good for alerts/notifications.
// - Purchased/Verified Numbers: Required for two-way communication (receiving replies). Must be obtained via Infobip.
// Regulations vary by country. Using 'InfoSMS' as a default might be misleading if replies are expected.
const sender = from || 'InfoSMS'; // Example default. Configure appropriately.
// Ensure 'to' number is in international E.164 format (e.g., 447123456789)
// **WARNING:** The regex below is extremely basic and insufficient for production validation.
// It does NOT properly validate E.164 (missing '+', country code checks, etc.).
// Strongly consider using a robust library like 'libphonenumber-js' for reliable validation.
if (!/^\d{11,15}$/.test(to.replace('+', ''))) {
this.logger.warn(`Recipient phone number format validation is basic and might be inaccurate: ${to}. Consider using a dedicated library. Attempting to send anyway.`);
// For stricter validation, you might throw an error here:
// throw new Error(`Invalid recipient phone number format: ${to}`);
}
this.logger.log(`Attempting to send SMS to ${to} from ${sender}`);
try {
const response = await this.infobipClient.channels.sms.send({
messages: [
{
destinations: [{ to: to }],
from: sender,
text: text,
},
],
});
this.logger.log(`SMS sent successfully via Infobip. Bulk ID: ${response.data.bulkId}`);
// Consider logging specific message IDs if needed: response.data.messages[0]?.messageId
return response.data; // Return the response data from Infobip
} catch (error) {
this.logger.error('Failed to send SMS via Infobip:', error.response?.data || error.message);
// Rethrow or handle the error appropriately
throw error;
}
}
}
Explanation:
- Injects
ConfigService
to retrieve API Key and Base URL. - Initializes the
Infobip
client inonModuleInit
. Includes basic error handling for configuration issues. sendSms
method constructs the payload and usesinfobipClient.channels.sms.send
.- Includes logging for better traceability.
- Includes a warning about the basic phone number validation and recommends a library.
- Manages the
from
address (Sender ID) with strong emphasis that alphanumeric IDs cannot receive replies and a purchased/verified number must be used for two-way communication. - Returns the Infobip API response on success, throws an error on failure.
4.3. Implement Infobip Module:
// src/infobip/infobip.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // ConfigModule is needed here if not global
import { InfobipService } from './infobip.service';
@Module({
imports: [ConfigModule], // Ensure ConfigService is available
providers: [InfobipService],
exports: [InfobipService], // Export the service
})
export class InfobipModule {}
4.4. Import Infobip Module:
Add InfobipModule
to the imports
array in 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 { PrismaModule } from './prisma/prisma.module';
import { InfobipModule } from './infobip/infobip.module'; // Import
// ... other imports
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
PrismaModule,
InfobipModule, // Add InfobipModule
// MessagingModule,
// WebhookModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
5. Implementing Core Functionality (Messaging Service)
Create a central service to orchestrate sending messages and saving records to the database.
5.1. Create Messaging Module and Service:
mkdir src/messaging
touch src/messaging/messaging.module.ts
touch src/messaging/messaging.service.ts
touch src/messaging/dto/send-sms.dto.ts # DTO for sending SMS
touch src/messaging/dto/infobip-incoming.dto.ts # DTO for incoming webhook
5.2. Define Data Transfer Objects (DTOs):
// src/messaging/dto/send-sms.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, Length } from 'class-validator';
export class SendSmsDto {
@IsNotEmpty()
@IsPhoneNumber(null) // Use null for generic international format check
@Length(10, 15) // Adjust length constraints as needed
readonly to: string;
@IsNotEmpty()
@IsString()
@Length(1, 160) // Standard SMS length limit (or more for concatenated)
readonly text: string;
// Optional: Allow specifying sender ID if needed and configured
// @IsOptional()
// @IsString()
// readonly from?: string;
}
// src/messaging/dto/infobip-incoming.dto.ts
// Based on common Infobip webhook structure for SMS MO (Mobile Originated)
// **IMPORTANT:** This DTO is an *example*. You MUST inspect the actual JSON payload
// sent by Infobip to your webhook endpoint (e.g., using webhook.site or logging
// the raw request body) and adjust this structure accordingly. Infobip's payload
// structure can vary based on account configuration and message type.
// Refer to Infobip documentation for the exact 'SMS Message Received' structure.
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
class MessageResult {
@IsNotEmpty()
@IsString()
messageId: string;
@IsNotEmpty()
@IsString()
from: string; // Sender's number
@IsNotEmpty()
@IsString()
to: string; // Your Infobip number
@IsNotEmpty()
@IsString()
text: string; // Message content
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@IsDateString()
receivedAt?: string; // Or use @Type(() => Date) if transforming
@IsOptional()
@IsNumber()
smsCount?: number;
// Add other potential fields based on Infobip docs (price, networkCode etc.)
}
class IncomingSmsPayload {
// This nested structure is common but verify it!
@IsNotEmpty()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MessageResult)
results: MessageResult[];
@IsOptional()
@IsNumber()
messageCount?: number;
@IsOptional()
@IsNumber()
pendingMessageCount?: number;
}
// The top-level DTO for the webhook payload
export class InfobipIncomingSmsDto {
// Assuming the payload looks like { "results": [...] }
// **VERIFY THIS STRUCTURE AGAINST ACTUAL INFOBIP PAYLOADS**
@IsNotEmpty()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MessageResult)
results: MessageResult[];
}
// --- ALTERNATIVE If payload is directly the array ---
// export class InfobipIncomingSmsDto extends Array<MessageResult> {}
// You would need to adjust validation pipes for arrays if using this.
5.3. Implement Messaging Service:
// src/messaging/messaging.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { InfobipService } from '../infobip/infobip.service';
import { SendSmsDto } from './dto/send-sms.dto';
import { InfobipIncomingSmsDto, MessageResult } from './dto/infobip-incoming.dto';
import { Direction } from '@prisma/client'; // Import enum from generated client
@Injectable()
export class MessagingService {
private readonly logger = new Logger(MessagingService.name);
constructor(
private readonly prisma: PrismaService,
private readonly infobip: InfobipService,
) {}
async handleOutgoingSms(sendSmsDto: SendSmsDto): Promise<{ messageId: string | null; dbId: string }> {
this.logger.log(`Handling outgoing SMS request to ${sendSmsDto.to}`);
let dbMessage;
try {
// 1. Save initial record to DB (optional, could save after sending)
dbMessage = await this.prisma.message.create({
data: {
direction: Direction.OUTBOUND,
recipient: sendSmsDto.to,
sender: 'SYSTEM', // Or the specific sender ID used
text: sendSmsDto.text,
status: 'PENDING_SEND', // Initial status
},
});
this.logger.log(`Saved initial outgoing message record with ID: ${dbMessage.id}`);
// 2. Send SMS via Infobip
const infobipResponse = await this.infobip.sendSms(sendSmsDto.to, sendSmsDto.text /*, sendSmsDto.from */);
// 3. Update DB record with Infobip details
const messageInfo = infobipResponse.messages?.[0];
if (messageInfo) {
await this.prisma.message.update({
where: { id: dbMessage.id },
data: {
externalId: messageInfo.messageId,
status: messageInfo.status?.name || 'SENT_TO_INFOBIP', // Use status from response
sender: messageInfo.from || 'SYSTEM', // Update sender if available in response
// Using `as any` bypasses type safety. Pragmatic for storing raw external data,
// but consider defining a basic interface for `infobipResponse` or using
// `unknown` with type guards for better practice if structure is known/stable.
rawResponse: infobipResponse as any,
},
});
this.logger.log(`Updated message ${dbMessage.id} with Infobip ID: ${messageInfo.messageId}`);
return { messageId: messageInfo.messageId, dbId: dbMessage.id };
} else {
this.logger.warn(`Infobip response did not contain message details for DB record ${dbMessage.id}`);
await this.prisma.message.update({ where: {id: dbMessage.id }, data: { status: 'SENT_NO_DETAILS'}});
return { messageId: null, dbId: dbMessage.id }; // Indicate success but no ID
}
} catch (error) {
this.logger.error(`Error handling outgoing SMS to ${sendSmsDto.to}:`, error);
// Update DB record to reflect failure if it was created
if (dbMessage) {
await this.prisma.message.update({
where: { id: dbMessage.id },
data: { status: 'FAILED_TO_SEND' },
}).catch(updateError => this.logger.error(`Failed to update message status after error: ${updateError}`));
}
// Rethrow the error to be handled by the controller/exception filter
throw error;
}
}
async handleIncomingSms(payload: InfobipIncomingSmsDto): Promise<void> {
this.logger.log(`Handling incoming SMS payload with ${payload.results?.length || 0} message(s)`);
if (!payload || !payload.results || payload.results.length === 0) {
this.logger.warn('Received empty or invalid incoming SMS payload.');
return;
}
for (const message of payload.results) {
try {
const existingMessage = await this.prisma.message.findUnique({
where: { externalId: message.messageId },
});
if (existingMessage) {
this.logger.warn(`Received duplicate incoming message with Infobip ID: ${message.messageId}. Skipping.`);
continue; // Avoid processing duplicates
}
await this.prisma.message.create({
data: {
externalId: message.messageId,
direction: Direction.INBOUND,
sender: message.from,
recipient: message.to,
text: message.text,
status: 'RECEIVED', // Or map from a status field in the webhook if available
// Using `as any` bypasses type safety here too. Consider a basic interface
// or `unknown` for the `message` object if its structure is reliable.
rawResponse: message as any, // Store the specific message part
createdAt: message.receivedAt ? new Date(message.receivedAt) : new Date(), // Use receivedAt if available
},
});
this.logger.log(`Saved incoming message from ${message.from} with Infobip ID: ${message.messageId}`);
// --- Optional: Trigger further actions ---
// e.g., Notify other services, process commands in the message text, emit WebSocket event
// this.eventEmitter.emit('sms.received', savedMessage);
} catch (error) {
this.logger.error(`Error processing incoming message ${message.messageId || 'N/A'} from ${message.from}:`, error);
// Continue processing other messages in the batch
}
}
}
}
Explanation:
- Injects
PrismaService
andInfobipService
. handleOutgoingSms
:- Takes the validated
SendSmsDto
. - (Optional) Creates an initial DB record with status
PENDING_SEND
. - Calls
InfobipService.sendSms
. - Updates the DB record with the
externalId
(InfobipmessageId
) and status from the response. Includes comment aboutas any
usage. - Includes error handling and updates the DB status on failure.
- Takes the validated
handleIncomingSms
:- Takes the validated
InfobipIncomingSmsDto
. - Iterates through the messages in the payload (Infobip webhooks can batch messages).
- Checks for duplicates based on
externalId
(InfobipmessageId
). - Creates a new DB record for each unique incoming message with
direction: INBOUND
. Includes comment aboutas any
usage. - Includes error handling for individual message processing.
- Takes the validated
5.4. Implement Messaging Module:
// src/messaging/messaging.module.ts
import { Module } from '@nestjs/common';
import { MessagingService } from './messaging.service';
// Import PrismaModule and InfobipModule if they are not global
// import { PrismaModule } from '../prisma/prisma.module';
// import { InfobipModule } from '../infobip/infobip.module';
@Module({
// imports: [PrismaModule, InfobipModule], // Needed if not global
providers: [MessagingService],
exports: [MessagingService], // Export service for use in Controllers
})
export class MessagingModule {}
5.5. Import Messaging Module:
Add MessagingModule
to 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 { PrismaModule } from './prisma/prisma.module';
import { InfobipModule } from './infobip/infobip.module';
import { MessagingModule } from './messaging/messaging.module'; // Import
// ... other imports
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
PrismaModule,
InfobipModule,
MessagingModule, // Add MessagingModule
// WebhookModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
6. Building the API Layer (Sending SMS)
Expose an HTTP endpoint to trigger sending SMS messages.
6.1. Create API Controller:
We can modify the default AppController
or create a new one (e.g., MessagingController
). Let's modify AppController
.
// src/app.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe } from '@nestjs/common';
// Remove AppService if not used
// import { AppService } from './app.service';
import { MessagingService } from './messaging/messaging.service';
import { SendSmsDto } from './messaging/dto/send-sms.dto';
@Controller('api/v1/messaging') // Define a base path for messaging endpoints
export class AppController {
private readonly logger = new Logger(AppController.name);
constructor(private readonly messagingService: MessagingService) {}
// Remove default GET endpoint if not needed
// @Get()
// getHello(): string {
// return this.appService.getHello();
// }
@Post('sms/send')
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is asynchronous
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Validate incoming DTO
async sendSms(@Body() sendSmsDto: SendSmsDto) {
this.logger.log(`Received request to send SMS to ${sendSmsDto.to}`);
try {
const result = await this.messagingService.handleOutgoingSms(sendSmsDto);
this.logger.log(`SMS queued for sending. DB ID: ${result.dbId}, Infobip ID: ${result.messageId || 'N/A'}`);
return {
message: 'SMS submitted successfully.',
databaseId: result.dbId,
infobipMessageId: result.messageId, // Can be null if Infobip response was unexpected
};
} catch (error) {
this.logger.error(`API Error sending SMS: ${error.message}`, error.stack);
// HttpExceptionFilter (added later) will handle formatting the error response
throw error; // Re-throw for the global filter
}
}
}
Explanation:
- Injects
MessagingService
. - Defines a
POST /api/v1/messaging/sms/send
endpoint. - Uses
@UsePipes(new ValidationPipe(...))
to automatically validate the incoming request body against theSendSmsDto
.transform: true
attempts to convert plain JS objects to DTO instances.whitelist: true
strips any properties not defined in the DTO.
- Calls
MessagingService.handleOutgoingSms
. - Returns a success response with relevant IDs or throws an error which will be caught by our global error handler.
- Uses
HttpStatus.ACCEPTED
(202) because the SMS isn't instantly delivered, just accepted for processing.
6.2. Testing the API Endpoint:
Once the application is running, you can test this endpoint using curl
:
curl -X POST http://localhost:3000/api/v1/messaging/sms/send \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+12345678900"", # Replace with a valid E.164 number
""text"": ""Hello from NestJS and Infobip! Test at '""`date`""'""
}'
# Expected Response (approximate):
# {
# ""message"": ""SMS submitted successfully."",
# ""databaseId"": ""cl..."",
# ""infobipMessageId"": ""2034...""
# }
Remember: If using an Infobip free trial, you can likely only send SMS to the phone number you registered with.
7. Handling Inbound Messages (Webhook)
Infobip notifies your application about incoming SMS messages by sending an HTTP POST request to a predefined URL (webhook).
7.1. Create Web