code examples

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

Build Two-Way SMS with Twilio, Node.js & NestJS: Complete Webhook Guide

Build production-ready two-way SMS messaging with Twilio API, Node.js, and NestJS. Learn webhook validation, conversation state management, database integration with Prisma, and deployment best practices.

Build Two-Way SMS with Twilio, Node.js & NestJS: Complete Webhook Guide

Build a production-ready two-way SMS messaging system using NestJS and Twilio's Communications API. Master webhook handling, conversation state management, security validation, and database integration for interactive SMS applications.

Create applications that both initiate SMS conversations and intelligently handle incoming replies, linking them to ongoing interactions. Essential for customer support chats, notifications with expected responses, appointment confirmations, or any interactive SMS communication scenario.

Technologies Used:

  • NestJS: Progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Modular architecture and dependency injection system ideal for complex projects.
  • Twilio: Cloud communications platform (CPaaS) providing APIs for SMS, voice, video, and more. Use Programmable Messaging API for sending and receiving SMS messages.
  • Twilio Node.js SDK: Official SDK v4+ (released January 2024) with TypeScript support (v2.9+), Node.js 18.x/20.x/22.x compatibility verified Q4 2024.
  • Prisma: Next-generation Node.js and TypeScript ORM simplifying database access with type safety, auto-completion, and automated migrations.
  • PostgreSQL: Powerful open-source object-relational database system (Prisma also supports MySQL, SQLite, MongoDB).
  • ngrok (Optional): 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) | +--------------------------+----------------------------+
  1. Outbound: The NestJS app calls the Twilio API to send an SMS to a user's phone number.
  2. Inbound: The user replies. Twilio receives the SMS and sends an HTTP POST request (webhook) to a designated endpoint in the NestJS application.
  3. Processing: The NestJS app receives the webhook, processes the incoming message (validates, stores it, potentially triggers logic), and updates the database via Prisma.
  4. 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:

bash
npm install -g @nestjs/cli
# or
yarn global add @nestjs/cli

1.2 Create New Project:

bash
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.

bash
# 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 uses class-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.

plaintext
#.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 with ngrok.

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.

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { 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.

bash
npx prisma init

This command does two things:

  1. Creates a prisma directory with a schema.prisma file.
  2. Creates or updates the .env file with a DATABASE_URL placeholder (which we've already populated).

Ensure your prisma/schema.prisma file correctly points to PostgreSQL and uses the environment variable:

prisma
// 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.

bash
nest generate module twilio
nest generate service twilio

Update the service to initialize the Twilio client and handle validation.

typescript
// 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.

typescript
// 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.

bash
nest generate module prisma
nest generate service prisma --flat # Use --flat for no sub-directory

Configure the PrismaService to connect and disconnect gracefully.

typescript
// 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.

typescript
// 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:

typescript
// 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
// 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.

bash
# Ensure your DATABASE_URL in .env is correctly pointing to your dev database
npx prisma migrate dev --name init-messaging-schema

This command:

  1. Creates a migration file in prisma/migrations/.
  2. Applies the migration to your database.
  3. 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.

typescript
// 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:

bash
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.

typescript
// 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.

typescript
// 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:

typescript
// 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:

bash
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:

bash
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:

  1. Validate the Request: Crucial for security (using TwilioService.validateWebhookRequest).
  2. Parse the Payload: Extract sender (From), recipient (To), message body (Body), and MessageSid.
  3. Find/Create Conversation: Look up the conversation using the From number (ensure it's normalized).
  4. Save the Message: Store the incoming message in the database, linking it to the conversation.
  5. Send a TwiML Reply: Respond to Twilio to acknowledge receipt or send an automated reply.
typescript
// 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:

typescript
// 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:

  1. Go to your Twilio Console -> Phone Numbers -> Active Numbers.
  2. Select the phone number you are using for this application.
  3. Scroll down to the "Messaging" section.
  4. Under "A MESSAGE COMES IN", select "Webhook".
  5. 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
  6. Ensure the method is set to HTTP POST.
  7. Click "Save".

5.4 Local Testing with ngrok:

If running locally:

  1. Start your NestJS app: npm run start:dev (usually runs on port 3000).
  2. Start ngrok: ngrok http 3000.
  3. ngrok will give you a public https URL (e.g., https://random123.ngrok.io).
  4. Update your .env file's API_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.
  5. 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, and InternalServerErrorException. Custom exceptions can be created for more specific error scenarios.

Enhanced Error Handling Strategy:

  1. Twilio API Errors: Handle specific Twilio error codes (20429 for rate limits, 21211 for invalid numbers)
  2. Webhook Validation Failures: Log signature mismatches for security monitoring
  3. Database Errors: Implement retry logic with exponential backoff for transient failures
  4. Rate Limiting: Use @nestjs/throttler to prevent abuse (configured in dependencies)

Webhook Response Time Requirements:

  • Twilio expects a 2xx response within 10 seconds (industry standard: 5-10 seconds)
  • If your endpoint times out, Twilio retries webhooks with exponential backoff
  • Return 200 OK immediately and process asynchronously to meet timing requirements
  • Implement background job processing for complex logic (use Bull, BullMQ, or similar)

Twilio Rate Limits (2024-2025 Verified):

  • Default SMS Rate: 1 message segment per second (MPS) on long codes (US/Canada)
  • API Concurrency Limit: Varies by account; returns HTTP 429 (Error 20429) when exceeded
  • Infinite Loop Protection: Maximum 30 outbound replies between two phone numbers in 30 seconds
  • Best Practice: Implement exponential backoff retries when receiving 429 responses
  • Higher Throughput: Consider toll-free numbers or short codes for increased capacity

Logging Best Practices:

  • Use NestJS Logger for consistent formatting and context
  • Log webhook receipt (message ID, sender) at INFO level
  • Log signature validation failures at WARN level
  • Log Twilio API errors with full context at ERROR level
  • Consider integration with external logging (Datadog, CloudWatch, Logz.io) for production
  • Never log full message content in production (PII/GDPR compliance)

Frequently Asked Questions About Twilio Two-Way SMS with NestJS

How do I validate Twilio webhook signatures?

Twilio computes an HMAC-SHA1 signature using your Auth Token and includes it in the X-Twilio-Signature header. Validate webhooks by: (1) retrieving the signature from the header, (2) reconstructing it using the full webhook URL (including query parameters), POST parameters, and your Auth Token, (3) comparing computed signature with received signature. Use Twilio SDK's validateRequest() method (shown in this guide's Section 5.2) for automatic validation. Always validate in production to prevent unauthorized webhook calls.

What is the Twilio SMS rate limit?

Twilio's default SMS rate limit is 1 message segment per second (MPS) for long codes in US/Canada (verified 2024-2025). Rate limits vary by phone number type (toll-free, short codes have higher limits). When exceeding your account's API concurrency limit, Twilio returns HTTP 429 (Error 20429). Implement exponential backoff retries and consider upgrading to toll-free numbers or short codes for higher throughput. Twilio also enforces a 30-message limit between two phone numbers within 30 seconds to prevent infinite loops.

How much does Twilio SMS cost?

Twilio SMS pricing varies by destination country. As of 2024-2025, typical US/Canada rates: $0.0079-0.01 per message segment (outbound), inbound messages typically free or minimal cost. International rates vary significantly (UK: ~$0.04/msg, India: ~$0.006/msg). Phone number rental: $1-2/month for local numbers, $2-3/month for toll-free. Check Twilio's pricing page for current rates. Volume discounts available for >100k messages/month.

Can I use Twilio with NestJS in production?

Yes, Twilio is enterprise-grade and production-ready with NestJS. Requirements: (1) stable HTTPS webhook URL with SSL certificate, (2) webhook signature validation enabled (shown in Section 5.2), (3) proper error handling and retry logic, (4) database for conversation state management, (5) rate limiting implementation (@nestjs/throttler), (6) monitoring and alerting, (7) compliance with regulations (TCPA for US, GDPR for EU). This guide provides production patterns for all requirements.

How do I test Twilio webhooks locally?

Test locally using ngrok: (1) start NestJS app (npm run start:dev on port 3000), (2) run ngrok http 3000 to expose localhost, (3) copy ngrok HTTPS URL (e.g., https://abc123.ngrok.io), (4) configure in Twilio Console under Phone Numbers → Messaging → Webhook URL, (5) send test SMS to your Twilio number, (6) monitor NestJS logs and ngrok web interface (http://127.0.0.1:4040). Alternative: Use Twilio CLI with twilio phone-numbers:update or mock webhook requests with cURL/Postman.

What Node.js version does Twilio SDK require?

Twilio Node.js SDK v4+ (released January 2024) supports Node.js 18.x, 20.x, and 22.x (verified Q4 2024). TypeScript support requires v2.9+. NestJS 10.x works seamlessly with these versions. Twilio Functions will default to Node.js v22 after June 1, 2025 (full migration by November 10, 2025). Ensure NPM dependencies are compatible with Node.js v22 before upgrading. This guide's code works with all supported Node.js LTS versions.

How do I track conversation state in two-way SMS?

Track conversation state using: (1) database table storing messages with conversationId linking related messages, (2) phone number as conversation identifier (create/retrieve conversation by fromNumber), (3) status field tracking conversation state (ACTIVE, CLOSED, AWAITING_REPLY), (4) lastMessageAt timestamp for timeout handling, (5) metadata JSONB field for custom context. This guide demonstrates full implementation using Prisma ORM with PostgreSQL (Section 3). Consider Redis for ephemeral state (5-15 minute expiry) or database for persistent history.

How do I handle message delivery failures?

Handle delivery failures by: (1) implementing retry logic with exponential backoff (1s, 2s, 4s delays), (2) catching Twilio SDK errors and checking status codes (21211: invalid number, 21610: unsubscribed/blocked), (3) storing failed messages in database with status: FAILED and errorCode, (4) implementing dead-letter queue for messages failing after max retries (3-5 attempts), (5) alerting for failure rate thresholds (>5%), (6) distinguishing permanent failures (invalid numbers) from transient (network errors, rate limits). Log full Twilio error responses for debugging.

Can I send MMS (multimedia messages) with Twilio and NestJS?

Yes, Twilio supports MMS in supported countries (US, Canada, UK). Send MMS using mediaUrl parameter in message body: await client.messages.create({ to, from, body, mediaUrl: ['https://example.com/image.jpg'] }). Requirements: HTTPS URLs for media, max 10 media files per message, supported formats (JPEG, PNG, GIF, MP4), max file size 5MB. MMS costs 3-5× standard SMS rates. Not all recipients support MMS (fallback to SMS automatically). Update DTO to include optional mediaUrl array field.

How do I secure Twilio webhooks in production?

Secure webhooks by: (1) always validating X-Twilio-Signature header (shown in Section 5.2), (2) using HTTPS only (enforced by Twilio), (3) implementing rate limiting on webhook endpoint (@nestjs/throttler: 100 requests/minute), (4) whitelisting Twilio IP ranges (optional, check Twilio docs for current IPs), (5) using environment variables for Auth Token (never commit to git), (6) logging all webhook requests for audit trails, (7) monitoring for unusual patterns (sudden spikes, malformed requests), (8) implementing CORS properly if exposing APIs. Never skip signature validation in production.

What is the difference between Twilio and Sinch?

Twilio provides webhook signature validation (X-Twilio-Signature header with HMAC-SHA1), broader geographic coverage, extensive documentation, and 1 MPS default rate for long codes. Sinch has no native webhook signature validation (requires custom implementation), offers 30 msg/s rate limit (per project), and competitive enterprise pricing. Both support Node.js SDKs, two-way messaging, and production deployments. Choose Twilio for robust security features and better documentation; choose Sinch for higher default throughput and enterprise features. This guide focuses on Twilio but patterns apply to both providers.

How do I deploy Twilio NestJS app to production?

Deploy to production: (1) build application (npm run build), (2) set environment variables on hosting platform (Heroku, AWS, DigitalOcean, Railway), (3) use process manager (PM2: pm2 start dist/main.js -i max for clustering), (4) configure reverse proxy (NGINX/Caddy) with SSL certificates (Let's Encrypt), (5) update Twilio webhook URL to production domain, (6) implement health checks for monitoring, (7) set up logging to external service (CloudWatch, Datadog), (8) configure auto-scaling based on CPU/memory, (9) use managed PostgreSQL (AWS RDS, DigitalOcean), (10) implement CI/CD pipeline (GitHub Actions, GitLab CI). Monitor webhook latency and Twilio API errors.

Conclusion

Build production-ready two-way SMS messaging applications using Twilio, Node.js, and NestJS. This implementation provides:

  • Webhook Integration: Secure HTTP endpoint receiving Twilio inbound SMS webhooks with signature validation
  • Conversation Management: Database-backed conversation state tracking linking messages by phone number
  • Type Safety: TypeScript with DTO validation using class-validator and Prisma ORM
  • Error Handling: Comprehensive error handling with retry logic, rate limit management, and exponential backoff
  • Production Patterns: Async webhook processing, proper logging, environment configuration, security validation
  • Scalability: Rate limiting, database connection pooling, considerations for high-volume messaging

Action Steps:

  1. Verify Twilio SDK v4+ and Node.js 18.x+ compatibility with your environment
  2. Implement webhook signature validation (never skip in production)
  3. Configure rate limiting using @nestjs/throttler (100 requests/minute recommended)
  4. Set up database migrations with Prisma for conversation tracking
  5. Test locally with ngrok before deploying to production
  6. Monitor webhook response times (<5 seconds) and Twilio API errors
  7. Implement exponential backoff for 429 rate limit responses
  8. Review TCPA/GDPR compliance requirements for your use case
  9. Set up monitoring and alerting for webhook failures and delivery issues
  10. Document your webhook URL and rotate Auth Token annually for security

Master Twilio's 1 MPS rate limit, 10-second webhook timeout, and signature validation requirements for reliable production deployments.

Frequently Asked Questions

How to send SMS messages with NestJS?

You can send SMS messages by creating a NestJS service that uses the Twilio Node.js SDK. This service interacts with the Twilio API to send messages. A controller then uses this service, handling the request and formatting the message appropriately before sending it via the Twilio API. See provided code examples for implementation details.

How to receive SMS messages with Twilio and NestJS?

Incoming messages are handled via webhooks. Set up a dedicated endpoint in your NestJS application. When a message arrives at your Twilio number, Twilio sends an HTTP POST request to this endpoint. Your application processes this request, which contains details like the sender, message body, and Twilio SID. See provided code examples for implementing webhooks.

How to set up Twilio webhooks for SMS in NestJS?

Create a controller with a POST route designated to handle incoming webhooks. Use this route's public URL in your Twilio phone number configuration so Twilio knows where to send incoming message notifications. Use a service like `ngrok` to create a public URL during local development and configure a more permanent URL such as your deployed site for production environments.

What database is recommended for storing SMS messages?

While the article utilizes PostgreSQL with Prisma, you could use other databases like MySQL, SQLite, and more that are compatible with Prisma. The code examples predominantly use PostgreSQL for storing conversation history and message details.

How to validate Twilio webhook requests in NestJS?

Validation is crucial for webhook security. Use the `validateRequest` or `validateRequestWithBody` function from the Twilio Node.js library. Provide the request signature, full URL, and request parameters. Ensure the URL matches what Twilio sent exactly. See provided code examples for correct validation implementation.

What is Prisma ORM and how does it help in this project?

Prisma is a next-generation ORM for Node.js and TypeScript. It simplifies database access with type safety and auto-completion. Prisma handles database migrations, allowing seamless schema updates, and simplifies interactions within services throughout your codebase. The code demonstrates Prisma alongside PostgreSQL.

How to structure a NestJS project for two-way SMS?

Create separate modules for Twilio integration, database interaction (Prisma), message sending (controller), and webhook handling (controller). The `Twilio` module should contain the Twilio Client and message validation functions. The `Messages` module houses API routes to trigger outbound SMS, while a separate `webhooks` module handles incoming messages sent to your Twilio number.

How to integrate Twilio with NestJS?

Install the `twilio` package. Create a `Twilio` module and service to encapsulate the Twilio client instantiation and methods for sending messages, validating requests. Inject the `ConfigService` from the `@nestjs/config` module to get credentials from `.env`. See code examples for implementation steps.

Why does Twilio webhook validation matter?

Webhook validation ensures that incoming requests genuinely originate from Twilio, preventing malicious actors from sending fake messages or accessing your application. Always validate incoming webhook requests using the Twilio library's validation function and a comparison between the signature Twilio sends with the body of the request.

What is the role of ngrok in local Twilio webhook testing?

Ngrok creates a temporary public URL that tunnels requests to your local development server. This lets you receive Twilio webhooks even though your NestJS app is running locally and not publicly available, crucial for the early phases of development and webhook configuration.

When should I use the Twilio Programmable Messaging API?

Use the Twilio Programmable Messaging API for sending and receiving SMS messages within your NestJS application. The API is integral to two-way SMS communication, covering functionalities like sending messages from your app and processing replies received via webhooks.

Can I use a different database besides PostgreSQL with Prisma?

Yes, Prisma supports various databases including MySQL, SQLite, MongoDB, and more. While the provided code samples use PostgreSQL, you can adapt the database connection string and Prisma schema to your preferred database by modifying the `provider` in your `schema.prisma` file and updating the `DATABASE_URL` in the `.env` file.

How to prevent duplicate webhook processing in NestJS?

Use a unique constraint in your database for the `twilioSid` (Twilio's unique Message SID). If you try to store a message with an existing `twilioSid`, Prisma will throw a unique constraint violation error. This ensures idempotency—processing the same webhook multiple times will not create duplicate entries. Catch this error to gracefully handle duplicate webhooks.

What is the best way to secure my Twilio Account SID and Auth Token in a NestJS project?

Store credentials (Account SID, Auth Token) securely in a `.env` file. This file should be excluded from version control (`.gitignore`). In your NestJS application, use the `@nestjs/config` package to load environment variables from the `.env` file. Never directly embed credentials into your code.