This guide provides a step-by-step walkthrough for building a robust two-way SMS messaging system using the NestJS framework and the Twilio Communications API. We'll cover everything from project setup and core logic to security, deployment, and verification.
The goal is to create an application that can both initiate SMS conversations and intelligently handle incoming replies, linking them to ongoing interactions. This is essential for applications requiring customer support chats, notifications with expected responses, appointment confirmations, or any scenario involving interactive SMS communication.
Technologies Used:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Its modular architecture and dependency injection system make it ideal for complex projects.
- Twilio: A cloud communications platform as a service (CPaaS) providing APIs for SMS, voice, video, and more. We'll use its Programmable Messaging API for sending and receiving SMS messages.
- Prisma: A next-generation Node.js and TypeScript ORM. It simplifies database access with type safety, auto-completion, and automated migrations.
- PostgreSQL: A powerful, open-source object-relational database system (though Prisma supports others like MySQL, SQLite, etc.).
- (Optional) ngrok: A utility to expose local development servers to the internet, crucial for testing Twilio webhooks locally.
System Architecture:
Here's a high-level overview of how the components interact:
+-----------------+ +----------------------+ +----------------+ +----------+
| User's Phone |<---- | Twilio SMS Network | <----| NestJS App | ---> | Database |
| (Sends/Receives)| ---->| (Sends/Receives SMS) | ---->| (API/Webhook) | <--- | (Prisma) |
+-----------------+ +----------------------+ +----------------+ +----------+
^ | |
| (Webhook POST) | (API Call to Send) |
+--------------------------+----------------------------+
- Outbound: The NestJS app calls the Twilio API to send an SMS to a user's phone number.
- Inbound: The user replies. Twilio receives the SMS and sends an HTTP POST request (webhook) to a designated endpoint in the NestJS application.
- Processing: The NestJS app receives the webhook_ processes the incoming message (validates_ stores it_ potentially triggers logic)_ and updates the database via Prisma.
- State Management: The application uses the database to maintain conversation state_ linking incoming messages to previous outbound messages or existing conversations based on phone numbers.
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Twilio account with a provisioned phone number capable of sending/receiving SMS. You'll need your Account SID_ Auth Token_ and Twilio Phone Number.
- PostgreSQL database running (or access to a cloud instance).
- Basic understanding of TypeScript_ Node.js_ REST APIs_ and asynchronous programming.
- (Optional but recommended for local testing)
ngrok
installed.
Final Outcome:
By the end of this guide_ you will have a functional NestJS application capable of:
- Sending SMS messages via a REST API endpoint.
- Receiving incoming SMS messages via a Twilio webhook.
- Validating incoming Twilio webhook requests for security.
- Storing conversation history in a PostgreSQL database using Prisma.
- Linking incoming replies to existing conversations based on the sender's phone number.
- Basic error handling and logging.
1. Setting up the Project
Let's scaffold our NestJS project and install the necessary dependencies.
1.1 Install NestJS CLI:
If you don't have it installed globally_ run:
npm install -g @nestjs/cli
# or
yarn global add @nestjs/cli
1.2 Create New Project:
nest new nestjs-twilio-sms
cd nestjs-twilio-sms
This creates a new project with a standard structure.
1.3 Install Dependencies:
We need packages for Twilio integration_ database interaction (Prisma)_ configuration management_ and validation.
# Core dependencies
npm install @nestjs/config twilio prisma @prisma/client joi class-validator class-transformer @nestjs/throttler
# or
yarn add @nestjs/config twilio prisma @prisma/client joi class-validator class-transformer @nestjs/throttler
# Development dependencies
npm install -D @types/joi prisma
# or
yarn add -D @types/joi prisma
@nestjs/config
: For managing environment variables.twilio
: The official Twilio Node.js SDK.prisma
_@prisma/client
: Prisma ORM CLI and client.joi
_@types/joi
: Optional schema description and data validation (NestJS also usesclass-validator
).class-validator
_class-transformer
: Used by NestJS for validation pipes with DTOs.@nestjs/throttler
: For rate limiting.
1.4 Environment Variables:
Create a .env
file in the project root for storing sensitive credentials and configuration. Never commit this file to version control. Add it to your .gitignore
file if it's not already there.
#.env
# Database
# Ensure correct quoting for your environment/parser if needed_ but often no outer quotes are necessary.
DATABASE_URL=postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@YOUR_DB_HOST:YOUR_DB_PORT/YOUR_DB_NAME?schema=public
# Twilio Credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+15551234567 # Your Twilio phone number
# Application Settings
API_BASE_URL=http://localhost:3000 # Base URL for webhook validation (use ngrok URL for local dev)
PORT=3000
- Replace placeholders with your actual database connection string and Twilio credentials (found in your Twilio Console).
API_BASE_URL
is important for Twilio webhook validation_ especially during local development withngrok
.
1.5 Configure NestJS ConfigModule:
Import and configure the ConfigModule
in your main application module (src/app.module.ts
) to load environment variables from the .env
file.
// 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 { TwilioModule } from './twilio/twilio.module';
import { MessagesModule } from './messages/messages.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { ThrottlerModule_ ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true_ // Make ConfigService available globally
envFilePath: '.env'_ // Specify the env file path
})_
// Rate Limiting Configuration (See Section 7)
ThrottlerModule.forRoot([{
ttl: 60000_ // Time-to-live in milliseconds (e.g._ 60 seconds)
limit: 10_ // Max requests per TTL per IP
}])_
PrismaModule_
TwilioModule_
MessagesModule_
WebhooksModule_
]_
controllers: [AppController]_
providers: [
AppService_
// Apply rate limiting globally
{
provide: APP_GUARD_
useClass: ThrottlerGuard_
}_
]_
})
export class AppModule {}
1.6 Initialize Prisma:
Set up Prisma to manage our database schema and interactions.
npx prisma init
This command does two things:
- Creates a
prisma
directory with aschema.prisma
file. - Creates or updates the
.env
file with aDATABASE_URL
placeholder (which we've already populated).
Ensure your prisma/schema.prisma
file correctly points to PostgreSQL and uses the environment variable:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Models will be defined in Section 3
2. Implementing the Twilio Module
This module will encapsulate the Twilio client setup and provide a service for sending messages and validating webhooks.
nest generate module twilio
nest generate service twilio
Update the service to initialize the Twilio client and handle validation.
// src/twilio/twilio.service.ts
import { Injectable_ Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Twilio } from 'twilio';
import * as twilio from 'twilio'; // Import twilio namespace for static methods
@Injectable()
export class TwilioService {
private readonly logger = new Logger(TwilioService.name);
private client: Twilio;
public readonly twilioPhoneNumber: string; // Made public for easier access
constructor(private configService: ConfigService) {
const accountSid = this.configService.get<string>('TWILIO_ACCOUNT_SID');
const authToken = this.configService.get<string>('TWILIO_AUTH_TOKEN');
this.twilioPhoneNumber = this.configService.get<string>('TWILIO_PHONE_NUMBER');
if (!accountSid || !authToken || !this.twilioPhoneNumber) {
this.logger.error('Twilio credentials are not configured in .env file');
throw new Error('Twilio credentials are not configured.');
}
this.client = new Twilio(accountSid, authToken);
this.logger.log('Twilio client initialized.');
}
async sendMessage(to: string, body: string): Promise<string> {
try {
const message = await this.client.messages.create({
body: body,
from: this.twilioPhoneNumber,
to: to, // Ensure 'to' number is in E.164 format
});
this.logger.log(`Message sent to ${to}. SID: ${message.sid}`);
return message.sid;
} catch (error) {
this.logger.error(`Failed to send message to ${to}: ${error.message}`);
// Consider rethrowing or handling specific Twilio errors (e.g., invalid number)
throw error;
}
}
/**
* Validates an incoming Twilio webhook request.
* CRITICAL: Ensure the 'url' and 'params' accurately reflect what Twilio uses
* to generate the signature. If using body-parsing middleware, you might need
* access to the RAW request body for accurate validation.
* @param signature The X-Twilio-Signature header value.
* @param url The full URL (with protocol, host, and path) that Twilio requested.
* @param params The request parameters (usually the parsed request body for POST).
* @returns True if the signature is valid, false otherwise.
*/
validateWebhookRequest(
signature: string | string[],
url: string, // The full request URL Twilio hit
params: any, // The POST parameters
): boolean {
const authToken = this.configService.get<string>('TWILIO_AUTH_TOKEN');
// The 'twilio' library's validator expects the full URL including protocol and host
// Ensure the URL passed here matches exactly what Twilio used.
// Example: `https://<your-ngrok-or-domain>.io/webhooks/twilio/sms`
// Ensure signature is a single string
const sigHeader = Array.isArray(signature) ? signature[0] : signature;
if (!sigHeader) {
this.logger.warn('Missing X-Twilio-Signature header');
return false;
}
this.logger.debug(`Validating webhook: Sig='${sigHeader}', URL='${url}', Params=${JSON.stringify(params)}`);
try {
// Use the library's validation function (accessed via namespace import)
// IMPORTANT: If body-parsing middleware modified `params`, this might fail.
// Consider using `twilio.validateRequestWithBody` if you have the raw body.
return twilio.validateRequest(authToken, sigHeader, url, params);
} catch (error) {
this.logger.error(`Error during webhook validation: ${error.message}`);
return false;
}
}
}
Make sure the TwilioService
is provided and exported by TwilioModule
.
// src/twilio/twilio.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is available
import { TwilioService } from './twilio.service';
@Module({
imports: [ConfigModule], // Import ConfigModule if not global or imported elsewhere
providers: [TwilioService],
exports: [TwilioService], // Export the service for other modules to use
})
export class TwilioModule {}
Remember to import TwilioModule
into AppModule
(as shown in section 1.5).
3. Creating a Database Schema and Data Layer (Prisma)
We need to define our database models using Prisma to store conversations and messages.
3.1 Prisma Module:
It's good practice to encapsulate Prisma client instantiation in its own module.
nest generate module prisma
nest generate service prisma --flat # Use --flat for no sub-directory
Configure the PrismaService
to connect and disconnect gracefully.
// src/prisma.service.ts
import { Injectable, OnModuleInit, INestApplication, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit() {
try {
await this.$connect();
this.logger.log('Database connection established.');
} catch (error) {
this.logger.error('Failed to connect to the database', error.stack);
// Consider application exit strategy if DB connection fails on start
// process.exit(1);
}
}
async enableShutdownHooks(app: INestApplication) {
// Prisma recommends listening to Prisma-specific signals if possible,
// but NestJS hooks often suffice.
// Correctly listen for 'beforeExit' on the process object
process.on('beforeExit', async () => {
this.logger.log('Closing database connection due to app shutdown...');
// Ensure the app closes, which should trigger onModuleDestroy
await app.close();
});
}
// NestJS automatically calls onModuleDestroy on shutdown if enableShutdownHooks is called
// async onModuleDestroy() {
// this.logger.log('Disconnecting Prisma client...');
// await this.$disconnect();
// }
}
Configure the PrismaModule
. Making it global simplifies dependency injection.
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Make PrismaService available globally without importing PrismaModule everywhere
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
Remember to import PrismaModule
into AppModule
(as shown in section 1.5). Also, ensure enableShutdownHooks
is called in your main.ts
:
// src/main.ts (example snippet)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaService } from './prisma.service'; // Import PrismaService
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Get PrismaService instance (needed for shutdown hooks)
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app); // Call enableShutdownHooks
// ... other configurations (pipes, etc.)
await app.listen(3000); // Or use ConfigService for port
}
bootstrap();
3.2 Define Prisma Schema:
Update prisma/schema.prisma
with models for Conversation
and Message
. We'll link messages to conversations based on the participant's phone number (stored in E.164 format).
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Conversation {
id String @id @default(uuid())
participantNumber String @unique // The external participant's phone number (E.164 format recommended)
twilioNumber String // The Twilio number involved in this conversation (E.164 format)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[] // Relation to messages in this conversation
}
model Message {
id String @id @default(uuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
direction Direction // Incoming or Outgoing
body String
twilioSid String? @unique // Twilio's message SID, unique identifier (important for idempotency)
sentAt DateTime @default(now())
status String? // Optional: Status from Twilio (e.g., sent, delivered, failed)
@@index([conversationId]) // Index for faster message lookups by conversation
}
enum Direction {
INCOMING
OUTGOING
}
- Conversation: Represents a chat thread with a specific phone number.
participantNumber
should be unique and ideally stored in E.164 format. - Message: Represents a single SMS, linked to a
Conversation
.direction
indicates if it was sent by the application (OUTGOING
) or to the application (INCOMING
).twilioSid
is crucial for tracking and preventing duplicates. onDelete: Cascade
ensures messages are deleted if their parent conversation is deleted.
3.3 Run Database Migration:
Apply the schema changes to your database during development. Prisma will generate the SQL and execute it.
# Ensure your DATABASE_URL in .env is correctly pointing to your dev database
npx prisma migrate dev --name init-messaging-schema
This command:
- Creates a migration file in
prisma/migrations/
. - Applies the migration to your database.
- Generates/updates the Prisma Client (
@prisma/client
) based on the new schema.
3.4 Data Access (Example within Services):
You'll inject the PrismaService
into other services (like the MessagesController
or TwilioWebhookController
) to interact with the database.
// Example usage in another service (e.g., MessagesService if created)
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service'; // Auto-injectable due to @Global()
import { Direction } from '@prisma/client'; // Import generated enum
@Injectable()
export class SomeServiceUsingPrisma {
constructor(private prisma: PrismaService) {}
async findOrCreateConversation(participantNumber: string, twilioNumber: string) {
// Use upsert to find existing or create new conversation
return this.prisma.conversation.upsert({
where: { participantNumber }, // Assumes participantNumber is unique constraint
update: {}, // No update needed if found based on participantNumber
create: { participantNumber, twilioNumber },
});
}
async saveMessage(data: {
conversationId: string;
direction: Direction;
body: string;
twilioSid?: string;
status?: string;
}) {
return this.prisma.message.create({ data });
}
}
4. Building the API Layer for Sending Messages
Let's create an API endpoint to initiate outbound messages.
4.1 Create Messages Module, Controller, and DTO:
nest generate module messages
nest generate controller messages
# No separate service needed here, logic fits well in the controller
Define a DTO (Data Transfer Object) for validating the request body using class-validator
.
// src/messages/dto/send-message.dto.ts
import { IsNotEmpty, IsPhoneNumber, IsString, MaxLength } from 'class-validator';
export class SendMessageDto {
@IsPhoneNumber(undefined, { message: 'Recipient phone number must be in E.164 format (e.g., +15551234567).' }) // Validates E.164 format
@IsNotEmpty()
to: string;
@IsString()
@IsNotEmpty()
@MaxLength(1600, { message: 'Message body cannot exceed 1600 characters.' }) // Twilio SMS limit
body: string;
}
4.2 Implement Message Sending Endpoint:
The controller uses TwilioService
to send the SMS and PrismaService
to record it.
// src/messages/messages.controller.ts
import { Controller, Post, Body, ValidationPipe, UsePipes, Logger, InternalServerErrorException } from '@nestjs/common';
import { TwilioService } from '../twilio/twilio.service'; // Injected via module imports
import { PrismaService } from '../prisma.service'; // Injected via @Global() PrismaModule
import { SendMessageDto } from './dto/send-message.dto';
import { Direction } from '@prisma/client';
@Controller('messages')
export class MessagesController {
private readonly logger = new Logger(MessagesController.name);
constructor(
private readonly twilioService: TwilioService,
private readonly prisma: PrismaService,
) {}
@Post('send')
// Use ValidationPipe to automatically validate the request body against SendMessageDto
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async sendMessage(@Body() sendMessageDto: SendMessageDto) {
const { to, body } = sendMessageDto;
// Access the configured Twilio number from the service
const twilioNumber = this.twilioService.twilioPhoneNumber;
try {
// 1. Find or create the conversation based on the recipient number (using E.164 format)
const conversation = await this.prisma.conversation.upsert({
where: { participantNumber: to },
update: {}, // No update needed if conversation exists
create: { participantNumber: to, twilioNumber: twilioNumber },
});
// 2. Send the message via Twilio
const messageSid = await this.twilioService.sendMessage(to, body);
// 3. Save the outgoing message to the database
const savedMessage = await this.prisma.message.create({
data: {
conversationId: conversation.id,
direction: Direction.OUTGOING,
body: body,
twilioSid: messageSid,
status: 'sent', // Initial status; could be updated via status callbacks later
},
});
this.logger.log(`Message initiated to ${to}, SID: ${messageSid}, DB ID: ${savedMessage.id}`);
return { success: true, messageSid: messageSid, conversationId: conversation.id };
} catch (error) {
this.logger.error(`Failed to send message to ${to}: ${error.message}`, error.stack);
// Consider more specific error handling based on Twilio or Prisma errors
throw new InternalServerErrorException('Failed to send message.');
}
}
}
Configure the MessagesModule
:
// src/messages/messages.module.ts
import { Module } from '@nestjs/common';
import { MessagesController } from './messages.controller';
// Import necessary modules that export services used by the controller
import { TwilioModule } from '../twilio/twilio.module'; // Provides TwilioService
// PrismaModule is global, so no need to import here
@Module({
imports: [
TwilioModule, // Makes TwilioService available for injection
],
controllers: [MessagesController],
providers: [], // No specific service for this module in this example
})
export class MessagesModule {}
Remember to import MessagesModule
into AppModule
(as shown in section 1.5).
4.3 Testing the Sending Endpoint:
Use curl
or Postman:
curl -X POST http://localhost:3000/messages/send \
-H ""Content-Type: application/json"" \
-d '{
""to"": ""+15559876543"",
""body"": ""Hello from NestJS and Twilio!""
}'
Replace +15559876543
with a real test number in E.164 format. You should receive the SMS on the test number, and see logs in your NestJS console. A record should appear in your Conversation
and Message
tables.
5. Handling Incoming Messages (Webhook)
This is the core of two-way messaging. We need an endpoint that Twilio can call when it receives an SMS directed at your Twilio number.
5.1 Create Webhook Module and Controller:
nest generate module webhooks
nest generate controller webhooks/twilio --flat # Place controller directly in webhooks
5.2 Implement Webhook Endpoint:
This endpoint receives POST requests from Twilio (typically application/x-www-form-urlencoded
). It must:
- Validate the Request: Crucial for security (using
TwilioService.validateWebhookRequest
). - Parse the Payload: Extract sender (
From
), recipient (To
), message body (Body
), andMessageSid
. - Find/Create Conversation: Look up the conversation using the
From
number (ensure it's normalized). - Save the Message: Store the incoming message in the database, linking it to the conversation.
- Send a TwiML Reply: Respond to Twilio to acknowledge receipt or send an automated reply.
// src/webhooks/twilio.controller.ts
import { Controller, Post, Body, Headers, Req, Res, Logger, ForbiddenException, InternalServerErrorException, BadRequestException } from '@nestjs/common';
import { Request, Response } from 'express'; // Use Express types for Req/Res
import { TwilioService } from '../twilio/twilio.service';
import { PrismaService } from '../prisma.service';
import { Direction, Prisma } from '@prisma/client'; // Import Prisma namespace for error codes
import { twiml } from 'twilio'; // For TwiML responses
import { ConfigService } from '@nestjs/config';
// Define a simple DTO for expected webhook payload fields
// This helps with basic structure validation if NestJS parses the body
// Note: Twilio sends PascalCase keys
class TwilioWebhookDto {
From: string;
To: string;
Body: string;
MessageSid: string;
SmsStatus?: string; // Optional status field
// Add other fields if needed (e.g., NumMedia)
}
@Controller('webhooks/twilio')
export class TwilioWebhookController {
private readonly logger = new Logger(TwilioWebhookController.name);
constructor(
private readonly twilioService: TwilioService,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
// Note: Twilio sends data as 'application/x-www-form-urlencoded'.
// NestJS's default body parser handles this.
// IMPORTANT: Validation requires the exact URL Twilio requested.
@Post('sms') // Endpoint: /webhooks/twilio/sms
async handleIncomingSms(
@Headers('X-Twilio-Signature') signature: string,
@Body() body: TwilioWebhookDto, // Use DTO, NestJS parses urlencoded body
@Req() request: Request, // Inject original Express request
@Res() response: Response, // Inject Express response for TwiML
) {
this.logger.log(`Incoming SMS webhook received. SID: ${body.MessageSid}`);
this.logger.debug(`Webhook Body: ${JSON.stringify(body)}`);
// 1. Construct the full URL Twilio requested (needed for validation)
// Ensure API_BASE_URL is correctly set (e.g., https://your-ngrok.io or https://your-domain.com)
const baseUrl = this.configService.get<string>('API_BASE_URL');
if (!baseUrl) {
this.logger.error('API_BASE_URL is not configured in environment variables.');
// Don't send TwiML here, it's an internal configuration error.
response.status(500).send('Internal server configuration error.');
return;
}
const fullUrl = `${baseUrl}${request.originalUrl}`;
// 2. Validate Twilio Request (CRITICAL FOR SECURITY)
// Pass the parsed body (params). If validation fails due to body parsing,
// you might need to access the raw body BEFORE NestJS parses it.
// This typically involves configuring `bodyParser: false` for the route
// and using a library like `raw-body`. For simplicity, we assume default parsing works.
const isValid = this.twilioService.validateWebhookRequest(
signature,
fullUrl, // Use the reconstructed full URL
body, // Pass the parsed body as parameters
);
if (!isValid) {
this.logger.warn(`Invalid Twilio signature for URL: ${fullUrl}. Request rejected.`);
// Respond with 403 Forbidden. DO NOT process further.
// Avoid sending TwiML here; just send HTTP status.
response.status(403).send('Invalid Twilio signature.');
return; // Stop execution
}
this.logger.log('Twilio signature validated successfully.');
// 3. Extract required fields (already partially validated by DTO structure)
const fromNumber = body.From; // Sender's number (E.164)
const twilioNumber = body.To; // Your Twilio number (E.164)
const messageBody = body.Body;
const messageSid = body.MessageSid;
// Optional: Add more robust validation if needed
if (!fromNumber || !twilioNumber || typeof messageBody === 'undefined' || !messageSid) {
this.logger.error('Webhook payload missing required fields after parsing.');
// Send a simple error response back to Twilio. Avoid TwiML if it's a bad request format.
response.status(400).send('Missing required fields.');
return;
}
try {
// 4. Find or Create Conversation (use normalized E.164 numbers)
const conversation = await this.prisma.conversation.upsert({
where: { participantNumber: fromNumber },
update: {}, // No update needed if found
create: { participantNumber: fromNumber, twilioNumber: twilioNumber },
});
// 5. Save Incoming Message (use unique constraint on twilioSid for idempotency)
try {
await this.prisma.message.create({
data: {
conversationId: conversation.id,
direction: Direction.INCOMING,
body: messageBody,
twilioSid: messageSid,
status: body.SmsStatus ?? 'received', // Capture status if provided
},
});
this.logger.log(`Incoming message from ${fromNumber} saved. SID: ${messageSid}`);
} catch (error) {
// Handle potential unique constraint violation (duplicate webhook) gracefully
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
this.logger.warn(`Duplicate message received (SID: ${messageSid}). Ignoring.`);
// Respond to Twilio successfully as we've already processed this SID
} else {
throw error; // Re-throw other database errors
}
}
// 6. Prepare and Send TwiML Response
// TwiML (Twilio Markup Language) is an XML-based language to instruct Twilio.
const twimlResponse = new twiml.MessagingResponse();
// Example: A simple auto-reply
twimlResponse.message('Thanks for your message! We received it.');
// If you need complex logic: Check messageBody, query APIs, etc., *before* sending response.
// Keep webhook handlers fast. Offload long tasks to background jobs if needed.
// Send the TwiML response back to Twilio
response.type('text/xml');
response.send(twimlResponse.toString());
this.logger.log(`Sent TwiML acknowledgement to Twilio for SID: ${messageSid}`);
} catch (error) {
this.logger.error(`Error processing incoming SMS from ${fromNumber} (SID: ${messageSid}): ${error.message}`, error.stack);
// Strategy: Send a TwiML error message back to Twilio so it knows something went wrong.
// Avoid leaking sensitive details in the message.
const twimlError = new twiml.MessagingResponse();
twimlError.message('Sorry, we encountered an error processing your message. Please try again later.');
response.type('text/xml');
response.status(500).send(twimlError.toString());
// Alternative: If the error should propagate internally and not send TwiML error:
// throw new InternalServerErrorException('Failed to process incoming message.');
}
}
}
Configure the WebhooksModule
:
// src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { TwilioWebhookController } from './twilio.controller';
import { TwilioModule } from '../twilio/twilio.module'; // Provides TwilioService for validation
import { ConfigModule } from '@nestjs/config'; // Needed for API_BASE_URL
// PrismaModule is global
@Module({
imports: [
TwilioModule,
ConfigModule, // Import ConfigModule to access ConfigService
],
controllers: [TwilioWebhookController],
})
export class WebhooksModule {}
Remember to import WebhooksModule
into AppModule
(as shown in section 1.5).
5.3 Configure Twilio Webhook URL:
- Go to your Twilio Console -> Phone Numbers -> Active Numbers.
- Select the phone number you are using for this application.
- Scroll down to the "Messaging" section.
- Under "A MESSAGE COMES IN", select "Webhook".
- Enter the publicly accessible URL for your webhook endpoint.
- Local Testing (ngrok):
https://<your-ngrok-subdomain>.ngrok.io/webhooks/twilio/sms
- Production:
https://your-production-domain.com/webhooks/twilio/sms
- Local Testing (ngrok):
- Ensure the method is set to
HTTP POST
. - Click "Save".
5.4 Local Testing with ngrok:
If running locally:
- Start your NestJS app:
npm run start:dev
(usually runs on port 3000). - Start ngrok:
ngrok http 3000
. - ngrok will give you a public
https
URL (e.g.,https://random123.ngrok.io
). - Update your
.env
file'sAPI_BASE_URL
to this ngrok URL (e.g.,API_BASE_URL=https://random123.ngrok.io
). Restart your NestJS app for the change to take effect. - Use this full ngrok URL + endpoint path (
https://random123.ngrok.io/webhooks/twilio/sms
) in the Twilio Console configuration.
Now, when you send an SMS to your Twilio number, Twilio will forward it to your local NestJS application via ngrok. Check your NestJS logs and database.
6. Error Handling and Logging
- Built-in Exceptions: NestJS handles standard HTTP errors. We used
ForbiddenException
,BadRequestException
, andInternalServerErrorException
. Custom exceptions can be created for more specific error scenarios.