code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

How to Track Twilio SMS Delivery Status with NestJS: Complete Webhook Guide

Build production-ready SMS delivery tracking with Twilio webhooks and NestJS. Complete guide with status callbacks, signature validation, and database integration.

How to Track Twilio SMS Delivery Status with NestJS: Complete Webhook Guide

Meta Description: Build production-ready SMS delivery tracking with Twilio webhooks and NestJS. Complete guide with status callbacks, signature validation, and database integration.

This guide provides a complete walkthrough for building a production-ready system using NestJS and Node.js to send SMS messages via Twilio and reliably track their delivery status using Twilio's status callback webhooks. You'll learn how to implement SMS delivery tracking, handle webhook callbacks, validate Twilio signatures, and store message status updates in a database. You'll cover everything from initial project setup to deployment and verification, ensuring you have a robust SMS delivery monitoring solution.

You'll focus on creating a system that not only sends messages but also listens for status updates (like queued, sent, delivered, failed, undelivered) from Twilio, enabling features like real-time delivery confirmation, logging for auditing, and triggering subsequent actions based on message status.

What You'll Build: SMS Delivery Tracking System

Project Components:

A NestJS application with the following capabilities:

  1. An API endpoint to trigger sending SMS messages via Twilio.
  2. Integration with the Twilio Node.js helper library.
  3. A dedicated webhook endpoint to receive message status updates from Twilio.
  4. Secure handling of Twilio credentials and webhook requests.
  5. (Optional but recommended) Storing message details and status updates in a database (using TypeORM and PostgreSQL).
  6. Robust logging and error handling.

Problem Solved:

Standard SMS sending often operates on a "fire-and-forget" basis. This guide addresses the need for reliable delivery confirmation. Knowing if and when a message reaches the recipient is crucial for many applications, including notifications, alerts, two-factor authentication, and customer communication workflows. Tracking delivery status provides visibility and enables automated responses to delivery failures.

Technologies Used:

  • Node.js: The underlying JavaScript runtime environment. This guide uses Node.js v20 LTS (maintenance until April 2026) or v22 LTS (active LTS through October 2025, recommended for new projects).
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. This guide targets NestJS v11.x (latest as of January 2025). Its modular architecture and built-in features (dependency injection, decorators, etc.) make it ideal for structured applications.
  • Twilio: A cloud communications platform providing APIs for SMS, voice, video, and more. You'll use their Programmable Messaging API and Node.js helper library (v5.x latest version, providing TypeScript support, improved security, and 31% smaller bundle size).
  • TypeScript: Superset of JavaScript adding static typing, enhancing code quality and maintainability.
  • dotenv: For managing environment variables.
  • class-validator & class-transformer: For request data validation.
  • (Optional) TypeORM v0.3.27 & PostgreSQL: For database persistence with full PostgreSQL support.
  • (Development) ngrok: To expose the local development server to the internet for Twilio webhooks.

System Architecture:

mermaid
sequenceDiagram
    participant User as User/Client App
    participant API as NestJS API (SMS Sending)
    participant NestJS as NestJS Application
    participant TwilioAPI as Twilio Messaging API
    participant TwilioWebhook as Twilio Status Callback
    participant NestJSCallback as NestJS API (Callback Handler)
    participant DB as Database (Optional)

    User->>API: POST /sms/send (to, body)
    API->>NestJS: Trigger send SMS service
    NestJS->>TwilioAPI: client.messages.create({ to, from, body, statusCallback: '/twilio/status' })
    TwilioAPI-->>NestJS: message SID
    NestJS-->>API: Acknowledge send request (with SID)
    API-->>User: Success/Failure (with SID)

    TwilioAPI->>TwilioWebhook: SMS Status Update (queued, sent, delivered, etc.)
    TwilioWebhook->>NestJSCallback: POST /twilio/status (MessageSid, MessageStatus, etc.)
    NestJSCallback->>NestJS: Validate Twilio Signature (Middleware)
    alt Signature Valid
        NestJSCallback->>DB: Log/Update Message Status (using MessageSid)
        DB-->>NestJSCallback: DB Write Success
        NestJSCallback-->>TwilioWebhook: HTTP 200 OK (or 204 No Content)
    else Signature Invalid
        NestJSCallback-->>TwilioWebhook: HTTP 403 Forbidden
    end

Prerequisites:

  • Node.js v20 LTS (maintenance mode) or v22 LTS (active LTS, recommended) and npm/yarn installed. Note: Node.js v18 LTS support ends April 2025.
  • A Twilio account (Free Tier is sufficient to start). Find your Account SID and Auth Token in the Twilio Console.
  • A Twilio phone number with SMS capabilities.
  • Basic understanding of TypeScript, REST APIs, and NestJS concepts.
  • (Optional) PostgreSQL database running locally or accessible.
  • (Development) ngrok installed to expose your local server.

Final Outcome:

By the end of this guide, you'll have a fully functional NestJS application capable of sending SMS messages and reliably tracking their delivery status via Twilio webhooks, including optional database persistence and essential security measures.

How to Set Up Your NestJS Project for Twilio Integration

Initialize your NestJS project and set up the basic structure and dependencies for SMS delivery tracking.

Step 1: Create a new NestJS project

Open your terminal and run the NestJS CLI command:

bash
# Install NestJS CLI if you haven't already (NestJS v11.x)
npm install -g @nestjs/cli

# Create the project (choose npm or yarn)
nest new nestjs-twilio-sms-delivery-status-callbacks
cd nestjs-twilio-sms-delivery-status-callbacks

Step 2: Install necessary dependencies

Install the Twilio helper library (v5.x), configuration management, validation pipes, and optionally TypeORM v0.3.27 for database interaction.

bash
# Core dependencies
npm install twilio@^5.0.0 dotenv @nestjs/config class-validator class-transformer

# Optional database dependencies (using PostgreSQL with TypeORM 0.3.27)
npm install @nestjs/typeorm typeorm@^0.3.27 pg

Step 3: Configure Environment Variables

Create a .env file in the project root. Never commit this file to version control. Add a .gitignore entry for .env.

plaintext
#.env

# Twilio Credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
TWILIO_PHONE_NUMBER=+15551234567 # Your Twilio number

# Application Settings
APP_PORT=3000
# Base URL for callbacks (use ngrok URL during development)
APP_BASE_URL=https://your-ngrok-subdomain.ngrok-free.app

# Optional Database Credentials (if using TypeORM)
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=your_db_user
DB_PASSWORD=your_db_password
DB_DATABASE=twilio_callbacks

Create a .env.example file to track necessary variables:

plaintext
#.env.example

# Twilio Credentials
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=

# Application Settings
APP_PORT=3000
APP_BASE_URL=

# Optional Database Credentials
DB_HOST=
DB_PORT=
DB_USERNAME=
DB_PASSWORD=
DB_DATABASE=

Step 4: Load Environment Variables using ConfigModule

Modify src/app.module.ts to load and validate environment variables globally.

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 other modules (like Twilio, SMS, TypeOrm) here later

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // Makes ConfigModule available globally
      envFilePath: '.env', // Specify the env file path
    }),
    // Add other modules here
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Step 5: Enable Validation Pipe Globally

Modify src/main.ts to automatically validate incoming request payloads using class-validator.

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);
  const port = configService.get<number>('APP_PORT', 3000); // Default to 3000

  // Enable global validation pipe
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true, // Strip properties not defined in DTO
    transform: true, // Automatically transform payloads to DTO instances
    forbidNonWhitelisted: true, // Throw error if extra properties are present
  }));

  await app.listen(port);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

Project Structure Explanation:

  • src/: Contains your application's source code.
  • src/main.ts: The application entry point, bootstrapping the NestJS app.
  • src/app.module.ts: The root module, organizing the application structure.
  • src/app.controller.ts / src/app.service.ts: Default controller and service (can be removed or repurposed).
  • .env: Stores sensitive configuration and credentials (ignored by Git).
  • nest-cli.json: NestJS CLI configuration.
  • tsconfig.json: TypeScript compiler options.

At this point, you have a basic NestJS project configured to load environment variables and validate incoming requests.

How to Implement SMS Sending with Twilio Service

Build the services and controllers for sending SMS messages and handling delivery status callbacks.

Step 1: Create a Twilio Module and Service

This encapsulates Twilio client initialization and interaction logic.

bash
nest g module twilio
nest g service twilio/twilio --no-spec # No spec file for simplicity here

Step 2: Configure the Twilio Service

Inject ConfigService to access credentials and initialize the Twilio client.

typescript
// src/twilio/twilio.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import twilio, { Twilio } from 'twilio';

@Injectable()
export class TwilioService implements OnModuleInit {
  private readonly logger = new Logger(TwilioService.name);
  private client: Twilio;
  private twilioPhoneNumber: string;
  private appBaseUrl: string;

  constructor(private readonly configService: ConfigService) {}

  onModuleInit() {
    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',
    );
    this.appBaseUrl = this.configService.get<string>('APP_BASE_URL');

    if (!accountSid || !authToken || !this.twilioPhoneNumber || !this.appBaseUrl) {
      this.logger.error('Twilio credentials or Base URL not configured in .env');
      throw new Error('Twilio credentials or Base URL are missing.');
    }

    this.client = twilio(accountSid, authToken);
    this.logger.log('Twilio client initialized');
  }

  getClient(): Twilio {
    return this.client;
  }

  getTwilioPhoneNumber(): string {
    return this.twilioPhoneNumber;
  }

  // Method to construct the callback URL
  getStatusCallbackUrl(): string {
    // Ensure the base URL doesn't end with a slash and the path starts with one
    const baseUrl = this.appBaseUrl.endsWith('/') ? this.appBaseUrl.slice(0, -1) : this.appBaseUrl;
    const path = '/twilio/status'; // Our designated callback endpoint path
    return `${baseUrl}${path}`;
  }

  // Method to send SMS
  async sendSms(to: string, body: string): Promise<string> {
    try {
      const message = await this.client.messages.create({
        body: body,
        from: this.twilioPhoneNumber,
        to: to,
        // Provide the URL for status updates
        statusCallback: this.getStatusCallbackUrl(),
        // Optionally specify which events trigger the callback
        // statusCallbackEvent: ['queued', 'sent', 'failed', 'delivered', 'undelivered'],
        // Defaults to 'completed' status (delivered/undelivered/failed)
      });

      this.logger.log(`SMS sent to ${to}. SID: ${message.sid}`);
      return message.sid; // Return the Message SID
    } catch (error) {
      this.logger.error(`Failed to send SMS to ${to}: ${error.message}`, error.stack);
      // Re-throw or handle specific Twilio errors
      throw new Error(`Twilio API Error: ${error.message}`);
    }
  }

  // Note: Request validation is handled by TwilioRequestValidatorMiddleware (Section 7)
}

Step 3: Register the Twilio Module

Make the TwilioService available for injection. Add TwilioModule to the imports array in src/app.module.ts.

typescript
// src/twilio/twilio.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TwilioService } from './twilio.service';

@Module({
  imports: [ConfigModule], // Import ConfigModule if not global or needed here
  providers: [TwilioService],
  exports: [TwilioService], // Export service for other modules to use
})
export class TwilioModule {}
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 { TwilioModule } from './twilio/twilio.module'; // Import TwilioModule
// Import other modules (SMS, TypeOrm) here later

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TwilioModule, // Add TwilioModule here
    // Add other modules (SMS, TypeOrm) later
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

You now have a dedicated service to handle Twilio interactions.

How to Create API Endpoints for SMS and Webhook Callbacks

Create endpoints to trigger sending SMS and to receive the status callbacks from Twilio.

Step 1: Create an SMS Module and Controller

This module handles the API endpoint for sending messages.

bash
nest g module sms
nest g controller sms --no-spec

Step 2: Define Data Transfer Object (DTO) for Sending SMS

Create a DTO to define the expected request body structure and apply validation rules.

typescript
// src/sms/dto/send-sms.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator';

export class SendSmsDto {
  @IsNotEmpty()
  @IsPhoneNumber() // Validates E.164 format (e.g., +15551234567)
  readonly to: string;

  @IsNotEmpty()
  @IsString()
  readonly body: string;
}

Step 3: Implement the SMS Sending Endpoint

Inject TwilioService into SmsController and create a POST endpoint.

typescript
// src/sms/sms.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common';
import { TwilioService } from '../twilio/twilio.service';
import { SendSmsDto } from './dto/send-sms.dto';

@Controller('sms')
export class SmsController {
  private readonly logger = new Logger(SmsController.name);

  constructor(private readonly twilioService: TwilioService) {}

  @Post('send')
  @HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is asynchronous
  async sendSms(@Body() sendSmsDto: SendSmsDto): Promise<{ message: string; sid: string }> {
    this.logger.log(`Received request to send SMS to ${sendSmsDto.to}`);
    try {
      const messageSid = await this.twilioService.sendSms(sendSmsDto.to, sendSmsDto.body);
      return {
        message: 'SMS send request accepted.',
        sid: messageSid,
      };
    } catch (error) {
      this.logger.error(`Error in sendSms endpoint: ${error.message}`, error.stack);
      // Consider throwing specific HTTP exceptions based on error type
      throw error; // Re-throw for NestJS default exception handling
    }
  }
}

Step 4: Register the SMS Module

Add SmsModule to src/app.module.ts.

typescript
// src/sms/sms.module.ts
import { Module } from '@nestjs/common';
import { TwilioModule } from '../twilio/twilio.module'; // Import TwilioModule
import { SmsController } from './sms.controller';

@Module({
  imports: [TwilioModule], // Make TwilioService available
  controllers: [SmsController],
})
export class SmsModule {}
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 { TwilioModule } from './twilio/twilio.module';
import { SmsModule } from './sms/sms.module'; // Import SmsModule
// Import other modules (TypeOrm) here later

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TwilioModule,
    SmsModule, // Add SmsModule here
    // Add other modules (TypeOrm) later
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Step 5: Create the Twilio Callback Controller

This controller houses the endpoint that Twilio calls with status updates.

bash
nest g controller twilio/twilio --flat --no-spec # Add controller to existing twilio module

Modify the generated src/twilio/twilio.controller.ts:

typescript
// src/twilio/twilio.controller.ts
import { Controller, Post, Req, Res, Body, Headers, HttpCode, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express'; // Import express types
// Import DB service later if needed

@Controller('twilio') // Base path for Twilio related endpoints
export class TwilioController {
  private readonly logger = new Logger(TwilioController.name);

  // Constructor for injecting services (like DB service later)
  constructor(
    // Inject services here, e.g., private readonly messageLogService: MessageLogService
  ) {}

  @Post('status') // Matches the statusCallback URL path
  @HttpCode(HttpStatus.NO_CONTENT) // Twilio expects 200 OK or 204 No Content on success
  handleStatusCallback(
    @Body() body: any, // Body will contain status info (MessageSid, MessageStatus, etc.)
    @Headers('X-Twilio-Signature') twilioSignature: string, // Signature for validation (uses HMAC-SHA1 with AuthToken)
    @Req() request: Request, // Access underlying express request (needed for middleware/raw body later)
    @Res() response: Response // Access underlying express response
  ) {
    this.logger.log(`Received Twilio Status Callback for SID: ${body.MessageSid}, Status: ${body.MessageStatus}`);
    this.logger.debug(`Callback Body: ${JSON.stringify(body)}`);
    this.logger.debug(`Twilio Signature: ${twilioSignature}`);

    // --- CRITICAL SECURITY WARNING ---
    // Validate the incoming request using the Twilio signature header (X-Twilio-Signature).
    // This signature is generated using HMAC-SHA1 with your AuthToken as the key.
    // Twilio strongly recommends using the official SDK's validateRequest method.
    // This ensures the request genuinely comes from Twilio and prevents unauthorized webhook calls.
    // Implement this using middleware in Section 7.
    // Until then, this endpoint is technically insecure if exposed publicly.
    // --- END WARNING ---

    // --- IMPORTANT: CALLBACK ORDERING ---
    // Twilio does NOT guarantee status callbacks arrive in the order they were sent.
    // Your callback handler must handle out-of-order events (e.g., receiving "delivered" before "sent").
    // Use timestamps and proper state management to handle this correctly.
    // --- END ORDERING NOTE ---

    const messageSid = body.MessageSid;
    const messageStatus = body.MessageStatus;
    const errorCode = body.ErrorCode; // Present ONLY if status is 'failed' or 'undelivered'
    const errorMessage = body.ErrorMessage; // Present ONLY if status is 'failed' or 'undelivered'

    // Process the status update (e.g., log, update database)
    // Add database interaction in Section 6.
    this.logger.log(`Processing status '${messageStatus}' for message ${messageSid}. ErrorCode: ${errorCode || 'N/A'}`);

    // Example: Update database (implement MessageLogService later)
    // try {
    //   await this.messageLogService.updateStatus(messageSid, messageStatus, errorCode, errorMessage);
    //   this.logger.log(`Successfully updated status for ${messageSid} in DB.`);
    // } catch (error) {
    //   this.logger.error(`Failed to update DB for ${messageSid}: ${error.message}`, error.stack);
    //   // Decide how to handle DB errors. Maybe retry? Log critical error?
    //   // For now, still respond 204 to Twilio to acknowledge receipt.
    // }

    // Respond to Twilio – IMPORTANT: Respond quickly (within 15 seconds)
    // NestJS handles sending the response automatically with @HttpCode(204)
    // If you need more control, use the @Res() decorator and response.status(204).send();
  }
}

Update src/twilio/twilio.module.ts to include the controller:

typescript
// src/twilio/twilio.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TwilioService } from './twilio.service';
import { TwilioController } from './twilio.controller'; // Import the controller

@Module({
  imports: [ConfigModule],
  providers: [TwilioService],
  exports: [TwilioService],
  controllers: [TwilioController], // Add the controller here
})
export class TwilioModule {}

Testing API Endpoints:

  • Sending SMS (POST /sms/send):

    Use curl or Postman:

    bash
    curl -X POST http://localhost:3000/sms/send \
    -H 'Content-Type: application/json' \
    -d '{
      "to": "+15559876543",
      "body": "Hello from NestJS Twilio Guide!"
    }'

    (Replace +15559876543 with a real number verified on your Twilio trial account)

    Expected Response (Status: 202 Accepted):

    json
    {
      "message": "SMS send request accepted.",
      "sid": "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  • Receiving Callbacks (POST /twilio/status): This endpoint is called by Twilio. To test locally:

    1. Start ngrok: ngrok http 3000 (replace 3000 if your app uses a different port).
    2. Copy the ngrok URL: Note the https:// URL provided (e.g., https://abcd-1234.ngrok-free.app).
    3. Update .env: Set APP_BASE_URL to your ngrok URL.
    4. Restart your NestJS app: To pick up the new APP_BASE_URL.
    5. Send an SMS: Use the /sms/send endpoint again.
    6. Monitor Logs: Check your NestJS application logs. You should see entries from TwilioController logging the incoming callback data (MessageSid, MessageStatus, etc.) as Twilio updates the status.
    7. Monitor ngrok: The ngrok web interface (http://localhost:4040) shows incoming requests to your /twilio/status endpoint.

    Expected Callback Body (Example for delivered status):

    json
    {
      "SmsSid": "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "SmsStatus": "delivered",
      "MessageStatus": "delivered",
      "To": "+15559876543",
      "MessageSid": "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "From": "+15551234567",
      "ApiVersion": "2010-04-01"
      // … other fields possible
    }

    Expected Callback Body (Example for failed status):

    json
    {
      "SmsSid": "SMzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
      "SmsStatus": "failed",
      "MessageStatus": "failed",
      "To": "+15551112222",
      "MessageSid": "SMzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
      "AccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "ErrorCode": "30006", // Example: Landline or unreachable carrier
      "ErrorMessage": "The destination number is unable to receive this message. Potential reasons could include trying to reach a landline telephone or the destination carrier blocking the message.",
      "From": "+15551234567",
      "ApiVersion": "2010-04-01"
      // … other fields possible
    }

How to Configure Twilio Webhooks for Status Updates

You've already integrated the twilio library. This section summarizes the key configuration points for SMS delivery tracking.

  • Credentials: TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are sourced from .env via ConfigService and used to initialize the Twilio client in TwilioService. Obtain these from your Twilio Console Dashboard.
  • Phone Number: TWILIO_PHONE_NUMBER is sourced from .env and used as the from number when sending SMS. Ensure this number is SMS-capable and matches the one in your Twilio account.
  • Status Callback URL: The statusCallback URL is dynamically constructed in TwilioService using APP_BASE_URL from .env and the fixed path /twilio/status. This URL is passed in the client.messages.create call.
    • Why per-message? Setting statusCallback during the API call provides flexibility, allowing different message types or workflows to potentially use different callback handlers if needed, though you use a single one here.
    • Dashboard Configuration: While Twilio allows setting a general messaging webhook URL on a phone number or Messaging Service in the console (for incoming messages), the statusCallback parameter in the API call overrides any console settings for outgoing message status updates. Rely solely on the API parameter here.
  • Status Callback Events: By default, Twilio sends callbacks for "completed" status (delivered, undelivered, or failed). You can specify statusCallbackEvent parameter with an array like ['queued', 'sent', 'failed', 'delivered', 'undelivered'] to receive callbacks for intermediate states. Note: Status callbacks may arrive out of order due to network conditions and processing delays.
  • Polling Requirement: If a message status hasn't been updated to delivered or undelivered within 12 hours, make a polling request to the Programmable Messaging API to retrieve the status based on the Message SID. It's possible the status callback wasn't received due to network issues.
  • Environment Variables:
    • TWILIO_ACCOUNT_SID: Your main account identifier. Found on the Twilio Console dashboard. Format: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
    • TWILIO_AUTH_TOKEN: Your secret API key used for both authentication and webhook signature validation (HMAC-SHA1). Found on the Twilio Console dashboard. Keep this secret. Format: Alphanumeric string.
    • TWILIO_PHONE_NUMBER: The E.164 formatted Twilio number you're sending from. Format: +15551234567.
    • APP_BASE_URL: The public base URL where your application is accessible by Twilio. During development, this is your ngrok HTTPS URL. In production, it's your deployed application's domain. Format: https://your-domain.com or https://sub.ngrok-free.app.

How to Handle Errors and Implement Retry Logic

Robust applications need proper error handling and logging for SMS delivery monitoring.

Error Handling Strategy:

  1. Validation Errors: Handled globally by ValidationPipe in main.ts. Invalid requests return 400 Bad Request automatically.
  2. Twilio API Errors: Caught within TwilioService (sendSms method). Logged with details. Currently re-thrown as generic Error, which NestJS maps to 500 Internal Server Error. Consider mapping specific Twilio error codes (e.g., authentication failure → 401/403, invalid number → 400) using custom exception filters for more precise client feedback.
  3. Callback Processing Errors: Errors during database updates or other logic in handleStatusCallback should be logged. Crucially, still respond 2xx to Twilio to acknowledge receipt, preventing Twilio from retrying the callback unnecessarily for your internal processing errors. Handle internal failures separately (e.g., dead-letter queue, alerts).
  4. Security Errors: Signature validation failures (implemented in Section 7) should return 403 Forbidden.
  5. ErrorCode Field: The ErrorCode field only appears in callback events for failed or undelivered messages. Ensure your callback handler checks for this field but doesn't require it, as it will be absent for successful deliveries.

Logging:

  • Use NestJS's built-in Logger.
  • Log key events: Application start, Twilio client init, incoming requests, SMS sending attempts (success/failure), received callbacks, database operations (success/failure), and any caught errors.
  • Include contextual information like MessageSid in logs related to callbacks.
  • Levels: Use log for general info, warn for potential issues, error for failures, debug for verbose development info. Control log levels based on environment (e.g., debug in dev, log or info in prod). Configure NestJS logger levels during app bootstrap.

Retry Mechanisms:

  • Twilio Callbacks: Twilio automatically retries sending status callbacks if your endpoint doesn't respond with 2xx within 15 seconds or returns a 5xx error. Retries happen with exponential backoff. This is why validating quickly and responding 2xx (even if internal processing fails later) is important.
  • Sending SMS: If the initial client.messages.create call fails due to network issues or temporary Twilio problems, you might implement retries within TwilioService. Libraries like nestjs-retry or simple loop/delay logic can be used. Implement with caution (e.g., limit retries, use exponential backoff) to avoid excessive calls or costs. For this guide, keep it simple and re-throw the error.
  • Callback Ordering: Because Twilio doesn't guarantee callback ordering, implement your status update logic to handle receiving callbacks out of sequence. Use timestamp comparison and idempotent database updates to ensure data consistency.

Testing Error Scenarios:

  • Invalid Input: Send requests to /sms/send with missing/invalid to or body fields to test ValidationPipe.
  • Twilio Auth Error: Temporarily use incorrect TWILIO_AUTH_TOKEN in .env and try sending SMS. Expect a 500 error from /sms/send and corresponding logs in TwilioService.
  • Invalid Recipient: Send SMS to a known invalid number (like +15005550001 for invalid number error, or +15005550004 for SMS queue full, see Twilio Test Credentials) to trigger failed status callbacks.
  • Callback Handler Error: Introduce a deliberate error (e.g., throw new Error('Test DB Error');) inside handleStatusCallback before the response is sent (once DB logic is added). Observe the logs. Twilio should retry the callback (visible in ngrok/server logs). Ensure you still log the incoming callback data.
  • Signature Validation Error: (Once implemented in Section 7) Send a POST request to /twilio/status without a valid X-Twilio-Signature header. Expect a 403 Forbidden response.

How to Store SMS Status in PostgreSQL Database

Storing message status provides persistence and enables analysis or UI updates. Use TypeORM v0.3.27 with PostgreSQL.

Step 1: Install DB Dependencies (if not done)

bash
npm install @nestjs/typeorm typeorm@^0.3.27 pg

Important Note: TypeORM doesn't follow semantic versioning. Upgrading from 0.3.26 to 0.3.27 may include breaking changes. Always test upgrades in a non-production environment first.

Step 2: Configure TypeORM (Initial Setup)

Update src/app.module.ts to configure the database connection using ConfigService. At this stage, don't add the specific entity yet.

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TwilioModule } from './twilio/twilio.module';
import { SmsModule } from './sms/sms.module';
// Do NOT import MessageLog or MessageLogModule yet

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get<string>('DB_HOST'),
        port: configService.get<number>('DB_PORT'),
        username: configService.get<string>('DB_USERNAME'),
        password: configService.get<string>('DB_PASSWORD'),
        database: configService.get<string>('DB_DATABASE'),
        // entities: [__dirname + '/../**/*.entity{.ts,.js}'], // Alternative: Use path matching for auto-discovery
        entities: [], // Add MessageLog entity explicitly later
        synchronize: true, // DEV ONLY: Automatically creates schema. Disable in prod and use migrations.
        logging: configService.get<string>('NODE_ENV') === 'development', // Log SQL in dev
      }),
      inject: [ConfigService],
    }),
    TwilioModule,
    SmsModule,
    // MessageLogModule will be added later
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Step 3: Create MessageLog Entity and Module

Define the structure of your database table.

bash
nest g module message-log
nest g service message-log/message-log --no-spec

Create the entity file:

typescript
// src/message-log/message-log.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';

export enum MessageTransportStatus {
  ACCEPTED = 'accepted', // Initial status upon API acceptance
  QUEUED = 'queued',
  SENDING = 'sending',
  SENT = 'sent',
  FAILED = 'failed',
  DELIVERED = 'delivered',
  UNDELIVERED = 'undelivered',
  RECEIVING = 'receiving', // For incoming messages, if tracked
  RECEIVED = 'received',   // For incoming messages, if tracked
  READ = 'read',         // If read receipts are implemented (e.g., WhatsApp)
  UNKNOWN = 'unknown',
}

@Entity('message_logs') // Table name
export class MessageLog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Index() // Index for faster lookups
  @Column({ type: 'varchar', length: 34, unique: true }) // Twilio SIDs are typically 34 chars (SM…)
  messageSid: string;

  @Column({ type: 'varchar', length: 20 })
  to: string;

  @Column({ type: 'varchar', length: 20 })
  from: string;

  @Column({ type: 'text', nullable: true })
  body?: string; // Body might not be relevant for all logs, or could be large

  @Column({
    type: 'enum',
    enum: MessageTransportStatus,
    default: MessageTransportStatus.ACCEPTED,
  })
  status: MessageTransportStatus;

  @Column({ type: 'varchar', length: 5, nullable: true }) // E.g., 30006
  errorCode?: string;

  @Column({ type: 'text', nullable: true })
  errorMessage?: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}