code examples

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

Build SMS Marketing Campaigns with MessageBird, NestJS & Node.js (2025)

Learn how to build TCPA-compliant SMS marketing campaigns with NestJS 11, MessageBird API, and PostgreSQL. Complete guide covering subscriber management, webhook handling, opt-in/opt-out compliance, and production deployment with Prisma ORM.

Building SMS Marketing Campaigns with Node.js, NestJS, and MessageBird

<!-- DEPTH: Introduction lacks specific examples of real-world use cases or business scenarios (Priority: Medium) --> <!-- GAP: Missing information about MessageBird pricing/costs for SMS campaigns (Type: Substantive) -->

Learn how to build production-ready SMS marketing campaigns using NestJS 11, MessageBird API (now Bird), and PostgreSQL. This comprehensive tutorial walks you through creating a scalable SMS marketing system with Node.js v16+, complete with TCPA compliance features, webhook handling, and subscriber management using Prisma ORM. You'll learn how to set up your project, implement core logic with Prisma ORM, handle webhooks for subscriber management, and deploy a TCPA-compliant system that manages SMS subscriptions (opt-in/opt-out) and broadcasts messages effectively while maintaining compliance with U.S. telecommunications regulations.

By the end of this tutorial, you'll have a scalable and secure NestJS application that handles incoming SMS commands (SUBSCRIBE/STOP) via MessageBird webhooks and sends targeted marketing messages through an administrative API endpoint. This guide provides a solid foundation, but achieving true "production-ready" status requires further hardening around security (implementing webhook signing key verification), robust error reporting, TCPA compliance, and strategies for very large scale (using job queues).

Important Compliance Note: If you send SMS marketing messages to U.S. consumers, you must comply with the Telephone Consumer Protection Act (TCPA). New TCPA rules became effective April 11, 2025, requiring express written consent, faster opt-out processing (within 10 business days), and acceptance of various opt-out methods. Non-compliance can result in penalties of $500–$1,500 per violation. Consult with legal counsel to ensure your implementation meets all regulatory requirements. [Source: FCC TCPA Rules, effective April 11, 2025]

What You'll Build: SMS Marketing Campaign System Overview

<!-- GAP: Missing concrete examples of what "express written consent" means in practice (Type: Critical) -->

The Challenge: Businesses need a reliable, TCPA-compliant way to manage SMS marketing campaigns, handle subscriber opt-ins and opt-outs automatically, send bulk messages to targeted audiences, and maintain compliance with U.S. telecommunications regulations including the April 2025 TCPA updates.

Solution: Build a NestJS application that:

  1. Listens for incoming SMS messages via a MessageBird webhook.
  2. Processes SUBSCRIBE and STOP keywords to manage user subscription status in a database.
  3. Sends confirmation messages back to the user upon status changes.
  4. Provides a secure API endpoint for administrators to send broadcast messages to all active subscribers.
  5. Maintains compliance with TCPA regulations including proper consent management and opt-out handling.
<!-- EXPAND: Could benefit from performance expectations/benchmarks table (Type: Enhancement) -->

Technologies:

  • Node.js: The runtime environment (Node.js v16 or higher required for NestJS v10+).
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture, dependency injection, and built-in tooling (validation pipes and Swagger integration) accelerate development and promote maintainability. Latest version: NestJS v11 (released January 2025) with enhanced console logging, performance optimizations, and improved microservice transporters. [Source: NestJS Official Release, January 2025]
  • MessageBird (Bird): The communications platform providing the SMS API, virtual mobile numbers (VMNs), and webhook capabilities. Note: MessageBird rebranded to "Bird" but the API endpoints and SDKs remain compatible. [Source: Bird Platform Documentation, 2024]
  • PostgreSQL: A powerful, open-source relational database for persistent storage of subscriber data.
  • Prisma: A next-generation Object-Relational Mapper (ORM) for Node.js and TypeScript, simplifying database access, migrations, and type safety. Latest version: Prisma ORM v6.2.0 (released January 7, 2025) with stabilized driverAdapters, enhanced full-text search, and AI coding assistant safety checks. [Source: Prisma GitHub Releases, January 2025]
  • Docker: For containerizing the application, ensuring consistent environments and simplifying deployment.

System Architecture:

Note: This diagram uses ASCII art for simplicity. In environments supporting it (like GitHub Markdown), a Mermaid diagram could provide a richer visual representation.

<!-- EXPAND: ASCII diagram could be converted to Mermaid for better readability (Type: Enhancement) --> +----------+ +-------------+ +---------------+ +-----------------+ +----------+ | User SMS | ----> | MessageBird | ----> | Flow Builder | ----> | NestJS Webhook | ----> | Database | | (SUB/STOP)| | (VMN) | | (Forward URL) | ----> | (/webhooks/mb) | <---> | (Postgres)| +----------+ +-------------+ +---------------+ | - Parse Command | +----------+ | - Update Sub | | - Send Confirm | +--------+--------+ | v +-----------+ +-----------------+ +-----------------+ +-------------+ +----------+ | Admin | ----> | NestJS API | ----> | NestJS Service | ----> | MessageBird | ----> | User SMS | | (API Call)| | (/campaigns/send| ----> | - Get Subs | ----> | (Send Bulk) | | (Campaign)| +-----------+ | Auth + Rate L.)| | - Call MB API | +-------------+ +----------+ +-----------------+ +--------+--------+ ^ | +---------------------------------------+ <!-- GAP: Missing explanation of what "Flow Builder" is and how to configure it (Type: Critical) -->

Prerequisites:

  • Node.js (LTS version recommended, v16 or higher required) and npm or yarn installed.
  • Docker and Docker Compose installed.
  • A MessageBird account with API credentials and a purchased Virtual Mobile Number (VMN) with SMS capabilities.
  • Access to a PostgreSQL database (local instance via Docker or a cloud provider).
  • Basic understanding of TypeScript, REST APIs, and asynchronous programming.
  • Legal consultation for TCPA compliance if targeting U.S. consumers.
  • localtunnel or ngrok installed globally (for local development testing only): npm install -g localtunnel. These tools are not suitable or reliable for production environments.
<!-- GAP: Missing cost information for MessageBird VMN and per-SMS pricing (Type: Substantive) --> <!-- DEPTH: Prerequisites section lacks troubleshooting guidance for common setup issues (Priority: Medium) -->

Final Outcome:

A containerized NestJS application deployable to various environments, featuring:

  • Webhook endpoint for MessageBird SMS events.
  • Database persistence for subscriber management.
  • API endpoint for sending campaigns.
  • Configuration management, logging, basic security, and error handling.
  • Setup for monitoring and testing.

Step 1: NestJS Project Setup and Configuration for SMS Marketing

<!-- DEPTH: Section lacks version compatibility matrix or known issues (Priority: Low) -->

Initialize your NestJS project and install necessary dependencies for SMS marketing.

  1. Install NestJS CLI:

    bash
    npm install -g @nestjs/cli
  2. Create New Project:

    bash
    nest new messagebird-campaign-app
    cd messagebird-campaign-app

    Choose your preferred package manager (npm or yarn).

  3. Project Structure: NestJS creates a standard structure (src/ with app.module.ts, app.controller.ts, app.service.ts, main.ts). Organize your features into modules (e.g., SubscribersModule, WebhooksModule, CampaignsModule, MessageBirdModule, DatabaseModule). This promotes separation of concerns and maintainability.

<!-- EXPAND: Could include a visual folder structure diagram showing recommended organization (Type: Enhancement) -->
  1. Install Dependencies:

    bash
    # Core & Config
    npm install @nestjs/config
    
    # MessageBird SDK
    npm install messagebird
    
    # Database (Prisma)
    npm install prisma @prisma/client pg # pg is the PostgreSQL driver
    npm install -D prisma # Dev dependency for CLI
    
    # Phone Number Validation (Required for webhook handler)
    npm install libphonenumber-js # Latest: v1.11+ supports E.164 format validation
    
    # API & Validation
    npm install @nestjs/swagger swagger-ui-express class-validator class-transformer
    
    # Security
    npm install helmet @nestjs/throttler
    
    # Monitoring & Health
    npm install @nestjs/terminus @nestjs/axios # Terminus for health checks, Axios often used by health indicators
    
    # Caching (Optional, for Performance Section)
    npm install @nestjs/cache-manager cache-manager
    
    # Retry Mechanism (Optional, for Error Handling Section)
    npm install async-retry

    Note: libphonenumber-js is essential for validating and normalizing international phone numbers in E.164 format as required by MessageBird API. [Source: libphonenumber-js npm package documentation]

  2. Initialize Prisma:

    bash
    npx prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file for the database connection string.

<!-- GAP: Missing guidance on handling multiple environments (dev/staging/prod) (Type: Substantive) -->
  1. Configure Environment Variables (.env): Update the .env file created by Prisma. Add placeholders for MessageBird credentials and application settings. Create a .env.example file to track required variables.

    .env (and .env.example):

    dotenv
    # Database
    # Example: postgresql://user:password@host:port/database?schema=public
    DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/mb_campaigns?schema=public"
    
    # MessageBird
    MESSAGEBIRD_API_KEY="YOUR_MESSAGEBIRD_LIVE_API_KEY"
    # Your purchased Virtual Mobile Number (VMN) in E.164 format
    MESSAGEBIRD_ORIGINATOR="+12025550181"
    
    # Application
    PORT=3000
    
    # Webhook Security (CRITICAL for Production)
    # MessageBird uses HMAC-SHA256 signature verification
    # Find/Generate in MessageBird Dashboard: Developers > API Access > Signing Key
    MESSAGEBIRD_WEBHOOK_SIGNING_KEY="YOUR_SECURE_RANDOM_SIGNING_KEY"
    # Simple shared secret for local testing/basic auth (Less Secure – not recommended)
    # WEBHOOK_SECRET="YOUR_SECURE_RANDOM_SECRET"
    
    # Admin API Basic Auth Credentials (CHANGE THESE DEFAULTS!)
    ADMIN_USER="admin"
    ADMIN_PASSWORD="password" # CHANGE THIS!
    
    # PostgreSQL Credentials for Docker Compose (if using .env file)
    POSTGRES_USER="your_db_user"
    POSTGRES_PASSWORD="your_db_password" # CHANGE THIS!
    • DATABASE_URL: Connection string for your PostgreSQL database. Replace placeholders. Ensure it uses double quotes as shown.
    • MESSAGEBIRD_API_KEY: Find this in your MessageBird Dashboard under Developers > API access > Live API Key.
    • MESSAGEBIRD_ORIGINATOR: The VMN you purchased from MessageBird (Numbers section), in E.164 format (e.g., +1…).
    • PORT: Port the NestJS application runs on.
    • MESSAGEBIRD_WEBHOOK_SIGNING_KEY: Critical for production webhook security. MessageBird signs webhook requests using HMAC-SHA256. The signature includes the request timestamp, URL, and body hash, protecting against tampering and replay attacks. Generate or find your signing key in the MessageBird Dashboard. [Source: Bird API Webhook Verification Documentation, 2024]
    • ADMIN_USER/ADMIN_PASSWORD: Credentials for the Basic Auth guard on the admin endpoint. Change the defaults immediately and use strong credentials.
    • POSTGRES_USER/POSTGRES_PASSWORD: Used by Docker Compose if sourcing from a .env file.
<!-- GAP: Missing step-by-step instructions on where to find/generate signing key in MessageBird Dashboard (Type: Critical) --> <!-- DEPTH: No guidance on password strength requirements or best practices (Priority: Medium) -->
  1. Configure ConfigModule: Import and configure ConfigModule in src/app.module.ts to load environment variables.

    src/app.module.ts:

    typescript
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { DatabaseModule } from './database/database.module';
    import { SubscribersModule } from './subscribers/subscribers.module';
    import { WebhooksModule } from './webhooks/webhooks.module';
    import { CampaignsModule } from './campaigns/campaigns.module';
    import { MessageBirdModule } from './messagebird/messagebird.module';
    import { ThrottlerModule } from '@nestjs/throttler';
    import { HealthModule } from './health/health.module'; // Import Health Module
    import { CacheModule } from '@nestjs/cache-manager'; // Import Cache Module
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
          envFilePath: '.env', // Load .env file
        }),
        ThrottlerModule.forRoot([{ // Rate Limiting Configuration
          ttl: 60000, // Time-to-live: 1 minute (in milliseconds)
          limit: 10, // Default limit: 10 requests per TTL per IP/route
        }]),
        CacheModule.register({ // Caching Configuration (optional)
          isGlobal: true, // Make cache manager available globally
          ttl: 60000, // Default Cache TTL: 60 seconds (in ms)
          // store: redisStore, // Example for Redis store
        }),
        DatabaseModule,
        SubscribersModule,
        MessageBirdModule,
        WebhooksModule, // Ensure MessageBirdModule is imported before or handles forwardRef
        CampaignsModule, // Ensure MessageBirdModule and SubscribersModule are imported
        HealthModule, // Health Check Module
      ],
      controllers: [AppController],
      providers: [AppService], // Global Guards like Throttler can be added here if needed everywhere
    })
    export class AppModule {}
<!-- DEPTH: Rate limiting values (10 req/min) need justification or tuning guidance (Priority: High) --> <!-- EXPAND: Could include discussion of Redis for distributed caching in production (Type: Enhancement) -->

Step 2: Database Schema Design and Prisma ORM Integration

Store and manage subscriber information with Prisma ORM and PostgreSQL.

  1. Define Database Schema (prisma/schema.prisma): Define the Subscriber model.

    prisma/schema.prisma:

    prisma
    // This is your Prisma schema file,
    // learn more about it in the docs: https://pris.ly/d/prisma-schema
    
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model Subscriber {
      id           String   @id @default(cuid()) // Unique identifier
      phoneNumber  String   @unique // E.164 format, unique constraint
      isSubscribed Boolean  @default(false) // Subscription status
      createdAt    DateTime @default(now())
      updatedAt    DateTime @updatedAt
    }
<!-- GAP: Missing fields for tracking consent timestamp, consent method, and opt-out timestamp (Type: Critical) --> <!-- DEPTH: Schema lacks indexes for common queries or audit trail fields (Priority: High) --> <!-- EXPAND: Could add fields for campaign tracking, user preferences, or segmentation (Type: Enhancement) -->
  1. Apply Database Migrations:
    • Ensure your PostgreSQL database is running (e.g., via Docker: docker run --name postgres-mb -e POSTGRES_PASSWORD=your_db_password -e POSTGRES_USER=your_db_user -e POSTGRES_DB=mb_campaigns -p 5432:5432 -d postgres) Note: For improved security even locally, avoid hardcoding passwords directly. Consider using environment variables passed to the docker run command (e.g., -e POSTGRES_PASSWORD=$DB_PASSWORD) or Docker Compose with a .env file.
    • Run the migration command:
      bash
      npx prisma migrate dev --name init-subscribers
    This creates the Subscriber table in your database and generates the Prisma Client.
<!-- DEPTH: Missing guidance on handling migration rollbacks or production migrations (Priority: Medium) -->
  1. Create Database Module (src/database): A dedicated module for Prisma Client.

    src/database/prisma.service.ts:

    typescript
    import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
    import { PrismaClient } from '@prisma/client';
    
    @Injectable()
    export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
      private readonly logger = new Logger(PrismaService.name);
    
      constructor() {
        super({
          // Optional: Configure logging or other Prisma Client options
          // log: ['query', 'info', 'warn', 'error'],
        });
      }
    
      async onModuleInit() {
        try {
          await this.$connect();
          this.logger.log('Database connected successfully');
        } catch (error) {
          this.logger.error('Database connection failed', error);
          // Optionally re-throw or handle critical connection failure
        }
      }
    
      async onModuleDestroy() {
        await this.$disconnect();
        this.logger.log('Database connection closed');
      }
    }
<!-- DEPTH: Missing connection pool configuration and retry strategy (Priority: Medium) --> `src/database/database.module.ts`: ```typescript import { Module, Global } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Global() // Make PrismaService available globally without importing DatabaseModule everywhere @Module({ providers: [PrismaService], exports: [PrismaService], // Export PrismaService for injection }) export class DatabaseModule {} ``` * `DatabaseModule` is already imported in `src/app.module.ts`.

4. Create Subscribers Module (src/subscribers): Generate the module and service.

```bash nest g module subscribers nest g service subscribers --no-spec nest g controller subscribers --no-spec ``` *Note: Although we generate a controller here, we will remove it later (in Section 2.6) as subscriber management is handled solely via the MessageBird webhook in this specific application design, not a direct API.*

5. Implement SubscribersService (src/subscribers/subscribers.service.ts): Add logic to interact with the database.

`src/subscribers/subscribers.service.ts`: ```typescript import { Injectable, Logger, Inject } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { Subscriber } from '@prisma/client'; import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; @Injectable() export class SubscribersService { private readonly logger = new Logger(SubscribersService.name); private readonly activeSubscribersCacheKey = 'active-subscribers-list'; constructor( private readonly prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache, // Inject Cache Manager ) {} /** * Error Handling Strategy: This service catches errors during DB operations and returns * `null` or `[]`. This allows the calling function (e.g., webhook handler) to log the * failure but continue its flow gracefully (e.g., still return 200 OK to MessageBird). * In other scenarios, throwing specific custom exceptions might be preferred for more * detailed error handling upstream or via global exception filters. */ // Find or create a subscriber, then update status async handleSubscriptionUpdate(phoneNumber: string, subscribe: boolean): Promise<Subscriber | null> { this.logger.log(`Handling subscription update for ${phoneNumber}. Subscribe: ${subscribe}`); try { const subscriber = await this.prisma.subscriber.upsert({ where: { phoneNumber: phoneNumber }, update: { isSubscribed: subscribe }, create: { phoneNumber: phoneNumber, isSubscribed: subscribe }, }); this.logger.log(`Subscription status updated for ${phoneNumber} to ${subscriber.isSubscribed}`); // Invalidate cache on any subscription change await this.cacheManager.del(this.activeSubscribersCacheKey); this.logger.log(`Invalidated active subscribers cache.`); return subscriber; } catch (error) { this.logger.error(`Failed to update subscription for ${phoneNumber}: ${error.message}`, error.stack); return null; // Return null on failure as per strategy } } // Get all active subscribers (potentially from cache) async findActiveSubscribers(): Promise<Pick<Subscriber, 'phoneNumber'>[]> { try { // Try fetching from cache first const cachedSubscribers = await this.cacheManager.get<Pick<Subscriber, 'phoneNumber'>[]>(this.activeSubscribersCacheKey); if (cachedSubscribers) { this.logger.log(`Fetched ${cachedSubscribers.length} active subscribers from cache.`); return cachedSubscribers; } // If not in cache, fetch from DB this.logger.log('Cache miss. Fetching active subscribers from database...'); const activeSubs = await this.prisma.subscriber.findMany({ where: { isSubscribed: true }, select: { phoneNumber: true }, // Only select the phone number }); this.logger.log(`Found ${activeSubs.length} active subscribers in database.`); // Store in cache before returning await this.cacheManager.set(this.activeSubscribersCacheKey, activeSubs); // Use default TTL from CacheModule config this.logger.log(`Stored ${activeSubs.length} active subscribers in cache.`); return activeSubs; } catch (error) { this.logger.error(`Failed to fetch active subscribers: ${error.message}`, error.stack); return []; // Return empty array on error } } // Find a single subscriber by phone number (useful for checks) async findSubscriberByNumber(phoneNumber: string): Promise<Subscriber | null> { try { return await this.prisma.subscriber.findUnique({ where: { phoneNumber: phoneNumber }, }); } catch (error) { this.logger.error(`Failed to find subscriber ${phoneNumber}: ${error.message}`, error.stack); return null; } } } ``` * Uses `upsert` for efficiency. * `findActiveSubscribers` retrieves only phone numbers and uses caching. * Includes logging and cache invalidation. <!-- DEPTH: Missing discussion of cache warming strategies for large subscriber lists (Priority: Medium) --> <!-- GAP: No handling for race conditions or concurrent subscription updates (Type: Substantive) --> <!-- EXPAND: Could add pagination for large subscriber lists or bulk operations (Type: Enhancement) -->
  1. Update SubscribersModule (src/subscribers/subscribers.module.ts): Remove the controller and ensure the service is exported.

    src/subscribers/subscribers.module.ts:

    typescript
    import { Module } from '@nestjs/common';
    import { SubscribersService } from './subscribers.service';
    // No controller needed as per Section 2.4 note. Management via webhook.
    // DatabaseModule is global, so PrismaService is available.
    // CacheModule is global, so CacheManager is available.
    
    @Module({
      providers: [SubscribersService],
      exports: [SubscribersService], // Export for use in Webhooks and Campaigns modules
    })
    export class SubscribersModule {}
    • SubscribersModule is already imported into src/app.module.ts.

Step 3: MessageBird SMS API Integration and Webhook Configuration

Handle incoming SMS messages via MessageBird webhooks and implement subscription management.

3.1 Setting Up MessageBird Webhooks for Inbound SMS

  1. Create Webhooks Module (src/webhooks):

    bash
    nest g module webhooks
    nest g controller webhooks --no-spec
  2. Define Webhook DTO (src/webhooks/dto/messagebird-webhook.dto.ts): Use class-validator to validate the incoming payload structure.

    src/webhooks/dto/messagebird-webhook.dto.ts:

    typescript
    import { IsString, IsNotEmpty, IsOptional, Matches } from 'class-validator';
    
    /**
     * Defines the expected structure of the incoming webhook payload from MessageBird for an SMS.
     * Based on common fields. Verify against actual payloads received during testing
     * or consult MessageBird's webhook documentation for the most up-to-date structure.
     * https://developers.messagebird.com/api/webhooks/
     */
    export class MessageBirdWebhookDto {
      @IsString()
      @IsOptional() // Sometimes webhook might not contain ID? Check MB docs/payloads.
      id?: string; // Message ID
    
      @IsString()
      @IsNotEmpty()
      @Matches(/^\+?[1-9]\d{1,14}$/) // Basic E.164 format check
      originator: string; // Sender's phone number (E.164 format expected)
    
      @IsString()
      @IsNotEmpty()
      payload: string; // The raw text content of the SMS
    
      @IsString()
      @IsOptional()
      recipient?: string; // Your VMN that received the message
    
      @IsString()
      @IsOptional()
      type?: string; // e.g., 'sms', 'mms'
    
      @IsString()
      @IsOptional()
      createdDatetime?: string; // ISO 8601 timestamp when message was created
    
      // Add other fields from MessageBird payload as needed and validate them.
    }

    Note: Added common optional fields. Adjust based on actual MessageBird payloads and your needs.

<!-- DEPTH: DTO validation needs examples of actual MessageBird payloads for reference (Priority: High) --> <!-- GAP: Missing documentation link to latest MessageBird webhook payload structure (Type: Substantive) -->
  1. Implement Webhook Controller (src/webhooks/webhooks.controller.ts):

    src/webhooks/webhooks.controller.ts:

    typescript
    import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, Inject, forwardRef, Req, BadRequestException } from '@nestjs/common';
    import { Request } from 'express'; // Import Request type
    import { SubscribersService } from '../subscribers/subscribers.service';
    import { MessageBirdWebhookDto } from './dto/messagebird-webhook.dto'; // Corrected import path
    import { MessageBirdService } from '../messagebird/messagebird.service';
    import { parsePhoneNumberFromString } from 'libphonenumber-js'; // For validation
    
    // TODO: Import your MessageBird signing verification guard/middleware here
    // import { MessageBirdSignatureGuard } from '../auth/messagebird-signature.guard';
    
    @Controller('webhooks')
    export class WebhooksController {
      private readonly logger = new Logger(WebhooksController.name);
    
      constructor(
        private readonly subscribersService: SubscribersService,
        // Use forwardRef if there's a circular dependency with MessageBirdModule
        @Inject(forwardRef(() => MessageBirdService))
        private readonly messageBirdService: MessageBirdService,
      ) {}
    
      @Post('messagebird')
      @HttpCode(HttpStatus.OK) // Respond 200 OK quickly to MessageBird
      // IMPORTANT: Apply MessageBird Request Signing verification guard here for production!
      // @UseGuards(MessageBirdSignatureGuard) // Example - Implement this guard
      @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })) // Apply validation & transformation
      async handleMessageBirdWebhook(
          @Body() webhookDto: MessageBirdWebhookDto,
          @Req() request: Request // Inject request object if needed for headers (e.g., signing)
      ) {
          // --- Production Security: Webhook Signing Key Verification ---
          // The @UseGuards(MessageBirdSignatureGuard) line above is where you'd apply
          // the guard that implements HMAC-SHA256 verification using the
          // 'MessageBird-Signature-JWT' or 'MessageBird-Signature' header,
          // 'MessageBird-Request-Timestamp' header, request URL, SHA-256 hash of request body,
          // and your secret signing key (MESSAGEBIRD_WEBHOOK_SIGNING_KEY).
          // MessageBird generates: HMAC-SHA256(timestamp + url + SHA256(body), signingKey)
          // You must decode the base64-encoded signature and compare securely.
          // This step is CRUCIAL for securing your webhook in production and is required
          // for TCPA compliance to ensure message integrity and prevent unauthorized access.
          // See MessageBird's official webhook verification documentation for implementation details.
          // Reference: https://docs.bird.com/api/notifications-api/api-reference/webhook-subscriptions/verifying-a-webhook-subscription
          // --- Basic Shared Secret (Less Secure - For local testing only if needed) ---
          // const providedSecret = request.headers['x-webhook-secret']; // Example header
          // if (!this.webhookSecret || providedSecret !== this.webhookSecret) {
          //   this.logger.warn('Webhook forbidden - Invalid or missing basic secret');
          //   throw new ForbiddenException('Invalid webhook secret');
          // }
          // ----------------------------------------------------------------------------
    
          this.logger.log(`Received MessageBird webhook. Originator: ${webhookDto.originator}, Payload: ""${webhookDto.payload}""`);
    
          // Validate and normalize phone number
          const phoneNumber = parsePhoneNumberFromString(webhookDto.originator);
          if (!phoneNumber || !phoneNumber.isValid()) {
              this.logger.warn(`Received webhook with invalid originator format: ${webhookDto.originator}`);
              // Don't throw an error back to MessageBird usually, just log and ignore.
              // Throwing might cause unnecessary retries for malformed numbers.
              // Consider how you want to handle this case.
              // throw new BadRequestException('Invalid phone number format');
              return { message: 'Webhook received (invalid originator ignored)' };
          }
          const normalizedOriginator = phoneNumber.format('E.164'); // Use normalized format
    
          // Normalize keyword: lowercase and remove leading/trailing whitespace
          const keyword = webhookDto.payload.trim().toLowerCase();
    
          let confirmationMessage: string | null = null;
    
          try {
              if (keyword === 'subscribe') {
                  const subscriber = await this.subscribersService.handleSubscriptionUpdate(normalizedOriginator, true);
                  if (subscriber) {
                      confirmationMessage = 'Thanks for subscribing! Text STOP to opt-out.';
                      this.logger.log(`User ${normalizedOriginator} subscribed.`);
                  } else {
                      this.logger.warn(`Subscription update failed for ${normalizedOriginator} (subscribe)`);
                      // Optional: Set an error message to send back if needed
                      // confirmationMessage = 'Sorry, we couldn't process your subscription. Please try again later.';
                  }
    
              } else if (keyword === 'stop') {
                  const subscriber = await this.subscribersService.handleSubscriptionUpdate(normalizedOriginator, false);
                   if (subscriber) {
                        confirmationMessage = 'You have unsubscribed. Text SUBSCRIBE to rejoin.';
                        this.logger.log(`User ${normalizedOriginator} unsubscribed.`);
                    } else {
                         this.logger.warn(`Subscription update failed for ${normalizedOriginator} (stop)`);
                         // confirmationMessage = 'Sorry, we couldn't process your unsubscription. Please try again later.';
                    }
              } else {
                  this.logger.log(`Received unknown keyword ""${keyword}"" from ${normalizedOriginator}. Ignoring.`);
                  // Optional: Send a help message for unknown keywords
                  // confirmationMessage = 'Unknown command. Text SUBSCRIBE or STOP.';
              }
    
              // Send confirmation asynchronously (don't wait for it to respond to webhook)
              if (confirmationMessage) {
                // Use the service method which now returns a promise
                this.messageBirdService.sendMessage(normalizedOriginator, confirmationMessage)
                  .then(() => this.logger.log(`Sent confirmation to ${normalizedOriginator}`))
                  .catch(err => this.logger.error(`Failed to send confirmation to ${normalizedOriginator}: ${err.message}`, err.stack)); // Log error stack too
              }
    
          } catch (error) {
              // Log unexpected errors during processing
              this.logger.error(`Error processing webhook from ${normalizedOriginator}: ${error.message}`, error.stack);
              // Don't throw here; we still want to return 200 OK to MessageBird if possible
              // unless it's a critical failure that MessageBird should retry (e.g., temporary DB outage)
          }
    
          // Always return OK to MessageBird to prevent retries for processed messages,
          // unless there's a specific reason to signal failure (e.g., 5xx for temporary issues).
          return { message: 'Webhook received' };
      }
    }
    • Responds 200 OK quickly.
    • Uses ValidationPipe.
    • Normalizes phone number and keyword.
    • Calls SubscribersService.
    • Calls MessageBirdService asynchronously for confirmations.
    • Includes strong comments emphasizing the need for production signing key verification.
<!-- GAP: Missing complete implementation of MessageBirdSignatureGuard (Type: Critical) --> <!-- DEPTH: No discussion of webhook retry behavior or idempotency handling (Priority: High) --> <!-- EXPAND: Could support additional keywords like HELP, INFO, or custom commands (Type: Enhancement) --> <!-- GAP: Missing explanation of when to return 4xx vs 5xx vs 200 to MessageBird (Type: Substantive) -->
  1. Update WebhooksModule (src/webhooks/webhooks.module.ts): Import necessary modules.

    src/webhooks/webhooks.module.ts:

    typescript
    import { Module, forwardRef } from '@nestjs/common';
    import { WebhooksController } from './webhooks.controller';
    import { SubscribersModule } from '../subscribers/subscribers.module';
    import { MessageBirdModule } from '../messagebird/messagebird.module';
    // Import AuthModule if your MessageBirdSignatureGuard lives there
    // import { AuthModule } from '../auth/auth.module';
    
    @Module({
      imports: [
        SubscribersModule,
        // Use forwardRef to handle circular dependency if MessageBirdModule imports WebhooksModule indirectly
        forwardRef(() => MessageBirdModule),
        // forwardRef(() => AuthModule), // If guard is in AuthModule
      ],
      controllers: [WebhooksController],
      // providers: [MessageBirdSignatureGuard] // Provide guard if needed locally
    })
    export class WebhooksModule {}
    • WebhooksModule is already imported into src/app.module.ts.

3.2 Building the Campaign Sending API

  1. Create Campaigns Module (src/campaigns):

    bash
    nest g module campaigns
    nest g controller campaigns --no-spec
    nest g service campaigns --no-spec
  2. Define Campaign DTO (src/campaigns/dto/create-campaign.dto.ts):

    src/campaigns/dto/create-campaign.dto.ts:

    typescript
    import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
    
    export class CreateCampaignDto {
      @IsString()
      @IsNotEmpty({ message: 'Message content cannot be empty' })
      @MaxLength(1600, { message: 'Message is too long (max 1600 characters for multi-part SMS)' })
      message: string;
    }
<!-- DEPTH: No explanation of SMS character limits or segmentation (Priority: High) --> <!-- EXPAND: Could add fields for scheduling, targeting, or A/B testing (Type: Enhancement) -->
  1. Implement CampaignsService (src/campaigns/campaigns.service.ts):

    src/campaigns/campaigns.service.ts:

    typescript
    import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
    import { SubscribersService } from '../subscribers/subscribers.service';
    import { MessageBirdService } from '../messagebird/messagebird.service';
    
    @Injectable()
    export class CampaignsService {
      private readonly logger = new Logger(CampaignsService.name);
    
      constructor(
        private readonly subscribersService: SubscribersService,
         // Use forwardRef if there's a circular dependency
        @Inject(forwardRef(() => MessageBirdService))
        private readonly messageBirdService: MessageBirdService,
      ) {}
    
      async sendCampaign(message: string): Promise<{ success: boolean; sentCount: number; failedCount: number; message: string }> {
        const shortMessage = message.length > 50 ? `${message.substring(0, 50)}...` : message;
        this.logger.log(`Starting campaign send with message: ""${shortMessage}""`);
    
        const activeSubscribers = await this.subscribersService.findActiveSubscribers();
        if (!activeSubscribers || activeSubscribers.length === 0) {
          this.logger.warn('No active subscribers found. Campaign not sent.');
          return { success: false, sentCount: 0, failedCount: 0, message: 'No active subscribers to send to.' };
        }
    
        const recipients = activeSubscribers.map(sub => sub.phoneNumber);
        this.logger.log(`Attempting to send campaign to ${recipients.length} subscribers.`);
    
        try {
          // Assuming MessageBirdService has a method like sendBulkMessage
          // Adjust based on your MessageBirdService implementation
          const result = await this.messageBirdService.sendBulkMessage(recipients, message);
    
          // Process result (this depends heavily on how sendBulkMessage reports success/failure)
          // Example: Assuming it returns counts or detailed status
          const sentCount = result.successCount || recipients.length; // Placeholder logic
          const failedCount = result.failureCount || 0; // Placeholder logic
    
          if (failedCount > 0) {
            this.logger.warn(`Campaign sent with ${failedCount} failures out of ${recipients.length}.`);
            return { success: true, sentCount, failedCount, message: `Campaign sent to ${sentCount} subscribers with ${failedCount} failures.` };
          } else {
            this.logger.log(`Campaign successfully sent to all ${sentCount} subscribers.`);
            return { success: true, sentCount, failedCount: 0, message: `Campaign successfully sent to ${sentCount} subscribers.` };
          }
    
        } catch (error) {
          this.logger.error(`Failed to send campaign: ${error.message}`, error.stack);
          return { success: false, sentCount: 0, failedCount: recipients.length, message: `Failed to send campaign due to an error: ${error.message}` };
        }
      }
    }
<!-- GAP: Article appears incomplete - missing MessageBirdService implementation (Type: Critical) --> <!-- GAP: Missing CampaignsController implementation (Type: Critical) --> <!-- GAP: Missing remaining sections: Testing, Deployment, Monitoring, Conclusion (Type: Critical) --> <!-- DEPTH: No discussion of rate limiting when sending to large subscriber lists (Priority: High) --> <!-- DEPTH: Missing error handling for partial campaign failures (Priority: High) --> <!-- EXPAND: Could implement job queue (Bull/BullMQ) for async campaign processing (Type: Enhancement) -->

Frequently Asked Questions

How to send SMS marketing campaigns with Node.js?

This article provides a guide using Node.js, NestJS, and MessageBird to build an SMS marketing application. You'll set up a NestJS project, integrate with the MessageBird API, and implement features to manage subscriptions and send targeted messages. The tutorial emphasizes building a scalable and secure application, covering key aspects from project setup to deployment and monitoring.

What is MessageBird used for in SMS marketing?

MessageBird is the communication platform that provides the SMS API, virtual mobile numbers (VMNs), and webhook capabilities. It handles sending the actual SMS messages and receiving incoming messages, which trigger actions within your Node.js application, such as subscribing or unsubscribing users.

Why use NestJS for building SMS campaigns?

NestJS is chosen for its modular architecture, dependency injection, and built-in tooling. These features accelerate development, improve code organization, and promote maintainability, making it well-suited for building robust server-side applications like the SMS campaign manager described in the article.

When should I use a job queue for SMS campaigns?

For very large-scale SMS campaigns, the guide recommends considering a job queue to handle sending messages asynchronously. This prevents blocking the main application thread and ensures reliable delivery even under high load. The article provides a foundation, and scaling strategies like job queues become crucial for production readiness at scale.

How to set up a MessageBird webhook with NestJS?

Create a webhook endpoint in your NestJS application that listens for incoming SMS messages from MessageBird. Configure MessageBird to forward incoming messages to this URL. When a user texts a keyword like 'SUBSCRIBE' or 'STOP', the webhook receives the message, processes it, and updates the user's subscription status in your database.

How to manage SMS subscriptions using MessageBird?

Users can subscribe by texting 'SUBSCRIBE' and unsubscribe by texting 'STOP' to your MessageBird VMN. These keywords trigger your webhook, which updates the database accordingly. Confirmation messages are sent back to the user upon successful subscription or unsubscription.

What database is recommended for storing subscriber data?

PostgreSQL is recommended in this article for its reliability and features as an open-source relational database. The tutorial uses Prisma, an ORM, to simplify database interactions within the NestJS application, providing type safety and efficient data management.

How to integrate Prisma with a NestJS application?

Install Prisma and the PostgreSQL driver. Initialize Prisma using `npx prisma init`, which creates a schema file. Define your data models in the schema file, then apply migrations to set up the database. Create a dedicated database module in NestJS to provide the Prisma service for database interactions.

How to secure the admin API for sending campaigns?

The article recommends using Basic Auth to protect the admin API endpoint responsible for triggering campaigns. The credentials for Basic Auth are stored in environment variables (`ADMIN_USER`, `ADMIN_PASSWORD`). Note: This is a basic measure and may not suit highly sensitive environments. Implement a robust authentication mechanism suitable for production use.

What is the role of Docker in this SMS campaign application?

Docker is used for containerizing the NestJS application. This ensures consistent environments across development, testing, and production, simplifying deployment and reducing potential compatibility issues. The final outcome of the tutorial is a containerized application ready for deployment.

How to handle errors in the MessageBird webhook?

The provided code example catches errors within the webhook handler and typically returns a 200 OK to MessageBird even if some internal operation fails. This prevents unnecessary retries by MessageBird. The code logs these errors, allowing you to monitor and address them. Critically examine your context - other error handling strategies such as a retry mechanism might be more relevant to your particular implementation.

What are the prerequisites for this tutorial?

You need Node.js, npm or yarn, Docker and Docker Compose, a MessageBird account with API credentials and a VMN, access to a PostgreSQL database, and a basic understanding of TypeScript, REST APIs, and asynchronous programming. For local testing, localtunnel or ngrok are helpful, but neither should be used in production environments. Additionally, familiarity with setting up and managing .env files is assumed.

How does the system architecture for the SMS campaign app work?

The user interacts with the system via SMS. MessageBird receives the message and forwards it to your NestJS application through a webhook. The application processes the message, updates the subscriber database, and sends confirmation messages. An admin API allows sending bulk messages to subscribers.