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. We'll cover everything from initial project setup to deployment and verification, ensuring you have a robust solution.
We'll focus on creating a system that not only sends messages but also listens for status updates (like sent
, delivered
, failed
, undelivered
) from Twilio, enabling features like real-time delivery confirmation, logging for auditing, and triggering subsequent actions based on message status.
Project Overview and Goals
What We'll Build:
A NestJS application with the following capabilities:
- An API endpoint to trigger sending SMS messages via Twilio.
- Integration with the Twilio Node.js helper library.
- A dedicated webhook endpoint to receive message status updates from Twilio.
- Secure handling of Twilio credentials and webhook requests.
- (Optional but recommended) Storing message details and status updates in a database (using TypeORM and PostgreSQL).
- 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.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. 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. We'll use their Programmable Messaging API and Node.js helper library.
- 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 & PostgreSQL: For database persistence.
- (Development) ngrok: To expose the local development server to the internet for Twilio webhooks.
System Architecture:
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 (v16 or later recommended) and npm/yarn installed.
- 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 will 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.
1. Setting up the Project
Let's initialize our NestJS project and set up the basic structure and dependencies.
Step 1: Create a new NestJS project
Open your terminal and run the NestJS CLI command:
# Install NestJS CLI if you haven't already
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
We need the Twilio helper library, configuration management, validation pipes, and optionally TypeORM for database interaction.
# Core dependencies
npm install twilio dotenv @nestjs/config class-validator class-transformer
# Optional database dependencies (using PostgreSQL)
npm install @nestjs/typeorm typeorm 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
.
#.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:
#.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.
// 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
.
// 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.
2. Implementing Core Functionality
Now, let's build the services and controllers for sending SMS and handling callbacks.
Step 1: Create a Twilio Module and Service
This encapsulates Twilio client initialization and interaction logic.
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.
// 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: ['sent', 'failed', 'delivered', 'undelivered'], // Defaults to 'completed' (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
.
// 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 {}
// 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 {}
Now we have a dedicated service to handle Twilio interactions.
3. Building the API Layer
We need endpoints to trigger sending SMS and to receive the status callbacks from Twilio.
Step 1: Create an SMS Module and Controller
This module will handle the API endpoint for sending messages.
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.
// 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.
// 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
.
// 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 {}
// 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 will house the endpoint that Twilio calls with status updates.
nest g controller twilio/twilio --flat --no-spec # Add controller to existing twilio module
Modify the generated src/twilio/twilio.controller.ts
:
// 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
@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 ---
// The incoming request MUST be validated using the Twilio signature header.
// This ensures the request genuinely comes from Twilio.
// We will implement this using middleware in Section 7.
// Until then, this endpoint is technically insecure if exposed publicly.
// --- END WARNING ---
const messageSid = body.MessageSid;
const messageStatus = body.MessageStatus;
const errorCode = body.ErrorCode; // Present if status is 'failed' or 'undelivered'
const errorMessage = body.ErrorMessage; // Present if status is 'failed' or 'undelivered'
// Process the status update (e.g., log, update database)
// We will 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, we still respond 204 to Twilio to acknowledge receipt.
// }
// Respond to Twilio - IMPORTANT: Respond quickly (within 15s)
// 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:
// 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: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):
{ ""message"": ""SMS send request accepted."", ""sid"": ""SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"" }
-
Receiving Callbacks (
POST /twilio/status
): This endpoint is called by Twilio. To test locally:- Start ngrok:
ngrok http 3000
(replace 3000 if your app uses a different port). - Copy the ngrok URL: Note the
https://
URL provided (e.g.,https://abcd-1234.ngrok-free.app
). - Update
.env
: SetAPP_BASE_URL
to your ngrok URL. - Restart your NestJS app: To pick up the new
APP_BASE_URL
. - Send an SMS: Use the
/sms/send
endpoint again. - 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. - Monitor ngrok: The ngrok web interface (
http://localhost:4040
) will show incoming requests to your/twilio/status
endpoint.
Expected Callback Body (Example for
delivered
status):{ ""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):{ ""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 }
- Start ngrok:
4. Integrating with Twilio (Configuration Recap)
We've already integrated the twilio
library. This section summarizes the key configuration points.
- Credentials:
TWILIO_ACCOUNT_SID
andTWILIO_AUTH_TOKEN
are sourced from.env
viaConfigService
and used to initialize the Twilio client inTwilioService
. Obtain these from your Twilio Console Dashboard. - Phone Number:
TWILIO_PHONE_NUMBER
is sourced from.env
and used as thefrom
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 inTwilioService
usingAPP_BASE_URL
from.env
and the fixed path/twilio/status
. This URL is passed in theclient.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 we 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. We rely solely on the API parameter here.
- Why per-message? Setting
- Environment Variables:
TWILIO_ACCOUNT_SID
: Your main account identifier. Found on the Twilio Console dashboard. Format:ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
.TWILIO_AUTH_TOKEN
: Your secret API key. 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
orhttps://sub.ngrok-free.app
.
5. Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
Error Handling Strategy:
- Validation Errors: Handled globally by
ValidationPipe
inmain.ts
. Invalid requests return 400 Bad Request automatically. - Twilio API Errors: Caught within
TwilioService
(sendSms
method). Logged with details. Currently re-thrown as genericError
, 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. - 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). - Security Errors: Signature validation failures (implemented in Section 7) should return 403 Forbidden.
Logging:
- We 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
orinfo
in prod). NestJS logger levels can be configured 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 withinTwilioService
. Libraries likenestjs-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, we keep it simple and re-throw the error.
Testing Error Scenarios:
- Invalid Input: Send requests to
/sms/send
with missing/invalidto
orbody
fields to testValidationPipe
. - 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 inTwilioService
. - 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 triggerfailed
status callbacks. - Callback Handler Error: Introduce a deliberate error (e.g.,
throw new Error('Test DB Error');
) insidehandleStatusCallback
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 validX-Twilio-Signature
header. Expect a 403 Forbidden response.
6. Database Schema and Data Layer (Optional)
Storing message status provides persistence and enables analysis or UI updates. We'll use TypeORM with PostgreSQL.
Step 1: Install DB Dependencies (if not done)
npm install @nestjs/typeorm typeorm pg
Step 2: Configure TypeORM (Initial Setup)
Update src/app.module.ts
to configure the database connection using ConfigService
. At this stage, we won't add the specific entity yet.
// 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: [], // We will 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 our database table.
nest g module message-log
nest g service message-log/message-log --no-spec
Create the entity file:
// 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;
}