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. You'll learn everything from initial project setup to deployment and monitoring, enabling you to send targeted SMS campaigns reliably and efficiently.
You'll 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.
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
Initialize your NestJS project and install the necessary dependencies.
Create NestJS Project:
Use the NestJS CLI to create a new project.
Bash
npm i -g @nestjs/cli
nest new nestjs-twilio-marketing
cd nestjs-twilio-marketing
Install Dependencies:
You 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 require ts-node and tsconfig-paths.
@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 your project root. Never commit this file to version control.
.env
# .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 your codebase.
Your ACCOUNT_SID and AUTH_TOKEN are on the main dashboard.
Navigate to Phone Numbers > Manage > Active numbers to find or buy a TWILIO_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 the MESSAGING_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 like ngrok 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 the ConfigModule to load the .env file globally.
TypeScript
// src/app.module.tsimport { 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 globallyenvFilePath: '.env',
}),
// Add TypeOrmModule, BullModule, etc. here later
],
controllers: [AppController],
providers: [AppService],
})
exportclassAppModule {}
Enable Validation Pipe (main.ts):
Automatically validate incoming request bodies based on DTOs.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
// Import Swagger setup later if usingasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const configService = app.get(ConfigService); // Get ConfigService instance
app.useGlobalPipes(newValidationPipe({
whitelist: true, // Strip properties not in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: true, // Throw error if extra properties are present
}));
// Add Swagger setup here later if neededconst port = configService.get<number>('PORT', 3000); // Get port from envawait app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
Project Structure (Example):
Organize your code into modules for better maintainability.
Configure TypeORM Module:
Set up the database connection in app.module.ts using ConfigService.
TypeScript
// src/app.module.tsimport { 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 heresynchronize: 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],
})
exportclassAppModule {}
Database Migrations:
TypeORM CLI is needed for migrations. Add scripts to package.json. Ensure ts-node and tsconfig-paths are installed (done in Step 1).
JSON
// 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:
TypeScript
// src/config/typeorm.config.tsimport { 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 contextimport * as dotenv from'dotenv';
dotenv.config(); // Ensure .env is loadedconst configService = newConfigService();
exportconstdataSourceOptions: 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 entitiesmigrations: [__dirname + '/../database/migrations/*{.ts,.js}'], // Point to migrations foldersynchronize: false, // Disable synchronize for migrationslogging: true,
};
// Export DataSource instance for TypeORM CLIconst dataSource = newDataSource(dataSourceOptions);
exportdefault dataSource;
Now you can generate and run migrations:
Make changes to your entities.
Generate a migration: npm run migration:generate src/database/migrations/MyFeatureMigration (replace MyFeatureMigration 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):
TypeScript
// src/features/campaigns/dtos/create-campaign.dto.tsimport { IsString, IsNotEmpty, MaxLength } from'class-validator';
exportclassCreateCampaignDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsString()
@IsNotEmpty()
messageBody: string;
// Add other fields like scheduledAt, targetSegment etc. if needed
}
Service (CampaignsService):
TypeScript
// src/features/campaigns/campaigns.service.tsimport { Injectable, NotFoundException, Logger } from'@nestjs/common'; // Added Loggerimport { 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()
exportclassCampaignsService {
privatereadonly logger = newLogger(CampaignsService.name); // Added logger instanceconstructor(@InjectRepository(Campaign)
privatecampaignsRepository: Repository<Campaign>,
// MessagingService will be injected once created and its module importedprivatereadonlymessagingService: MessagingService,
) {}
asynccreate(createCampaignDto: CreateCampaignDto): Promise<Campaign> {
const campaign = this.campaignsRepository.create({
...createCampaignDto,
status: CampaignStatus.PENDING, // Default status
});
returnthis.campaignsRepository.save(campaign);
}
asyncfindAll(): Promise<Campaign[]> {
returnthis.campaignsRepository.find();
}
asyncfindOne(id: string): Promise<Campaign> {
const campaign = awaitthis.campaignsRepository.findOneBy({ id });
if (!campaign) {
thrownewNotFoundException(`Campaign with ID ${id} not found`);
}
return campaign;
}
asyncstartCampaign(id: string): Promise<{ message: string; queuedMessages: number }> {
const campaign = awaitthis.findOne(id);
if (campaign.status !== CampaignStatus.PENDING) {
// Allow restarting 'FAILED' campaigns? Add logic if needed.thrownewError(`Campaign ${id} is not in PENDING state (current: ${campaign.status}).`);
}
// Update status immediately to prevent double sendingawaitthis.campaignsRepository.update(id, { status: CampaignStatus.SENDING, sentAt: newDate() });
this.logger.log(`Campaign ${id} status updated to SENDING.`);
// Trigger the messaging service to queue jobsconst queuedCount = awaitthis.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):
TypeScript
// src/features/campaigns/campaigns.controller.tsimport { 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')
exportclassCampaignsController {
constructor(privatereadonlycampaignsService: 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> {
returnthis.campaignsService.create(createCampaignDto);
}
// @ApiOperation({ summary: 'Get all campaigns' })// @ApiResponse({ status: 200, description: 'List of campaigns.', type: [Campaign] })@Get()
findAll(): Promise<Campaign[]> {
returnthis.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> {
returnthis.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 operationsstartSending(@Param('id', ParseUUIDPipe) id: string): Promise<{ message: string; queuedMessages: number }> {
// Add Auth Guard here in production: @UseGuards(JwtAuthGuard)returnthis.campaignsService.startCampaign(id);
}
}
Subscribers Feature: Implement similarly (DTO, Service, Controller, Module) for adding, listing, updating (opt-out status), and deleting subscribers. Ensure you validate phone numbers and store them in E.164 format.
(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
You've already started building the API layer with controllers and DTOs.
Authentication/Authorization: For production, protect your 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 in main.ts and class-validator decorators in DTOs.
API Documentation (Swagger – Optional):
Install dev dependencies (done in Step 1).
Setup in main.ts:
TypeScript
// src/main.ts// ... other importsimport { SwaggerModule, DocumentBuilder } from'@nestjs/swagger';
asyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const configService = app.get(ConfigService);
app.useGlobalPipes(newValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}));
// Swagger Setupconst swaggerConfig = newDocumentBuilder()
.setTitle('Marketing Campaign API')
.setDescription('API for managing SMS marketing campaigns and subscribers')
.setVersion('1.0')
.addBearerAuth() // If using JWT auth
.build();
constdocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api-docs', app, document); // Access at /api-docsconst 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 in CampaignsController).
Testing Endpoints (curl examples):
Create Campaign:
Bash
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."
}'
# 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
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. You'll incorporate p-queue for concurrency control and p-retry for robustness.
TypeScript
// src/shared/twilio/twilio.service.tsimportPQueuefrom'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()
exportclassTwilioServiceimplementsOnModuleInit {
privateclient: Twilio;
privatereadonly logger = newLogger(TwilioService.name);
privatequeue: PQueue;
privatemessagingServiceSid: string | undefined;
privatetwilioPhoneNumber: string | undefined;
privatestatusCallbackUrl: string | undefined;
privateauthToken: string | undefined; // Store auth token for validationconstructor(privateconfigService: 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.');
thrownewError('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.');
thrownewError('Twilio sender identifier not found.');
}
this.client = twilio(accountSid, this.authToken);
this.queue = newPQueue({ concurrency }); // Control concurrent API callsthis.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}`);
}
}
privateasyncsendSmsInternal(options: MessageListInstanceCreateOptions): Promise<any> {
// Use Messaging Service SID if available, otherwise use the phone numberconst sender = this.messagingServiceSid
? { messagingServiceSid: this.messagingServiceSid }
: { from: this.twilioPhoneNumber };
constpayload: MessageListInstanceCreateOptions = {
...options,
...sender,
statusCallback: this.statusCallbackUrl, // Add status callback URL
};
this.logger.debug(`Sending SMS via Twilio: ${JSON.stringify({...payload, body: '[REDACTED]'})}`);
returnthis.client.messages.create(payload);
}
/**
* Adds an SMS message to a managed queue for sending.
* Handles retries and concurrency.
* @param options Message options (to, body)
*/asyncqueueSms(options: Pick<MessageListInstanceCreateOptions, 'to' | 'body'>) {
const retries = this.configService.get<number>('TWILIO_SEND_RETRIES', 3);
returnthis.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):
TypeScript
// src/shared/twilio/twilio.module.tsimport { 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 availableproviders: [TwilioService],
exports: [TwilioService], // Export the service
})
exportclassTwilioModule {}
Status Callback Controller (TwilioStatusController):
Create a controller to handle incoming webhook requests from Twilio about message status updates. Crucially, secure this endpoint.
TypeScript
// src/webhooks/twilio-status/twilio-status.controller.tsimport { Controller, Post, Body, Req, Res, Logger, ForbiddenException, Headers } from'@nestjs/common';
import { Request, Response } from'express';
import { validateRequest } from'twilio'; // Twilio's request validation helperimport { ConfigService } from'@nestjs/config';
@Controller('twilio/status') // Matches TWILIO_STATUS_CALLBACK_URL pathexportclassTwilioStatusController {
privatereadonly logger = newLogger(TwilioStatusController.name);
privatetwilioAuthToken: string;
constructor(privateconfigService: 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.');
thrownewForbiddenException('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.');
thrownewForbiddenException('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
}
}
The validateRequest function from twilio 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. Use ngrok or similar tools for local development to get a public URL.
Frequently Asked Questions (FAQ)
What Node.js version should I use for this Twilio NestJS marketing campaign system?
Use Node.js v18 or later (v20 LTS recommended) as specified in the prerequisites. Node.js 18.x provides long-term support and is compatible with NestJS, Twilio SDK, TypeORM, and Bull queue dependencies used in this guide.
Do I need a paid Twilio account to send marketing SMS campaigns?
Yes. While Twilio offers trial accounts for testing, production SMS marketing campaigns require a paid account with purchased phone numbers or a Messaging Service SID. Trial accounts have limitations on recipient numbers (only verified numbers) and sending volumes. Review Twilio's pricing and upgrade your account before launching campaigns.
How do I prevent sending duplicate SMS messages to the same subscriber?
Use Bull queue's built-in job deduplication by setting a unique jobId based on campaign ID and subscriber phone number combination. Additionally, track message sends in your database with a compound index on (campaignId, subscriberId) and check before queuing. The guide's architecture with PostgreSQL and TypeORM supports adding a MessageLog entity for tracking.
What's the recommended concurrency for Twilio API calls?
Start with TWILIO_QUEUE_CONCURRENCY=5 as shown in the .env configuration. Twilio's default rate limit is typically 1 message per second for trial accounts and higher for paid accounts (varies by account type). Monitor your specific account limits in the Twilio Console and adjust the p-queue concurrency setting accordingly to avoid rate limit errors (HTTP 429).
How do I handle Twilio SMS delivery failures and retries?
The guide implements retry logic using p-retry in TwilioService.queueSms() with configurable TWILIO_SEND_RETRIES (default 3 attempts). For delivery status tracking, implement the TwilioStatusController webhook to receive status updates ("delivered", "failed", "undelivered"). Store Twilio's MessageSid when sending, then match it in the webhook to update your database and trigger retry logic for failed messages.
Can I use this system with Twilio Messaging Services instead of phone numbers?
Yes. The code supports both approaches. Set TWILIO_MESSAGING_SERVICE_SID in your .env file to use a Messaging Service (recommended for features like sender ID pools, geo-matching, and integrated opt-out handling). If not set, the system falls back to TWILIO_PHONE_NUMBER. The TwilioService automatically selects the appropriate sender identifier.
How do I deploy this NestJS Twilio marketing system to production?
Use Docker containerization (as referenced in prerequisites) with a multi-stage Dockerfile. Deploy to platforms like AWS ECS, Google Cloud Run, Azure Container Instances, or DigitalOcean App Platform. Ensure PostgreSQL and Redis are accessible (use managed services like AWS RDS and ElastiCache). Set all environment variables in your deployment platform, configure webhook URLs to your public domain, and implement proper monitoring with tools like DataDog, New Relic, or AWS CloudWatch.
What database migrations strategy should I use for production?
Use TypeORM migrations as demonstrated in the guide. Set synchronize: false in production TypeOrmModule configuration. Generate migrations with npm run migration:generate after entity changes, review the generated SQL, and run npm run migration:run during deployment. Store migrations in version control and automate migration execution in your CI/CD pipeline before starting the application.
Related Resources
Official Documentation
Twilio SMS API Documentation – Complete reference for Twilio's Programmable SMS API, including rate limits, best practices, and advanced features
NestJS Official Documentation – Comprehensive guides for building scalable Node.js applications with NestJS framework architecture
Twilio's Guide to A2P 10DLC – Understanding Application-to-Person messaging registration requirements for US long code SMS campaigns
Frequently asked questions
Integrate the Twilio Node.js helper library and use the TwilioService to queue messages. The service handles sending via the Twilio API, managing concurrency with p-queue, and implementing retries with p-retry for reliable delivery. The provided code examples demonstrate setting up the service and queuing messages.
Redis, an in-memory data store, is used with the Bull queue system to manage message queuing. This allows asynchronous SMS sending, improving performance and handling potential spikes in message volume without blocking the main application thread.
A Twilio Messaging Service offers benefits like using sender ID pools for better deliverability, geo-matching, and integrated opt-out handling, which simplifies compliance and user experience. It's optional but strongly recommended for production systems.
Always use TypeORM migrations in production to manage database schema changes safely and reliably. For initial development, synchronize: true in the TypeORM config can speed up prototyping, but it's not suitable for production. The article provides scripts for generating and running migrations.
The article demonstrates PostgreSQL with TypeORM, but you can adapt it to other databases. Change the TypeORM configuration and install the appropriate database driver. Update the entity definitions if needed to match the database's features.
Use the validateRequest function from the Twilio library. Ensure your controller receives the raw request body and the X-Twilio-Signature header to verify authenticity, protecting against unauthorized requests.
Bull is a Redis-based queue package. It's crucial for handling message queues, ensuring reliable SMS delivery even during high-traffic periods or if the Twilio API is temporarily unavailable. Bull manages the queue and triggers the NestJS worker to process messages.
Create a .env file in your project root and store configuration like database credentials, Twilio API keys, and other sensitive data. Use the @nestjs/config package to load these variables into your application, accessible via the ConfigService.
Node.js version 18 or later is recommended for this project to leverage the latest features and security updates for NestJS and other dependencies.
Install the NestJS CLI globally (npm i -g @nestjs/cli), then run nest new nestjs-twilio-marketing to create a new project. Navigate to the project directory (cd nestjs-twilio-marketing) and follow the steps in the article to install dependencies and configure the project.
You need Node.js, access to a PostgreSQL database, a Redis instance, a Twilio account with credentials and a phone number, basic understanding of TypeScript, NestJS, REST APIs, and Docker for deployment.
The article recommends a modular structure, separating concerns like database interactions, external services (Twilio), business logic features (Campaigns, Subscribers), and webhooks, making the application easier to scale and maintain. Example folder structure is shown.
p-queue manages concurrency, preventing overloading the Twilio API with too many requests. p-retry adds retry logic to handle transient network issues or temporary API errors, ensuring messages are sent reliably.
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. You'll learn everything from initial project setup to deployment and monitoring, enabling you to send targeted SMS campaigns reliably and efficiently.
You'll 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:
System Architecture:
Prerequisites:
curlor a tool like Postman for API testing.1. Setting up the Project
Initialize your 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-marketingInstall Dependencies: You 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-nodeandtsconfig-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.envfile in your project root. Never commit this file to version control.ACCOUNT_SIDandAUTH_TOKENare on the main dashboard.Phone Numbers>Manage>Active numbersto find or buy aTWILIO_PHONE_NUMBER. Ensure it's SMS-capable. Use the test number+15005550006for development if needed, but replace it with your actual number for real sending.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.TWILIO_STATUS_CALLBACK_URLis 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 likengrokduring development.REDIS_PASSWORDline and provide the correct password.Load Environment Variables (
app.module.ts): Configure theConfigModuleto load the.envfile 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.
2. Implementing Core Functionality
You'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
CampaignandSubscriber.// 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.tsusingConfigService.// 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-nodeandtsconfig-pathsare 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:
npm run migration:generate src/database/migrations/MyFeatureMigration(replaceMyFeatureMigrationwith a descriptive name).npm run migration:run2.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 you validate phone numbers and store them 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
CampaignsModuleandSubscribersModule(once created) intoAppModule.3. Building the API Layer
You've already started building the API layer with controllers and DTOs.
Authentication/Authorization: For production, protect your 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
ValidationPipeconfigured inmain.tsandclass-validatordecorators 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 (
curlexamples):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
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. You'll incorporatep-queuefor concurrency control andp-retryfor 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
TwilioModuleintoAppModule:// 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, secure this endpoint.// 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
TwilioStatusModuleintoAppModule:// 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:
validateRequestfunction fromtwiliorequires the original URL Twilio called and the raw, unparsed POST body parameters./twilio/statusroute 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.TWILIO_STATUS_CALLBACK_URLin.envexactly matches the public URL configured in Twilio and used in the validation. Usengrokor similar tools for local development to get a public URL.Frequently Asked Questions (FAQ)
What Node.js version should I use for this Twilio NestJS marketing campaign system?
Use Node.js v18 or later (v20 LTS recommended) as specified in the prerequisites. Node.js 18.x provides long-term support and is compatible with NestJS, Twilio SDK, TypeORM, and Bull queue dependencies used in this guide.
Do I need a paid Twilio account to send marketing SMS campaigns?
Yes. While Twilio offers trial accounts for testing, production SMS marketing campaigns require a paid account with purchased phone numbers or a Messaging Service SID. Trial accounts have limitations on recipient numbers (only verified numbers) and sending volumes. Review Twilio's pricing and upgrade your account before launching campaigns.
How do I prevent sending duplicate SMS messages to the same subscriber?
Use Bull queue's built-in job deduplication by setting a unique
jobIdbased on campaign ID and subscriber phone number combination. Additionally, track message sends in your database with a compound index on(campaignId, subscriberId)and check before queuing. The guide's architecture with PostgreSQL and TypeORM supports adding aMessageLogentity for tracking.What's the recommended concurrency for Twilio API calls?
Start with
TWILIO_QUEUE_CONCURRENCY=5as shown in the.envconfiguration. Twilio's default rate limit is typically 1 message per second for trial accounts and higher for paid accounts (varies by account type). Monitor your specific account limits in the Twilio Console and adjust thep-queueconcurrency setting accordingly to avoid rate limit errors (HTTP 429).How do I handle Twilio SMS delivery failures and retries?
The guide implements retry logic using
p-retryinTwilioService.queueSms()with configurableTWILIO_SEND_RETRIES(default 3 attempts). For delivery status tracking, implement theTwilioStatusControllerwebhook to receive status updates ("delivered", "failed", "undelivered"). Store Twilio'sMessageSidwhen sending, then match it in the webhook to update your database and trigger retry logic for failed messages.Can I use this system with Twilio Messaging Services instead of phone numbers?
Yes. The code supports both approaches. Set
TWILIO_MESSAGING_SERVICE_SIDin your.envfile to use a Messaging Service (recommended for features like sender ID pools, geo-matching, and integrated opt-out handling). If not set, the system falls back toTWILIO_PHONE_NUMBER. TheTwilioServiceautomatically selects the appropriate sender identifier.How do I deploy this NestJS Twilio marketing system to production?
Use Docker containerization (as referenced in prerequisites) with a multi-stage Dockerfile. Deploy to platforms like AWS ECS, Google Cloud Run, Azure Container Instances, or DigitalOcean App Platform. Ensure PostgreSQL and Redis are accessible (use managed services like AWS RDS and ElastiCache). Set all environment variables in your deployment platform, configure webhook URLs to your public domain, and implement proper monitoring with tools like DataDog, New Relic, or AWS CloudWatch.
What database migrations strategy should I use for production?
Use TypeORM migrations as demonstrated in the guide. Set
synchronize: falsein productionTypeOrmModuleconfiguration. Generate migrations withnpm run migration:generateafter entity changes, review the generated SQL, and runnpm run migration:runduring deployment. Store migrations in version control and automate migration execution in your CI/CD pipeline before starting the application.Related Resources
Official Documentation
SMS Marketing Best Practices