code examples
code examples
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:
- Listens for incoming SMS messages via a MessageBird webhook.
- Processes
SUBSCRIBEandSTOPkeywords to manage user subscription status in a database. - Sends confirmation messages back to the user upon status changes.
- Provides a secure API endpoint for administrators to send broadcast messages to all active subscribers.
- Maintains compliance with TCPA regulations including proper consent management and opt-out handling.
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.
localtunnelorngrokinstalled globally (for local development testing only):npm install -g localtunnel. These tools are not suitable or reliable for production environments.
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.
-
Install NestJS CLI:
bashnpm install -g @nestjs/cli -
Create New Project:
bashnest new messagebird-campaign-app cd messagebird-campaign-appChoose your preferred package manager (npm or yarn).
-
Project Structure: NestJS creates a standard structure (
src/withapp.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.
-
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-retryNote: 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]
-
Initialize Prisma:
bashnpx prisma init --datasource-provider postgresqlThis creates a
prismadirectory with aschema.prismafile and a.envfile for the database connection string.
-
Configure Environment Variables (
.env): Update the.envfile created by Prisma. Add placeholders for MessageBird credentials and application settings. Create a.env.examplefile 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.envfile.
-
Configure
ConfigModule: Import and configureConfigModuleinsrc/app.module.tsto load environment variables.src/app.module.ts:typescriptimport { 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 {}
Step 2: Database Schema Design and Prisma ORM Integration
Store and manage subscriber information with Prisma ORM and PostgreSQL.
-
Define Database Schema (
prisma/schema.prisma): Define theSubscribermodel.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 }
- 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 thedocker runcommand (e.g.,-e POSTGRES_PASSWORD=$DB_PASSWORD) or Docker Compose with a.envfile. - Run the migration command:
bash
npx prisma migrate dev --name init-subscribers
Subscribertable in your database and generates the Prisma Client. - Ensure your PostgreSQL database is running (e.g., via Docker:
-
Create Database Module (
src/database): A dedicated module for Prisma Client.src/database/prisma.service.ts:typescriptimport { 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'); } }
`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) -->
-
Update
SubscribersModule(src/subscribers/subscribers.module.ts): Remove the controller and ensure the service is exported.src/subscribers/subscribers.module.ts:typescriptimport { 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 {}SubscribersModuleis already imported intosrc/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
-
Create Webhooks Module (
src/webhooks):bashnest g module webhooks nest g controller webhooks --no-spec -
Define Webhook DTO (
src/webhooks/dto/messagebird-webhook.dto.ts): Useclass-validatorto validate the incoming payload structure.src/webhooks/dto/messagebird-webhook.dto.ts:typescriptimport { 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.
-
Implement Webhook Controller (
src/webhooks/webhooks.controller.ts):src/webhooks/webhooks.controller.ts:typescriptimport { 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 OKquickly. - Uses
ValidationPipe. - Normalizes phone number and keyword.
- Calls
SubscribersService. - Calls
MessageBirdServiceasynchronously for confirmations. - Includes strong comments emphasizing the need for production signing key verification.
- Responds
-
Update
WebhooksModule(src/webhooks/webhooks.module.ts): Import necessary modules.src/webhooks/webhooks.module.ts:typescriptimport { 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 {}WebhooksModuleis already imported intosrc/app.module.ts.
3.2 Building the Campaign Sending API
-
Create Campaigns Module (
src/campaigns):bashnest g module campaigns nest g controller campaigns --no-spec nest g service campaigns --no-spec -
Define Campaign DTO (
src/campaigns/dto/create-campaign.dto.ts):src/campaigns/dto/create-campaign.dto.ts:typescriptimport { 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; }
-
Implement
CampaignsService(src/campaigns/campaigns.service.ts):src/campaigns/campaigns.service.ts:typescriptimport { 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}` }; } } }
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.