code examples

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

Build SMS Marketing Campaigns with Twilio, NestJS & Node.js: Complete Guide

Build production-ready SMS marketing campaigns with Twilio API, NestJS, TypeORM, and Bull queues. Includes bulk messaging, subscriber management, webhook handling, and deployment strategies.

Build SMS Marketing Campaigns with Twilio, NestJS & Node.js

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.

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

Initialize your NestJS project and install the necessary dependencies.

  1. 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
  2. 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.

    bash
    # 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.
  3. Environment Configuration (.env): Create a .env file in your project root. Never commit this file to version control.

    dotenv
    # .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.
    • Obtaining Twilio Credentials:
      1. Log in to your Twilio Console.
      2. Your ACCOUNT_SID and AUTH_TOKEN are on the main dashboard.
      3. 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.
      4. (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.
      5. 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.
  4. Load Environment Variables (app.module.ts): Configure the ConfigModule to load the .env file globally.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    // Import other modules (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 {}
  5. Enable Validation Pipe (main.ts): Automatically validate incoming request bodies based on DTOs.

    typescript
    // 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();
  6. 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 your application easier to understand, test, and scale.

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)

  1. Define Entities: Create TypeORM entities for Campaign and Subscriber.

    typescript
    // 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;
    }
    typescript
    // 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',
    }
  2. Configure TypeORM Module: Set up the database connection in app.module.ts using ConfigService.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule, ConfigService } from '@nestjs/config';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { 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 {}
  3. 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.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 (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:

  1. DTO (CreateCampaignDto):

    typescript
    // 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
    }
  2. Service (CampaignsService):

    typescript
    // 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
    }
  3. Controller (CampaignsController):

    typescript
    // 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);
      }
    }
  4. Module (CampaignsModule):

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

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

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

    1. Install dev dependencies (done in Step 1).

    2. Setup in main.ts:

      typescript
      // 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();
    3. 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."
               }'

      (Response: JSON object of the created campaign)

    • Add Subscriber:

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

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

  1. 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.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
    }
  2. Twilio Module (TwilioModule):

    typescript
    // 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 {}
  3. Import TwilioModule into AppModule:

    typescript
    // 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 {}
  4. 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.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
      }
    }
  5. Status Callback Module (TwilioStatusModule):

    typescript
    // 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 {}
  6. Import TwilioStatusModule into AppModule:

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

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.


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
  • TypeORM Documentation – Database ORM documentation covering entities, migrations, and advanced query techniques
  • Bull Queue Documentation – Redis-based queue system for Node.js with detailed examples for job processing and concurrency control

SMS Marketing Best Practices

Frequently Asked Questions

How to send SMS messages with NestJS and Twilio?

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.

What is the purpose of Redis in this SMS marketing system?

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.

Why does the article recommend using a Twilio Messaging Service?

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.

When should I use TypeORM migrations in NestJS?

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.

Can I use a different database with this NestJS and Twilio setup?

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.

How to validate Twilio webhook requests in NestJS?

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.

What is the role of Bull in the NestJS SMS campaign system?

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.

How to set up environment variables in a NestJS project?

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

What is the recommended Node.js version for this project?

Node.js version 18 or later is recommended for this project to leverage the latest features and security updates for NestJS and other dependencies.

How to create a new NestJS project?

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.

What are the prerequisites for building this SMS marketing campaign system?

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.

How to structure a NestJS project for maintainability?

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.

Why use p-queue and p-retry with the Twilio API?

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