This guide provides a comprehensive, step-by-step walkthrough for building a robust SMS marketing campaign system using Node.js, the NestJS framework, and Twilio's messaging APIs. We will cover everything from initial project setup to deployment and monitoring, enabling you to send targeted SMS campaigns reliably and efficiently.
We aim to create a backend system capable of managing subscribers, defining marketing campaigns, and sending SMS messages in bulk via Twilio, incorporating best practices for scalability, error handling, and security. By the end, you'll have a functional application ready for production use, complete with logging, monitoring, and deployment considerations.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications.
- Twilio: A cloud communications platform providing APIs for SMS, voice, and more.
- PostgreSQL: A powerful, open-source object-relational database system.
- TypeORM: An ORM (Object-Relational Mapper) for TypeScript and JavaScript.
- Redis: An in-memory data structure store, used here for message queuing.
- Bull: A robust Redis-based queue system for Node.js.
- Docker: For containerizing the application for consistent deployment.
System Architecture:
+-------------------+ +---------------------+ +-----------------+ +-----------------+
| Client / Admin |----->| NestJS API Layer |----->| Redis (Bull) |----->| NestJS Worker |
| (UI or API Call) | | (Controllers, DTOs) | | (Message Queue) | | (Job Processor) |
+-------------------+ +----------+----------+ +--------+--------+ +--------+--------+
| ^ | |
| | | |
v | v v
+----------+----------+ +-----------------+ +-----------------+
| NestJS Services |----->| PostgreSQL (DB) | | Twilio API |
| (Business Logic) | | (Subscribers, | | (Send SMS) |
+---------------------+ | Campaigns) | +-----------------+
+-----------------+
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- Access to a PostgreSQL database.
- A Redis instance (local or cloud-based).
- A Twilio account with Account SID, Auth Token, and a Twilio phone number.
- Basic understanding of TypeScript, NestJS, and REST APIs.
- Docker installed (for deployment section).
curl
or a tool like Postman for API testing.
1. Setting up the Project
Let's initialize our NestJS project and install the necessary dependencies.
-
Create NestJS Project: Use the NestJS CLI to create a new project.
npm i -g @nestjs/cli nest new nestjs-twilio-marketing cd nestjs-twilio-marketing
-
Install Dependencies: We need packages for configuration, Twilio, database interaction (TypeORM, PostgreSQL driver), queuing (Bull), validation, queuing utilities (
p-queue
,p-retry
), and optionally Swagger for API documentation. TypeORM migrations also requirets-node
andtsconfig-paths
.# Core Dependencies npm install @nestjs/config twilio @nestjs/typeorm typeorm pg @nestjs/bull bull class-validator class-transformer p-queue p-retry # Dev Dependencies (Swagger, Migration tools) npm install --save-dev @nestjs/swagger swagger-ui-express ts-node tsconfig-paths
@nestjs/config
: Handles environment variables.twilio
: Official Twilio Node.js helper library.@nestjs/typeorm
,typeorm
,pg
: For PostgreSQL database interaction using TypeORM.@nestjs/bull
,bull
: For background job processing using Redis.class-validator
,class-transformer
: For request payload validation.p-queue
,p-retry
: Utilities for managing concurrency and retries when calling external APIs like Twilio.@nestjs/swagger
,swagger-ui-express
: For generating API documentation (optional but recommended).ts-node
,tsconfig-paths
: Required for running TypeORM CLI migrations with TypeScript paths.
-
Environment Configuration (
.env
): Create a.env
file in the project root. Never commit this file to version control.# .env # Application PORT=3000 # Database (PostgreSQL) DB_HOST=localhost DB_PORT=5432 DB_USERNAME=your_db_user DB_PASSWORD=your_db_password DB_DATABASE=marketing_db # Redis (for Bull Queue) REDIS_HOST=localhost REDIS_PORT=6379 # REDIS_PASSWORD=your_redis_password # Uncomment and set if your Redis instance requires a password # Twilio Credentials TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Get from Twilio Console TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxxx # Get from Twilio Console TWILIO_PHONE_NUMBER=+15005550006 # Your Twilio phone number (Test number shown; replace with your actual number for sending) TWILIO_STATUS_CALLBACK_URL=http://your_public_domain.com/twilio/status # Needs to be publicly accessible # Messaging Service Config TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Optional, but recommended for features like opt-out handling # Retry/Queue Config TWILIO_SEND_RETRIES=3 TWILIO_QUEUE_CONCURRENCY=5 # Adjust based on Twilio rate limits and account type
- Purpose: This file centralizes configuration, keeping secrets out of the codebase.
- Obtaining Twilio Credentials:
- Log in to your Twilio Console.
- Your
ACCOUNT_SID
andAUTH_TOKEN
are on the main dashboard. - Navigate to
Phone Numbers
>Manage
>Active numbers
to find or buy aTWILIO_PHONE_NUMBER
. Ensure it's SMS-capable. Use the test number+15005550006
for development if needed, but replace it with your actual number for real sending. - (Recommended) Navigate to
Messaging
>Services
>Create Messaging Service
. Note theMESSAGING_SERVICE_SID
. Using a Messaging Service provides advanced features like sender ID pools, geo-matching, and integrated opt-out handling. - The
TWILIO_STATUS_CALLBACK_URL
is a webhook URL you will create in your NestJS app (Section 4) that Twilio will call to report message status updates (sent, delivered, failed, etc.). It must be publicly accessible. Use tools likengrok
during development.
- Redis Password: If your Redis instance requires authentication, uncomment the
REDIS_PASSWORD
line and provide the correct password.
-
Load Environment Variables (
app.module.ts
): Configure theConfigModule
to load the.env
file 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 (Database, Queue, Feature Modules) here later @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigService available globally envFilePath: '.env', }), // Add TypeOrmModule, BullModule, etc. here later ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
-
Enable Validation Pipe (
main.ts
): Automatically validate incoming request bodies based on DTOs.// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; // Import Swagger setup later if using async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); // Get ConfigService instance app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if extra properties are present })); // Add Swagger setup here later if needed const port = configService.get<number>('PORT', 3000); // Get port from env await app.listen(port); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();
-
Project Structure (Example): Organize your code into modules for better maintainability.
src/ ├── app.module.ts ├── main.ts ├── config/ # Configuration related files (e.g., TypeORM config) ├── core/ # Core modules (Auth, Logging, Filters etc. - if needed) │ └── filters/ │ └── http-exception.filter.ts ├── database/ # Database entities and migrations │ ├── entities/ │ │ ├── campaign.entity.ts │ │ └── subscriber.entity.ts │ ├── enums/ │ │ └── campaign-status.enum.ts │ └── migrations/ ├── shared/ # Shared modules, services, utilities │ └── twilio/ # Twilio integration module │ ├── twilio.module.ts │ └── twilio.service.ts ├── features/ # Business-logic features │ ├── campaigns/ │ │ ├── campaigns.module.ts │ │ ├── campaigns.controller.ts │ │ ├── campaigns.service.ts │ │ └── dtos/ │ │ └── create-campaign.dto.ts │ ├── subscribers/ │ │ ├── subscribers.module.ts │ │ ├── subscribers.controller.ts │ │ ├── subscribers.service.ts │ │ └── dtos/ │ │ └── add-subscriber.dto.ts │ └── messaging/ # Handles queuing and sending logic │ ├── messaging.module.ts │ ├── messaging.processor.ts # Bull queue processor │ └── messaging.service.ts # Service to add jobs to the queue └── webhooks/ # Controllers handling external webhooks └── twilio-status/ ├── twilio-status.controller.ts └── twilio-status.module.ts
- Why this structure? It separates concerns (database, external services, features), making the application easier to understand, test, and scale.
2. Implementing Core Functionality
We'll define database entities, create services for managing campaigns and subscribers, and set up the messaging service.
2.1 Database Schema and Data Layer (TypeORM)
-
Define Entities: Create TypeORM entities for
Campaign
andSubscriber
.// src/database/entities/subscriber.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; @Entity('subscribers') export class Subscriber { @PrimaryGeneratedColumn('uuid') id: string; @Index({ unique: true }) // Ensure unique phone numbers @Column({ type: 'varchar', length: 20, unique: true }) phoneNumber: string; // Store in E.164 format (e.g., +14155552671) @Column({ type: 'varchar', length: 100, nullable: true }) firstName?: string; @Column({ type: 'varchar', length: 100, nullable: true }) lastName?: string; @Column({ type: 'boolean', default: true }) isOptedIn: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }
// src/database/entities/campaign.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { CampaignStatus } from '../enums/campaign-status.enum'; @Entity('campaigns') export class Campaign { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ type: 'text' }) messageBody: string; // Consider adding targeting criteria here (e.g., tags, segments) @Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.PENDING, }) status: CampaignStatus; @Column({ type: 'timestamp', nullable: true }) scheduledAt?: Date; // For scheduled campaigns @Column({ type: 'timestamp', nullable: true }) sentAt?: Date; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; // Add relations if needed, e.g., tracking which messages belong to this campaign } // Create an enum for status: // src/database/enums/campaign-status.enum.ts export enum CampaignStatus { PENDING = 'PENDING', SENDING = 'SENDING', COMPLETED = 'COMPLETED', FAILED = 'FAILED', }
-
Configure TypeORM Module: Set up the database connection in
app.module.ts
usingConfigService
.// 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 { Campaign } from './database/entities/campaign.entity'; import { Subscriber } from './database/entities/subscriber.entity'; // ... other imports @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], 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: [Campaign, Subscriber], // Add all entities here synchronize: false, // IMPORTANT: Use migrations in production! Set to true for rapid development ONLY. autoLoadEntities: true, // Automatically loads entities defined via forFeature() logging: process.env.NODE_ENV === 'development', // Log SQL in dev }), }), // Add BullModule, Feature Modules etc. here later ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
-
Database Migrations: TypeORM CLI is needed for migrations. Add scripts to
package.json
. Ensurets-node
andtsconfig-paths
are installed (done in Step 1).// package.json (add/update scripts section) ""scripts"": { // ... other scripts like ""start"", ""build"", ""test"" ""typeorm"": ""ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/config/typeorm.config.ts"", ""migration:generate"": ""npm run typeorm -- migration:generate"", ""migration:run"": ""npm run typeorm -- migration:run"", ""migration:revert"": ""npm run typeorm -- migration:revert"" },
Create a TypeORM configuration file for the CLI:
// src/config/typeorm.config.ts import { DataSource, DataSourceOptions } from 'typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { Campaign } from '../database/entities/campaign.entity'; import { Subscriber } from '../database/entities/subscriber.entity'; // Load .env variables manually for CLI context import * as dotenv from 'dotenv'; dotenv.config(); // Ensure .env is loaded const configService = new ConfigService(); export const dataSourceOptions: DataSourceOptions = { 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: [Campaign, Subscriber], // Point to your entities migrations: [__dirname + '/../database/migrations/*{.ts,.js}'], // Point to migrations folder synchronize: false, // Disable synchronize for migrations logging: true, }; // Export DataSource instance for TypeORM CLI const dataSource = new DataSource(dataSourceOptions); export default dataSource;
Now you can generate and run migrations:
- Make changes to your entities.
- Generate a migration:
npm run migration:generate src/database/migrations/MyFeatureMigration
(replaceMyFeatureMigration
with a descriptive name). - Review the generated SQL in the migration file.
- Run the migration:
npm run migration:run
2.2 Feature Modules (Campaigns, Subscribers)
Create modules, controllers, services, and DTOs for managing campaigns and subscribers.
Campaigns Feature:
-
DTO (
CreateCampaignDto
):// src/features/campaigns/dtos/create-campaign.dto.ts import { IsString, IsNotEmpty, MaxLength } from 'class-validator'; export class CreateCampaignDto { @IsString() @IsNotEmpty() @MaxLength(255) name: string; @IsString() @IsNotEmpty() messageBody: string; // Add other fields like scheduledAt, targetSegment etc. if needed }
-
Service (
CampaignsService
):// src/features/campaigns/campaigns.service.ts import { Injectable, NotFoundException, Logger } from '@nestjs/common'; // Added Logger import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Campaign } from '../../database/entities/campaign.entity'; import { CreateCampaignDto } from './dtos/create-campaign.dto'; import { CampaignStatus } from '../../database/enums/campaign-status.enum'; import { MessagingService } from '../messaging/messaging.service'; // Will be imported properly later @Injectable() export class CampaignsService { private readonly logger = new Logger(CampaignsService.name); // Added logger instance constructor( @InjectRepository(Campaign) private campaignsRepository: Repository<Campaign>, // MessagingService will be injected once created and its module imported private readonly messagingService: MessagingService, ) {} async create(createCampaignDto: CreateCampaignDto): Promise<Campaign> { const campaign = this.campaignsRepository.create({ ...createCampaignDto, status: CampaignStatus.PENDING, // Default status }); return this.campaignsRepository.save(campaign); } async findAll(): Promise<Campaign[]> { return this.campaignsRepository.find(); } async findOne(id: string): Promise<Campaign> { const campaign = await this.campaignsRepository.findOneBy({ id }); if (!campaign) { throw new NotFoundException(`Campaign with ID ${id} not found`); } return campaign; } async startCampaign(id: string): Promise<{ message: string; queuedMessages: number }> { const campaign = await this.findOne(id); if (campaign.status !== CampaignStatus.PENDING) { // Allow restarting 'FAILED' campaigns? Add logic if needed. throw new Error(`Campaign ${id} is not in PENDING state (current: ${campaign.status}).`); } // Update status immediately to prevent double sending await this.campaignsRepository.update(id, { status: CampaignStatus.SENDING, sentAt: new Date() }); this.logger.log(`Campaign ${id} status updated to SENDING.`); // Trigger the messaging service to queue jobs const queuedCount = await this.messagingService.queueCampaignMessages(campaign); this.logger.log(`Queued ${queuedCount} messages for campaign ${id}.`); return { message: `Campaign ${id} sending process initiated.`, queuedMessages: queuedCount }; } // Add update, delete methods as needed }
-
Controller (
CampaignsController
):// src/features/campaigns/campaigns.controller.ts import { Controller, Get, Post, Body, Param, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; import { CampaignsService } from './campaigns.service'; import { CreateCampaignDto } from './dtos/create-campaign.dto'; import { Campaign } from '../../database/entities/campaign.entity'; // Import Swagger decorators later // import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; // @ApiTags('Campaigns') // Add Swagger tag if using @Controller('campaigns') export class CampaignsController { constructor(private readonly campaignsService: CampaignsService) {} // @ApiOperation({ summary: 'Create a new campaign' }) // @ApiResponse({ status: 201, description: 'Campaign created successfully.', type: Campaign }) @Post() @HttpCode(HttpStatus.CREATED) create(@Body() createCampaignDto: CreateCampaignDto): Promise<Campaign> { return this.campaignsService.create(createCampaignDto); } // @ApiOperation({ summary: 'Get all campaigns' }) // @ApiResponse({ status: 200, description: 'List of campaigns.', type: [Campaign] }) @Get() findAll(): Promise<Campaign[]> { return this.campaignsService.findAll(); } // @ApiOperation({ summary: 'Get a specific campaign by ID' }) // @ApiResponse({ status: 200, description: 'Campaign details.', type: Campaign }) // @ApiResponse({ status: 404, description: 'Campaign not found.' }) @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string): Promise<Campaign> { return this.campaignsService.findOne(id); } // @ApiOperation({ summary: 'Start sending a campaign' }) // @ApiResponse({ status: 202, description: 'Campaign sending process initiated.' }) // @ApiResponse({ status: 404, description: 'Campaign not found.' }) // @ApiResponse({ status: 400, description: 'Campaign cannot be started (e.g., already sent).' }) // Endpoint to trigger sending @Post(':id/send') @HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted for async operations startSending(@Param('id', ParseUUIDPipe) id: string): Promise<{ message: string; queuedMessages: number }> { // Add Auth Guard here in production: @UseGuards(JwtAuthGuard) return this.campaignsService.startCampaign(id); } }
-
Module (
CampaignsModule
):// src/features/campaigns/campaigns.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CampaignsService } from './campaigns.service'; import { CampaignsController } from './campaigns.controller'; import { Campaign } from '../../database/entities/campaign.entity'; import { MessagingModule } from '../messaging/messaging.module'; // Import MessagingModule @Module({ imports: [ TypeOrmModule.forFeature([Campaign]), MessagingModule, // Import MessagingModule here to make MessagingService available ], controllers: [CampaignsController], providers: [CampaignsService], }) export class CampaignsModule {}
Subscribers Feature: Implement similarly (DTO, Service, Controller, Module) for adding, listing, updating (opt-out status), and deleting subscribers. Ensure phone numbers are validated and stored in E.164 format.
// src/features/subscribers/dtos/add-subscriber.dto.ts
import { IsString, IsNotEmpty, IsPhoneNumber, IsOptional, IsBoolean } from 'class-validator';
export class AddSubscriberDto {
@IsPhoneNumber(null) // Basic phone number validation (expects E.164 format ideally)
@IsNotEmpty()
phoneNumber: string; // Expect E.164 format (+1xxxxxxxxxx)
@IsString()
@IsOptional()
firstName?: string;
@IsString()
@IsOptional()
lastName?: string;
@IsBoolean()
@IsOptional()
isOptedIn?: boolean = true; // Default to opted-in
}
(Note: Service, Controller, and Module for Subscribers are omitted for brevity but should follow the same pattern as Campaigns.)
Finally, import CampaignsModule
and SubscribersModule
(once created) into AppModule
.
3. Building the API Layer
We've already started building the API layer with controllers and DTOs.
-
Authentication/Authorization: For production, protect endpoints. Use NestJS Guards with strategies like JWT (
@nestjs/jwt
,@nestjs/passport
). Apply guards to controllers or specific routes (@UseGuards(AuthGuard('jwt'))
). This is beyond the scope of this basic guide but crucial for security. -
Request Validation: Handled by
ValidationPipe
configured inmain.ts
andclass-validator
decorators in DTOs. -
API Documentation (Swagger - Optional):
-
Install dev dependencies (done in Step 1).
-
Setup in
main.ts
:// src/main.ts // ... other imports import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, })); // Swagger Setup const swaggerConfig = new DocumentBuilder() .setTitle('Marketing Campaign API') .setDescription('API for managing SMS marketing campaigns and subscribers') .setVersion('1.0') .addBearerAuth() // If using JWT auth .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); SwaggerModule.setup('api-docs', app, document); // Access at /api-docs const port = configService.get<number>('PORT', 3000); await app.listen(port); console.log(`Application is running on: ${await app.getUrl()}`); console.log(`API documentation available at ${await app.getUrl()}/api-docs`); } bootstrap();
-
Decorate controllers and DTOs with
@ApiTags
,@ApiOperation
,@ApiResponse
,@ApiProperty
, etc. as needed (examples shown commented out inCampaignsController
).
-
-
Testing Endpoints (
curl
examples):-
Create Campaign:
curl -X POST http://localhost:3000/campaigns \ -H ""Content-Type: application/json"" \ -d '{ ""name"": ""Spring Sale 2025"", ""messageBody"": ""Huge Spring Sale starts now! Visit example.com/sale. Reply STOP to opt out."" }'
(Response: JSON object of the created campaign)
-
Add Subscriber:
curl -X POST http://localhost:3000/subscribers \ -H ""Content-Type: application/json"" \ -d '{ ""phoneNumber"": ""+14155551234"", ""firstName"": ""Jane"", ""lastName"": ""Doe"" }'
(Response: JSON object of the added subscriber)
-
Start Sending Campaign:
# Replace <campaign-uuid> with an actual ID from a created campaign curl -X POST http://localhost:3000/campaigns/<campaign-uuid>/send
(Response:
{ ""message"": ""Campaign <campaign-uuid> sending process initiated."", ""queuedMessages"": X }
)
-
4. Integrating with Twilio
Let's create a dedicated module for Twilio interactions and handle status callbacks.
-
Twilio Service (
TwilioService
): This service initializes the Twilio client and provides methods to send SMS. We'll incorporatep-queue
for concurrency control andp-retry
for robustness.// src/shared/twilio/twilio.service.ts import PQueue from 'p-queue'; import pRetry from 'p-retry'; import twilio, { Twilio } from 'twilio'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MessageListInstanceCreateOptions } from 'twilio/lib/rest/api/v2010/account/message'; import { Request } from 'express'; // Import Request type @Injectable() export class TwilioService implements OnModuleInit { private client: Twilio; private readonly logger = new Logger(TwilioService.name); private queue: PQueue; private messagingServiceSid: string | undefined; private twilioPhoneNumber: string | undefined; private statusCallbackUrl: string | undefined; private authToken: string | undefined; // Store auth token for validation constructor(private configService: ConfigService) {} onModuleInit() { const accountSid = this.configService.get<string>('TWILIO_ACCOUNT_SID'); this.authToken = this.configService.get<string>('TWILIO_AUTH_TOKEN'); const concurrency = this.configService.get<number>('TWILIO_QUEUE_CONCURRENCY', 5); this.messagingServiceSid = this.configService.get<string>('TWILIO_MESSAGING_SERVICE_SID'); this.twilioPhoneNumber = this.configService.get<string>('TWILIO_PHONE_NUMBER'); this.statusCallbackUrl = this.configService.get<string>('TWILIO_STATUS_CALLBACK_URL'); if (!accountSid || !this.authToken) { this.logger.error('Twilio Account SID or Auth Token missing in config.'); throw new Error('Twilio credentials not found. SMS sending disabled.'); } if (!this.messagingServiceSid && !this.twilioPhoneNumber) { this.logger.error('Either TWILIO_MESSAGING_SERVICE_SID or TWILIO_PHONE_NUMBER must be configured.'); throw new Error('Twilio sender identifier not found.'); } this.client = twilio(accountSid, this.authToken); this.queue = new PQueue({ concurrency }); // Control concurrent API calls this.logger.log('Twilio Client Initialized.'); if(this.messagingServiceSid) { this.logger.log(`Using Messaging Service SID: ${this.messagingServiceSid}`); } else { this.logger.log(`Using Twilio Phone Number: ${this.twilioPhoneNumber}`); } } private async sendSmsInternal(options: MessageListInstanceCreateOptions): Promise<any> { // Use Messaging Service SID if available, otherwise use the phone number const sender = this.messagingServiceSid ? { messagingServiceSid: this.messagingServiceSid } : { from: this.twilioPhoneNumber }; const payload: MessageListInstanceCreateOptions = { ...options, ...sender, statusCallback: this.statusCallbackUrl, // Add status callback URL }; this.logger.debug(`Sending SMS via Twilio: ${JSON.stringify({...payload, body: '[REDACTED]'})}`); return this.client.messages.create(payload); } /** * Adds an SMS message to a managed queue for sending. * Handles retries and concurrency. * @param options Message options (to, body) */ async queueSms(options: Pick<MessageListInstanceCreateOptions_ 'to' | 'body'>) { const retries = this.configService.get<number>('TWILIO_SEND_RETRIES', 3); return this.queue.add(() => pRetry(() => this.sendSmsInternal(options), { onFailedAttempt: (error) => { this.logger.warn( `SMS to ${options.to} failed. Retrying (${error.retriesLeft} attempts left). Error: ${error.message}`, error.name === 'TwilioRestException' ? error.cause : error.stack, // Log Twilio specific details if available ); }, retries: retries, }), ); } // Add method to validate Twilio webhook requests later }
-
Twilio Module (
TwilioModule
):// src/shared/twilio/twilio.module.ts import { Module, Global } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TwilioService } from './twilio.service'; @Global() // Make TwilioService available globally without importing TwilioModule everywhere @Module({ imports: [ConfigModule], // Ensure ConfigService is available providers: [TwilioService], exports: [TwilioService], // Export the service }) export class TwilioModule {}
-
Import
TwilioModule
intoAppModule
:// src/app.module.ts // ... other imports import { TwilioModule } from './shared/twilio/twilio.module'; // ... other feature modules @Module({ imports: [ // ... ConfigModule, TypeOrmModule TwilioModule, // Add TwilioModule // ... CampaignsModule, SubscribersModule, MessagingModule etc. ], // ... controllers, providers }) export class AppModule {}
-
Status Callback Controller (
TwilioStatusController
): Create a controller to handle incoming webhook requests from Twilio about message status updates. Crucially, this endpoint must be secured.// src/webhooks/twilio-status/twilio-status.controller.ts import { Controller, Post, Body, Req, Res, Logger, ForbiddenException, Headers } from '@nestjs/common'; import { Request, Response } from 'express'; import { validateRequest } from 'twilio'; // Twilio's request validation helper import { ConfigService } from '@nestjs/config'; @Controller('twilio/status') // Matches TWILIO_STATUS_CALLBACK_URL path export class TwilioStatusController { private readonly logger = new Logger(TwilioStatusController.name); private twilioAuthToken: string; constructor(private configService: ConfigService) { this.twilioAuthToken = this.configService.get<string>('TWILIO_AUTH_TOKEN'); if (!this.twilioAuthToken) { this.logger.error('TWILIO_AUTH_TOKEN not configured. Webhook validation disabled!'); // Consider throwing an error in production if the token is missing } } @Post() handleStatusUpdate( @Headers('X-Twilio-Signature') twilioSignature: string, @Body() body: any, // Twilio sends form-urlencoded data @Req() req: Request, @Res() res: Response, ) { this.logger.debug(`Received Twilio status update: ${JSON.stringify(body)}`); // **IMPORTANT: Validate the request** const url = this.configService.get<string>('TWILIO_STATUS_CALLBACK_URL'); if (!this.twilioAuthToken || !url) { this.logger.error('Cannot validate Twilio request: Auth Token or Callback URL missing.'); throw new ForbiddenException('Webhook validation configuration missing.'); } // Use raw body if available (requires body-parser middleware setup correctly) // For NestJS, you might need a custom setup or ensure the raw body is accessible. // If using standard body-parser, `body` will be parsed. Twilio's validator needs the raw POST params. // A common workaround is to re-serialize the parsed body for validation, // BUT this is less secure than using the actual raw body. // For production, ensure you have middleware that preserves the raw body for this route. // Example using parsed body (less secure, use with caution): const isValid = validateRequest(this.twilioAuthToken, twilioSignature, url, body); if (!isValid) { this.logger.warn('Invalid Twilio signature received.'); throw new ForbiddenException('Invalid Twilio signature.'); } // Process the status update (body contains MessageSid, MessageStatus, To, From, etc.) const messageSid = body.MessageSid; const messageStatus = body.MessageStatus; // e.g., 'sent', 'delivered', 'failed', 'undelivered' const errorCode = body.ErrorCode; // Present if status is 'failed' or 'undelivered' const to = body.To; this.logger.log(`Message ${messageSid} to ${to} status: ${messageStatus}${errorCode ? ` (Error: ${errorCode})` : ''}`); // TODO: Implement logic based on status: // - Update message status in your database (requires storing MessageSid when sending) // - Handle failures/undelivered messages (e.g., mark subscriber, retry logic) // - Update campaign status if all messages are finalized // Respond to Twilio to acknowledge receipt res.status(204).send(); // 204 No Content is standard practice } }
-
Status Callback Module (
TwilioStatusModule
):// src/webhooks/twilio-status/twilio-status.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TwilioStatusController } from './twilio-status.controller'; @Module({ imports: [ConfigModule], // Needs ConfigService for validation controllers: [TwilioStatusController], }) export class TwilioStatusModule {}
-
Import
TwilioStatusModule
intoAppModule
:// src/app.module.ts // ... other imports import { TwilioStatusModule } from './webhooks/twilio-status/twilio-status.module'; @Module({ imports: [ // ... ConfigModule, TypeOrmModule, TwilioModule, Feature Modules... TwilioStatusModule, // Add the webhook module ], // ... controllers, providers }) export class AppModule {}
Important Notes on Webhook Validation:
- The
validateRequest
function fromtwilio
requires the original URL Twilio called and the raw, unparsed POST body parameters. - Standard NestJS body parsing might interfere. You may need specific middleware for the
/twilio/status
route to capture the raw body before it's parsed, or carefully reconstruct the parameters as Twilio sent them (which is less secure). Research NestJS raw body handling for robust validation. - Ensure your
TWILIO_STATUS_CALLBACK_URL
in.env
exactly matches the public URL configured in Twilio and used in the validation. Usengrok
or similar tools for local development to get a public URL.