Step-by-step guide to building production-ready SMS marketing campaigns using MessageBird API, Node.js, and Express. Includes webhook integration, MongoDB setup, TCPA compliance, batch sending, and deployment best practices.
Learn how to build a complete SMS marketing campaign system using MessageBird API, Node.js, and Express. This comprehensive tutorial shows you how to create a production-ready application where users subscribe and unsubscribe via SMS while administrators broadcast marketing messages to opted-in subscribers. You'll implement webhook integration for two-way messaging, MongoDB database management, TCPA compliance features, and deployment best practices for scaling your SMS marketing campaigns.
This SMS campaign application solves the common business need to manage SMS marketing lists efficiently while adhering to opt-in/opt-out regulations and TCPA compliance requirements. Leverage MessageBird's programmable virtual mobile numbers (VMNs) and real-time webhooks to create a seamless two-way messaging experience for both subscribers and administrators, enabling automated subscription management and bulk message broadcasting.
Technologies Used:
Node.js: JavaScript runtime for building scalable network applications.
Express: Minimal and flexible Node.js web application framework.
MessageBird API: Communication platform providing APIs for SMS messaging and virtual numbers. (Node.js SDK v4.0.1, last updated 2022)
MongoDB: NoSQL database for storing subscriber information. (Node.js driver v6+)
dotenv: Module to load environment variables from a .env file.
Winston: Versatile logging library.
Ngrok/Localtunnel: (For development) Tools to expose your local server to the internet for webhook testing.
Access to a MongoDB database (local instance or a cloud service like MongoDB Atlas).
(Optional but Recommended for Dev) Ngrok or Localtunnel installed.
By the end of this guide, you'll have a functional SMS subscription management and broadcasting application ready for further customization and deployment. For related topics, see our guides on E.164 phone number formatting and 10DLC SMS registration requirements for US businesses.
1. Set up the Node.js Project with MessageBird SDK
Initialize your Node.js project and install the necessary dependencies for your SMS marketing campaign application. This section covers project initialization, dependency installation, environment variable configuration, and basic Express server setup with security middleware.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir messagebird-sms-campaign
cd messagebird-sms-campaign
Initialize npm:
Create a package.json file to manage dependencies.
Bash
npm init -y
Install Dependencies:
Install Express for the web server, the MessageBird SDK, the MongoDB driver, dotenv for environment variables, and winston for logging. Modern Express versions include body parsing middleware, so body-parser is not strictly required as a separate dependency.
express: Web framework (includes JSON and URL-encoded body parsing).
messagebird: Official SDK for interacting with the MessageBird API.
mongodb: Driver for connecting to and interacting with MongoDB.
dotenv: Loads environment variables from .env into process.env.
winston: Robust logging library.
express-rate-limit: Middleware for rate limiting requests (added in Security section).
helmet: Middleware for setting secure HTTP headers (added in Security section).
Create .gitignore:
Prevent sensitive files and unnecessary directories from being committed to version control. Create a file named .gitignore with the following content:
Create .env File:
Create a file named .env in the project root to store sensitive configuration. Never commit this file to Git.
.env
# MessageBird Credentials
MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY
MESSAGEBIRD_ORIGINATOR=YOUR_VIRTUAL_MOBILE_NUMBER_OR_SENDER_ID
# MongoDB Connection
MONGODB_URI=mongodb://localhost:27017/sms_campaign # Or your MongoDB Atlas URI
# Application Port
PORT=8080
# Basic Admin Auth (INSECURE – Development/Demo ONLY)
ADMIN_PASSWORD=your_secret_password
MESSAGEBIRD_API_KEY: Obtain your Live API key from the MessageBird Dashboard (Developers → API access).
MESSAGEBIRD_ORIGINATOR: This is the virtual mobile number (VMN) you purchased from MessageBird (in E.164 format, e.g., +12025550156) or an approved alphanumeric sender ID (max 11 characters, check country restrictions).
MONGODB_URI: The connection string for your MongoDB database. Replace with your actual URI if using Atlas or a different setup. sms_campaign is the database name.
PORT: The port your application will run on locally.
ADMIN_PASSWORD: WARNING: This plain-text password is for demonstration purposes only. In a production environment, you must replace this with a secure authentication mechanism (e.g., hashed passwords with a proper login flow using libraries like Passport.js, or OAuth).
Basic index.js Structure:
Create an index.js file in the root directory. This will be the entry point of our application.
JavaScript
// index.jsrequire('dotenv').config(); // Load environment variables firstconst express = require('express');
const { MongoClient } = require('mongodb');
constMessageBird = require('messagebird');
const winston = require('winston');
const helmet = require('helmet'); // For security headersconst rateLimit = require('express-rate-limit'); // For rate limiting// --- Logger Setup ---const logger = winston.createLogger({
level: 'info', // Default log levelformat: winston.format.combine(
winston.format.timestamp(),
winston.format.json() // Log in JSON format for easier parsing
),
transports: [
// - Write all logs with level `error` and below to `error.log`new winston.transports.File({ filename: 'error.log', level: 'error' }),
// - Write all logs with level `info` and below to `combined.log`new winston.transports.File({ filename: 'combined.log' }),
],
});
// If we're not in production, log to the `console` as wellif (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple() // Simple format for console
),
level: 'debug', // Log more verbosely in development
}));
}
// Initialize MessageBird SDKconst messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY);
// MongoDB Client Setupconst mongoClient = newMongoClient(process.env.MONGODB_URI);
let db;
let subscribersCollection;
// Initialize Express Appconst app = express();
const port = process.env.PORT || 8080;
// --- Security Middleware ---
app.use(helmet()); // Set various security HTTP headers// --- Middleware ---// Parse JSON bodies for API requests (built-in Express middleware)
app.use(express.json());
// Parse URL-encoded bodies (for form submissions and webhooks)
app.use(express.urlencoded({ extended: true }));
// --- Database Connection ---asyncfunctionconnectDB() {
try {
await mongoClient.connect();
logger.info('Successfully connected to MongoDB.');
db = mongoClient.db(); // Default DB from URI or specify name: mongoClient.db('sms_campaign')
subscribersCollection = db.collection('subscribers');
// Ensure index on phone number for faster lookupsawait subscribersCollection.createIndex({ number: 1 }, { unique: true });
logger.info('Subscribers collection ready and indexed.');
} catch (error) {
logger.error('Failed to connect to MongoDB', { message: error.message, stack: error.stack });
process.exit(1); // Exit if DB connection fails
}
}
// --- Routes ---// (We will add routes in the next sections)// --- Start Server ---asyncfunctionstartServer() {
awaitconnectDB();
app.listen(port, () => {
logger.info(`SMS Campaign App listening on port ${port}`);
});
}
startServer();
// Graceful shutdown
process.on('SIGINT', async () => {
logger.info('Shutting down server...');
try {
await mongoClient.close();
logger.info('MongoDB connection closed.');
} catch (error) {
logger.error('Error closing MongoDB connection during shutdown', { message: error.message, stack: error.stack });
} finally {
process.exit(0);
}
});
This sets up the basic Express server, loads environment variables, initializes the MessageBird SDK and Winston logger, establishes the MongoDB connection, applies basic security middleware, and prepares the subscribers collection with a unique index on the number field.
2. Implement SMS Webhook Handler for Incoming Messages
Create a webhook endpoint to receive and process incoming SMS messages sent by users to your MessageBird virtual number. This enables two-way SMS messaging where users can text "SUBSCRIBE" to opt-in or "STOP" to opt-out from your marketing campaigns, with automatic confirmation messages and database updates.
Configure MessageBird Flow Builder:
Go to the "Numbers" section in your MessageBird Dashboard.
Find the virtual mobile number you're using as MESSAGEBIRD_ORIGINATOR.
Click the "Flow" icon (often looks like branching lines or </>) next to the number. If no flow exists, click "Add flow".
Choose "Create Custom Flow".
Give your flow a name (e.g., "SMS Subscription Handler").
Select "SMS" as the trigger. Click "Next".
You'll see the "SMS" step connected to your number. Click the + button below it.
Choose "Forward to URL" from the available steps.
Method: Select POST.
URL: This needs to be a publicly accessible URL pointing to your application's webhook endpoint.
For Development: Start your local server (node index.js). Then, use ngrok or localtunnel:
ngrok http 8080 or lt --port 8080
Copy the https forwarding URL provided (e.g., https://randomstring.ngrok.io or https://yoursubdomain.loca.lt).
Append /webhook to this URL (e.g., https://randomstring.ngrok.io/webhook). Paste this full URL into the Flow Builder step.
For Production: Use your deployed application's public URL (e.g., https://your-app-domain.com/webhook).
Click "Save".
Click "Publish" in the top right corner to activate the flow.
Create the Webhook Route (/webhook):
Add the following route handler inside index.js within the // --- Routes --- section.
JavaScript
// index.js (continued)// --- Routes ---// Helper function to send confirmation SMS (fire-and-forget)functionsendConfirmation(recipient, message) {
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [recipient],
body: message,
};
messagebird.messages.create(params, (err, response) => {
if (err) {
// Log the error, potentially add to a retry queue for critical confirmations
logger.error(`Error sending confirmation SMS to ${recipient}`, { error: err, recipient: recipient });
} else {
const status = response?.recipients?.items[0]?.status;
logger.info(`Confirmation SMS sent to ${recipient}. Status: ${status || 'Unknown'}`, { recipient: recipient, status: status });
}
});
}
app.post('/webhook', async (req, res) => {
const { originator, payload } = req.body;
const text = payload ? payload.trim().toLowerCase() : ''; // Handle potentially empty payloadif (!originator || !text) {
logger.warn('Webhook received invalid data: Missing originator or payload', { requestBody: req.body });
return res.status(400).send('Missing originator or payload');
}
logger.info(`Webhook received: From ${originator}, Text: "${text}"`, { originator, text });
try {
const existingSubscriber = await subscribersCollection.findOne({ number: originator });
if (text === 'subscribe') {
if (!existingSubscriber) {
// New subscriberawait subscribersCollection.insertOne({
number: originator,
subscribed: true,
subscribedAt: newDate(),
unsubscribedAt: null,
});
logger.info(`New subscriber added: ${originator}`, { originator });
sendConfirmation(originator, 'Thanks for subscribing! Text STOP to unsubscribe.');
} elseif (!existingSubscriber.subscribed) {
// Re-subscribingawait subscribersCollection.updateOne(
{ number: originator },
{ $set: { subscribed: true, subscribedAt: newDate(), unsubscribedAt: null } }
);
logger.info(`Subscriber re-subscribed: ${originator}`, { originator });
sendConfirmation(originator, 'Welcome back! You are now subscribed again. Text STOP to unsubscribe.');
} else {
// Already subscribed
logger.info(`Subscriber already subscribed: ${originator}`, { originator });
sendConfirmation(originator, 'You are already subscribed. Text STOP to unsubscribe.');
}
} elseif (text === 'stop') {
if (existingSubscriber && existingSubscriber.subscribed) {
// Opting outawait subscribersCollection.updateOne(
{ number: originator },
{ $set: { subscribed: false, unsubscribedAt: newDate() } }
);
logger.info(`Subscriber opted out: ${originator}`, { originator });
sendConfirmation(originator, 'You have unsubscribed and will no longer receive messages.');
} else {
// Not subscribed or already opted out
logger.info(`Unsubscribe request from non-active subscriber: ${originator}`, { originator });
sendConfirmation(originator, 'You are not currently subscribed.');
}
} else {
// Optional: Handle unrecognized commands
logger.info(`Unrecognized command from ${originator}: "${text}"`, { originator, text });
sendConfirmation(originator, 'Unrecognized command. Text SUBSCRIBE to join or STOP to leave.');
}
// Acknowledge receipt to MessageBird immediately.// This is crucial to prevent MessageBird from retrying the webhook delivery// due to timeouts. The actual confirmation SMS sending happens asynchronously// in the background via the sendConfirmation function.
res.status(200).send('OK');
} catch (error) {
logger.error(`Error processing webhook for ${originator}`, {
originator: originator,
text: text,
message: error.message,
stack: error.stack,
requestBody: req.body
});
// Don't send detailed error back to MessageBird, just acknowledge failure internally.// Sending 500 might cause MessageBird to retry, consider if 200 is safer depending on error type.
res.status(500).send('Internal Server Error');
}
});
// (Add Admin routes below)
This route handles incoming POST requests from MessageBird. It parses the sender's number (originator) and the message text (payload), converts the text to lowercase, and performs database operations based on the subscribe or stop keywords. It uses the logger for detailed logging. It calls the sendConfirmation helper function (which runs asynchronously) to send confirmation messages back to the user and responds to MessageBird with a 200 OK immediately to acknowledge receipt, explaining why this is done.
3. Build the MessageBird API Integration for Bulk SMS Broadcasting
Create an admin interface to send bulk SMS marketing messages to all subscribed users. This section implements batch processing to handle large subscriber lists efficiently, with built-in error handling and delivery tracking. You'll build a password-protected web form for campaign management.
Create Admin Form Route (GET /admin):
This route displays the HTML form. Add this within the // --- Routes --- section in index.js.
Create Send Message Route (POST /send):
This route handles the form submission, verifies the password, fetches subscribers, and sends the message in batches. Add this within the // --- Routes --- section.
JavaScript
// index.js (continued)
app.post('/send', async (req, res) => {
const { message, password } = req.body;
// --- Basic Authentication (INSECURE - Replace in Production) ---if (password !== process.env.ADMIN_PASSWORD) {
logger.warn('Admin send attempt failed: Invalid password.', { remoteAddress: req.ip });
return res.status(401).send('Unauthorized: Invalid password.');
}
if (!message || message.trim() === '') {
logger.warn('Admin send attempt failed: Empty message.', { remoteAddress: req.ip });
return res.status(400).send('Bad Request: Message content cannot be empty.');
}
try {
// Fetch active subscribers' numbersconst subscribers = await subscribersCollection.find(
{ subscribed: true },
{ projection: { number: 1, _id: 0 } } // Only get the number field
).toArray();
if (subscribers.length === 0) {
logger.info('Admin send attempt: No active subscribers found.');
return res.send('No active subscribers to send message to.');
}
const recipientNumbers = subscribers.map(sub => sub.number);
logger.info(`Preparing to send message to ${recipientNumbers.length} subscribers.`, { count: recipientNumbers.length });
// --- Batch Sending (MessageBird limit: 50 recipients per API call) ---const batchSize = 50;
let successfulSends = 0;
let failedBatches = 0;
const totalBatches = Math.ceil(recipientNumbers.length / batchSize);
for (let i = 0; i < recipientNumbers.length; i += batchSize) {
const batch = recipientNumbers.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: batch,
body: message,
};
try {
// Use await directly with the promise returned by the SDK (v3+)const response = await messagebird.messages.create(params);
// Log success/partial success for the batchconst firstStatus = response?.recipients?.items[0]?.status;
logger.info(`Batch ${batchNumber}/${totalBatches} sent. Count: ${batch.length}. First status: ${firstStatus || 'Unknown'}`, { batchNumber, totalBatches, count: batch.length, firstStatus });
successfulSends += batch.length; // Assume success for count unless specific errors are handled per recipient
} catch (batchError) {
failedBatches++;
logger.error(`Error sending batch ${batchNumber}/${totalBatches}. Count: ${batch.length}`, {
batchNumber,
totalBatches,
count: batch.length,
message: batchError.message,
errors: batchError.errors// MessageBird SDK often includes detailed errors here
});
// Decide if you want to stop or continue on batch failure
}
}
const totalSent = successfulSends; // Adjust if tracking per-recipient status within batches
logger.info(`Finished sending campaign. Total potentially sent: ${totalSent}. Failed batches: ${failedBatches}`, { totalSent, failedBatches });
res.send(`Campaign message processed for ${recipientNumbers.length} subscribers. ${failedBatches > 0 ? `Encountered errors in ${failedBatches} batches. Check logs for details.` : 'All batches submitted successfully.'}`);
} catch (error) {
logger.error('Error during message sending process (fetching subscribers or general failure)', {
message: error.message,
stack: error.stack
});
res.status(500).send('Internal Server Error while sending messages.');
}
});
// (Start Server function below)
This route first checks the admin password (again, stressing this is INSECURE). If valid, it fetches all subscribed numbers from MongoDB. It then iterates through the numbers in batches of 50 and sends the message using await messagebird.messages.create(params);, leveraging the native Promise returned by recent SDK versions. Detailed logging using logger is included for batch success and failure.
Test with curl:
Test the /send endpoint using curl (replace placeholders):
Bash
curl -X POST http://localhost:8080/send \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "message=Hello subscribers! Special offer today only!" \
-d "password=your_secret_password"
(Response: A success or error message from the server)
4. Integrate MessageBird SMS API with Node.js (Summary)
You've already integrated MessageBird in the previous steps. Here's a summary of the key MessageBird API integration points:
Installation:npm install messagebird
Initialization:const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY); placed early in index.js.
API Key: Store securely in .env (MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY) and load via dotenv. Obtain this from your MessageBird Dashboard → Developers → API access. Ensure it's a Live key for actual sending.
Originator: Configure in .env (MESSAGEBIRD_ORIGINATOR=YOUR_VMN_OR_SENDER_ID). This must be a number purchased/verified in your MessageBird account or an approved alphanumeric ID.
Send Messages: Use await messagebird.messages.create(params) (for Promise-based handling) or messagebird.messages.create(params, callback) (for callback style). The params object must include originator, recipients (an array of numbers), and body.
Receive Messages (Webhook): Requires configuring a Flow in the MessageBird Dashboard ("Numbers" section) to forward incoming SMS for your MESSAGEBIRD_ORIGINATOR number via POST to your application's /webhook URL. The webhook handler parses req.body.originator and req.body.payload.
5. Implement Error Handling, Logging, and SMS Retry Mechanisms
Production SMS applications need robust error handling and logging.
Logging Strategy: Integrate Winston for structured JSON logging to files (error.log, combined.log) and optionally to the console in development.
Configuration: Done near the top of index.js.
Usage: Replace all console.* calls with logger.info, logger.warn, and logger.error. Log errors with message and stack trace where applicable. Include contextual information (like originator, batchNumber) in log calls.
Error Handling:
Explicit try...catch: Wrap database operations and MessageBird API calls in try...catch blocks.
Input Validation: Perform basic checks for required fields (originator, payload, message, password), returning appropriate HTTP status codes (400, 401).
Consistent Responses: Send meaningful HTTP status codes (200, 400, 401, 500). Log internal error details but don't expose them in production responses. The webhook responds 200 OK quickly to MessageBird.
Retry Mechanisms (for Sending): MessageBird API calls might fail temporarily. Implement retries only for critical outgoing messages (like the main campaign send), not usually for webhook processing (to avoid duplicate actions).
Simple Manual Retry Example (Illustrative): The following demonstrates a basic retry loop within the /send route's batch processing. For production, consider a more robust library like async-retry.
JavaScript
// Inside app.post('/send', ...) batch loop (Illustrative – Use a library for robustness)constMAX_RETRIES = 3;
constRETRY_DELAY_MS = 1000; // Base delayfor (let i = 0; i < recipientNumbers.length; i += batchSize) {
const batch = recipientNumbers.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
const params = { /* ... */ };
let attempt = 0;
let batchSuccess = false;
while (attempt < MAX_RETRIES && !batchSuccess) {
attempt++;
try {
const response = await messagebird.messages.create(params);
batchSuccess = true; // Assume success if no exception
logger.info(`Batch ${batchNumber}/${totalBatches}: Send attempt ${attempt} successful. Count: ${batch.length}.`, { batchNumber, totalBatches, attempt, count: batch.length });
successfulSends += batch.length;
} catch (batchError) {
logger.warn(`Batch ${batchNumber}/${totalBatches}: Send attempt ${attempt} failed.`, {
batchNumber, totalBatches, attempt, count: batch.length, message: batchError.message, errors: batchError.errors
});
if (attempt >= MAX_RETRIES) {
logger.error(`Batch ${batchNumber}/${totalBatches}: Send failed after ${MAX_RETRIES} attempts. Giving up on this batch.`, {
batchNumber, totalBatches, maxAttempts: MAX_RETRIES, count: batch.length, message: batchError.message, errors: batchError.errors
});
failedBatches++;
// Optionally: Log the specific numbers in the failed batch for manual follow-up
} else {
// Basic exponential backoff (1s, 2s, 4s…)const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1);
logger.info(`Batch ${batchNumber}/${totalBatches}: Retrying attempt ${attempt+1} after ${delay}ms…`);
awaitnewPromise(resolve =>setTimeout(resolve, delay));
}
}
}
} // End of batch loop
Note: Implement retry logic carefully. Retrying non-idempotent operations can cause issues. Sending SMS is generally safe to retry if the initial attempt failed definitively.
6. Design MongoDB Schema for SMS Subscriber Management
Use MongoDB with a simple schema defined by the documents you insert for SMS subscription tracking.
subscribersCollection.countDocuments({ subscribed: true }) (Used in /admin)
Indexing: A unique index on the number field (subscribersCollection.createIndex({ number: 1 }, { unique: true })) is crucial for performance and data integrity, ensuring you don't store duplicate numbers. Create this on application start.
Migrations: For this simple schema, migrations aren't strictly necessary. For evolving schemas, tools like migrate-mongo can manage database changes systematically.
Sample Data (Manual Insertion via mongosh):
JavaScript
// Connect using mongosh: mongosh "YOUR_MONGODB_URI"// use sms_campaign; // Select the database
db.subscribers.insertOne({
number: "+12025550199", // Replace with a test numbersubscribed: true,
subscribedAt: newDate(),
unsubscribedAt: null
});
7. Add Security Features for SMS Marketing Applications
Security is paramount when handling user data and sending SMS messages.
Input Validation:
Webhook: Check that originator and payload exist. Trim payload. Consider adding E.164 format validation for originator.
Send Route: Ensure message is not empty. Validate password. Consider adding length limits or sanitization to message.
Rate Limiting: Protect against brute-force attacks and abuse. Add express-rate-limit.
Implementation: Add configuration in index.js before routes.
JavaScript
// index.js (Add near other middleware)// Rate limiting for the webhook (adjust limits as needed)const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsmessage: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
app.use('/webhook', webhookLimiter);
// Stricter rate limiting for the admin send endpointconst adminLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hourmax: 5, // Limit each IP to 5 send attempts per hourmessage: 'Too many send attempts from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/send', adminLimiter);
app.use('/admin', adminLimiter); // Also limit access to the admin form page
Security Headers: Protect against common web vulnerabilities (XSS, clickjacking, etc.). Add helmet.
Implementation:app.use(helmet()); added early in index.js.
Authentication:
Admin: The current plain-text password check in /send is highly insecure. Replace this immediately in a production environment with a proper authentication system (e.g., Passport.js with username/hashed password stored securely, OAuth, JWT).
Webhook: MessageBird webhooks don't have built-in authentication beyond the obscurity of the URL. For higher security, consider implementing MessageBird Webhook Signature Verification. This involves comparing a signature header sent by MessageBird with a signature you compute using your webhook signing key and the request body.
Environment Variables: Keep sensitive data (API keys, DB URIs, passwords) out of the codebase using .env and dotenv. Ensure the .env file is never committed to version control (.gitignore).
Logging: Avoid logging excessively sensitive information (like full message bodies if they contain PII, or passwords). Review your current logging based on data sensitivity.
Database Security:
Use strong, unique credentials for your MongoDB instance.
Configure network access rules (firewalls) to only allow connections from your application server's IP address.
Consider encryption at rest if required by compliance standards.
Dependency Management: Keep dependencies up-to-date (npm audit, npm update) to patch known vulnerabilities. Use tools like Snyk or Dependabot for automated scanning.
8. Deploy Your SMS Campaign Application to Production
Deploy your Node.js SMS marketing application following these steps.
Choose a Hosting Provider: Options include:
PaaS (Platform as a Service): Heroku, Render, Fly.io, Google App Engine, AWS Elastic Beanstalk. Often simpler to manage.
IaaS (Infrastructure as a Service): AWS EC2, Google Compute Engine, DigitalOcean Droplets. More control, more management overhead.
Serverless: AWS Lambda, Google Cloud Functions, Vercel Serverless Functions. Good for event-driven workloads like webhooks, potentially cost-effective.
Prepare for Production:
Environment Variables: Set production values for MESSAGEBIRD_API_KEY, MONGODB_URI, PORT, and crucially, replace the insecure ADMIN_PASSWORD with your secure authentication mechanism's configuration. Do not use a .env file in production; use the hosting provider's mechanism for setting environment variables.
Set NODE_ENV=production: This environment variable enables optimizations in Express and other libraries and disables development-only features (like verbose console logging in your setup).
Build Step (If using TypeScript/Babel): Transpile your code to JavaScript if necessary.
Process Manager: Use a process manager like PM2 or Nodemon (in production mode) to:
Keep the application running continuously.
Restart automatically if it crashes.
Manage clustering (running multiple instances) for better performance and availability.
Example (using PM2):
Bash
npm install pm2 -g # Install globally
pm2 start index.js --name messagebird-sms-app -i max # Start clustered
pm2 startup # Configure PM2 to start on system boot
pm2 save # Save current process list
Reverse Proxy (Recommended): Use Nginx or Apache in front of your Node.js application to:
Handle SSL/TLS termination (HTTPS).
Serve static assets efficiently.
Perform load balancing if running multiple instances.
Provide basic caching or security filtering.
Database: Ensure your production MongoDB instance is properly secured, backed up, and scaled for your expected load. Use the production connection string.
Webhook URL: Update the MessageBird Flow Builder ("Forward to URL" step) to use your production application's public URL (e.g., https://your-app-domain.com/webhook). Ensure this URL is accessible from the internet.
Monitoring & Logging:
Set up monitoring tools (e.g., Prometheus/Grafana, Datadog, New Relic) to track application performance (CPU, memory, response times) and errors.
Aggregate logs from your application (e.g., using the hosting provider's logging service, or tools like ELK Stack, Splunk, Logtail) for easier analysis and troubleshooting. Your Winston file transport is suitable for basic logging, but centralized logging is better for production.
9. SMS Marketing Compliance and Next Steps
You've successfully built a foundational SMS marketing campaign application using Node.js, Express, MongoDB, and MessageBird API. Users can subscribe and unsubscribe via SMS, and an administrator can broadcast messages to active subscribers.
Key achievements:
Project setup with essential dependencies.
Webhook implementation for handling incoming SUBSCRIBE and STOP messages.
Database integration with MongoDB for subscriber management.
Basic admin interface for sending broadcast messages.
Integration of logging, basic error handling, and security considerations.
Next Steps:
Secure Admin Authentication: Replace the insecure password check with a robust authentication system (Passport.js, OAuth, etc.).
Improve Admin Interface: Build a proper web UI (using React, Vue, Angular, or server-side templating like EJS) instead of the basic HTML form. Include features like viewing subscribers, message history, etc.
Webhook Security: Implement MessageBird webhook signature verification for enhanced security.
Advanced SMS Marketing Features:
Personalization: Use placeholders in messages (e.g., Hello {name}, …) for targeted campaigns.
Scheduling: Allow admins to schedule SMS campaigns for optimal delivery times.
A/B Testing: Test different message variations to optimize campaign performance.
Scalability: Implement clustering (PM2) and optimize database queries for handling larger subscriber lists efficiently.
More Robust Retries: Use libraries like async-retry or implement a background job queue (e.g., BullMQ, Kue) for handling message sending failures more reliably.
Testing: Add unit tests (Jest, Mocha) and integration tests (Supertest) to ensure code quality and prevent regressions.
TCPA Compliance for SMS Marketing (United States): Ensure full compliance with the Telephone Consumer Protection Act (TCPA) for SMS marketing in the US:
Express Written Consent: Obtain prior express written consent before sending promotional or automated SMS messages. Consumers must take a clear affirmative action to opt in – consent cannot be implied.
Time Restrictions: Do not send messages before 8:00 AM or after 9:00 PM in the recipient's local time zone (TCPA requirement).
Opt-Out Processing: Process opt-out requests within 24 hours. Under new TCPA rules effective April 11, 2025, honor revocations within 10 business days maximum through various methods including emails, voicemails, and informal messages like "Leave me alone".
One-to-One Consent (Effective January 26, 2026): The new one-to-one consent rule requires marketers to obtain specific consent from each consumer to receive telemarketing calls and texts.
Required Disclosures: Provide clear disclosures explicitly stating the recipient agrees to receive automated marketing messages from your specific company.
Penalties: TCPA violations can result in $500 to $1,500 per violation, plus potential class-action lawsuits.
Message Type Distinction: Transactional messages (order confirmations, shipping updates, etc.) do not require express written consent but must be strictly informational. Promotional messages always require express written consent.
GDPR Compliance (European Union): If targeting EU residents, ensure compliance with data protection requirements including explicit consent, data access rights, and the right to be forgotten.
Additional Compliance Considerations: Research and comply with local SMS marketing regulations in your target markets (e.g., CASL in Canada, various regulations in other countries).
Frequently asked questions
Configure a Flow in your MessageBird Dashboard under the "Numbers" section. Select "Create Custom Flow," choose "SMS" as the trigger, and then "Forward to URL" as the next step. Set the method to POST and enter your application's public webhook URL (e.g., 'https://your-app.com/webhook'). For development, use ngrok or localtunnel to create a public URL and append '/webhook'.
MessageBird is the communication platform providing the APIs for sending and receiving SMS messages, as well as managing virtual mobile numbers (VMNs). It acts as the bridge between your application and the mobile network, allowing you to send bulk messages and receive user replies via webhooks.
The immediate '200 OK' response to MessageBird's webhook POST request acknowledges receipt and prevents MessageBird from retrying the delivery due to potential timeouts. The actual SMS confirmations to users are handled asynchronously afterwards by a separate function, ensuring MessageBird doesn't retry the webhook unnecessarily.
Always use a Live API key for production environments where you're sending real SMS messages. Test API keys are strictly for development and testing and shouldn't be used for actual campaigns due to rate limits and potential data inconsistencies.
For initial development, you can test the send functionality by logging the outgoing messages instead of actually sending them via the MessageBird API. For more realistic testing, create test accounts within MessageBird with dedicated virtual numbers to avoid sending unexpected SMS to real users during development.
The MessageBird API has a limit of 50 recipients per API call. The application code iterates through the subscriber list and sends messages in batches of 50 using a loop and array slicing to adhere to this limit. It's crucial to implement this batching to avoid errors from MessageBird.
The MongoDB database uses the subscribers collection. Each document stores the subscriber's number (E.164 format), subscribed status (boolean), subscribedAt timestamp, and unsubscribedAt timestamp. A unique index on the number field ensures no duplicate numbers are stored.
Storing the admin password in plain text in the .env file is extremely insecure. For production, replace this with a proper authentication system like Passport.js, OAuth 2.0, or JWT, which should store password hashes securely and implement robust login flows.
Implement MessageBird Webhook Signature Verification to ensure that incoming webhook requests are genuinely from MessageBird and not malicious actors. This involves calculating a signature hash and comparing it to the one provided in the 'MessageBird-Signature' header.
Recommended deployment options include PaaS solutions like Heroku, Render, or Fly.io for ease of management, or IaaS like AWS EC2 or Google Compute Engine for more control. Serverless functions might be suitable for specific components but may not be ideal for the whole application due to webhook and long-running process needs.
Use a library like async-retry or a job queue system to handle temporary failures when sending messages. This ensures better reliability for your SMS campaign and reduces the chance of messages not reaching subscribers.
The application uses Winston for logging, configured to log in JSON format for easier parsing and analysis. Logs are written to 'error.log' for error messages and 'combined.log' for other log levels. In development, logs are also outputted to the console.
Implement secure admin authentication, improve the admin UI for better user experience, implement MessageBird webhook signature verification, add advanced messaging features like personalization and scheduling, implement robust retry mechanisms, and thorough testing are important next steps to consider.
Learn how to build a complete SMS marketing campaign system using MessageBird API, Node.js, and Express. This comprehensive tutorial shows you how to create a production-ready application where users subscribe and unsubscribe via SMS while administrators broadcast marketing messages to opted-in subscribers. You'll implement webhook integration for two-way messaging, MongoDB database management, TCPA compliance features, and deployment best practices for scaling your SMS marketing campaigns.
This SMS campaign application solves the common business need to manage SMS marketing lists efficiently while adhering to opt-in/opt-out regulations and TCPA compliance requirements. Leverage MessageBird's programmable virtual mobile numbers (VMNs) and real-time webhooks to create a seamless two-way messaging experience for both subscribers and administrators, enabling automated subscription management and bulk message broadcasting.
Technologies Used:
.envfile.System Architecture:
Prerequisites:
By the end of this guide, you'll have a functional SMS subscription management and broadcasting application ready for further customization and deployment. For related topics, see our guides on E.164 phone number formatting and 10DLC SMS registration requirements for US businesses.
1. Set up the Node.js Project with MessageBird SDK
Initialize your Node.js project and install the necessary dependencies for your SMS marketing campaign application. This section covers project initialization, dependency installation, environment variable configuration, and basic Express server setup with security middleware.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir messagebird-sms-campaign cd messagebird-sms-campaignInitialize npm: Create a
package.jsonfile to manage dependencies.Install Dependencies: Install Express for the web server, the MessageBird SDK, the MongoDB driver,
dotenvfor environment variables, andwinstonfor logging. Modern Express versions include body parsing middleware, sobody-parseris not strictly required as a separate dependency.express: Web framework (includes JSON and URL-encoded body parsing).messagebird: Official SDK for interacting with the MessageBird API.mongodb: Driver for connecting to and interacting with MongoDB.dotenv: Loads environment variables from.envintoprocess.env.winston: Robust logging library.express-rate-limit: Middleware for rate limiting requests (added in Security section).helmet: Middleware for setting secure HTTP headers (added in Security section).Create
.gitignore: Prevent sensitive files and unnecessary directories from being committed to version control. Create a file named.gitignorewith the following content:Create
.envFile: Create a file named.envin the project root to store sensitive configuration. Never commit this file to Git.MESSAGEBIRD_API_KEY: Obtain your Live API key from the MessageBird Dashboard (Developers → API access).MESSAGEBIRD_ORIGINATOR: This is the virtual mobile number (VMN) you purchased from MessageBird (in E.164 format, e.g.,+12025550156) or an approved alphanumeric sender ID (max 11 characters, check country restrictions).MONGODB_URI: The connection string for your MongoDB database. Replace with your actual URI if using Atlas or a different setup.sms_campaignis the database name.PORT: The port your application will run on locally.ADMIN_PASSWORD: WARNING: This plain-text password is for demonstration purposes only. In a production environment, you must replace this with a secure authentication mechanism (e.g., hashed passwords with a proper login flow using libraries like Passport.js, or OAuth).Basic
index.jsStructure: Create anindex.jsfile in the root directory. This will be the entry point of our application.// index.js require('dotenv').config(); // Load environment variables first const express = require('express'); const { MongoClient } = require('mongodb'); const MessageBird = require('messagebird'); const winston = require('winston'); const helmet = require('helmet'); // For security headers const rateLimit = require('express-rate-limit'); // For rate limiting // --- Logger Setup --- const logger = winston.createLogger({ level: 'info', // Default log level format: winston.format.combine( winston.format.timestamp(), winston.format.json() // Log in JSON format for easier parsing ), transports: [ // - Write all logs with level `error` and below to `error.log` new winston.transports.File({ filename: 'error.log', level: 'error' }), // - Write all logs with level `info` and below to `combined.log` new winston.transports.File({ filename: 'combined.log' }), ], }); // If we're not in production, log to the `console` as well if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() // Simple format for console ), level: 'debug', // Log more verbosely in development })); } // Initialize MessageBird SDK const messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY); // MongoDB Client Setup const mongoClient = new MongoClient(process.env.MONGODB_URI); let db; let subscribersCollection; // Initialize Express App const app = express(); const port = process.env.PORT || 8080; // --- Security Middleware --- app.use(helmet()); // Set various security HTTP headers // --- Middleware --- // Parse JSON bodies for API requests (built-in Express middleware) app.use(express.json()); // Parse URL-encoded bodies (for form submissions and webhooks) app.use(express.urlencoded({ extended: true })); // --- Database Connection --- async function connectDB() { try { await mongoClient.connect(); logger.info('Successfully connected to MongoDB.'); db = mongoClient.db(); // Default DB from URI or specify name: mongoClient.db('sms_campaign') subscribersCollection = db.collection('subscribers'); // Ensure index on phone number for faster lookups await subscribersCollection.createIndex({ number: 1 }, { unique: true }); logger.info('Subscribers collection ready and indexed.'); } catch (error) { logger.error('Failed to connect to MongoDB', { message: error.message, stack: error.stack }); process.exit(1); // Exit if DB connection fails } } // --- Routes --- // (We will add routes in the next sections) // --- Start Server --- async function startServer() { await connectDB(); app.listen(port, () => { logger.info(`SMS Campaign App listening on port ${port}`); }); } startServer(); // Graceful shutdown process.on('SIGINT', async () => { logger.info('Shutting down server...'); try { await mongoClient.close(); logger.info('MongoDB connection closed.'); } catch (error) { logger.error('Error closing MongoDB connection during shutdown', { message: error.message, stack: error.stack }); } finally { process.exit(0); } });This sets up the basic Express server, loads environment variables, initializes the MessageBird SDK and Winston logger, establishes the MongoDB connection, applies basic security middleware, and prepares the
subscriberscollection with a unique index on thenumberfield.2. Implement SMS Webhook Handler for Incoming Messages
Create a webhook endpoint to receive and process incoming SMS messages sent by users to your MessageBird virtual number. This enables two-way SMS messaging where users can text "SUBSCRIBE" to opt-in or "STOP" to opt-out from your marketing campaigns, with automatic confirmation messages and database updates.
Configure MessageBird Flow Builder:
MESSAGEBIRD_ORIGINATOR.</>) next to the number. If no flow exists, click "Add flow".+button below it.POST.node index.js). Then, use ngrok or localtunnel:ngrok http 8080orlt --port 8080httpsforwarding URL provided (e.g.,https://randomstring.ngrok.ioorhttps://yoursubdomain.loca.lt)./webhookto this URL (e.g.,https://randomstring.ngrok.io/webhook). Paste this full URL into the Flow Builder step.https://your-app-domain.com/webhook).Create the Webhook Route (
/webhook): Add the following route handler insideindex.jswithin the// --- Routes ---section.// index.js (continued) // --- Routes --- // Helper function to send confirmation SMS (fire-and-forget) function sendConfirmation(recipient, message) { const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR, recipients: [recipient], body: message, }; messagebird.messages.create(params, (err, response) => { if (err) { // Log the error, potentially add to a retry queue for critical confirmations logger.error(`Error sending confirmation SMS to ${recipient}`, { error: err, recipient: recipient }); } else { const status = response?.recipients?.items[0]?.status; logger.info(`Confirmation SMS sent to ${recipient}. Status: ${status || 'Unknown'}`, { recipient: recipient, status: status }); } }); } app.post('/webhook', async (req, res) => { const { originator, payload } = req.body; const text = payload ? payload.trim().toLowerCase() : ''; // Handle potentially empty payload if (!originator || !text) { logger.warn('Webhook received invalid data: Missing originator or payload', { requestBody: req.body }); return res.status(400).send('Missing originator or payload'); } logger.info(`Webhook received: From ${originator}, Text: "${text}"`, { originator, text }); try { const existingSubscriber = await subscribersCollection.findOne({ number: originator }); if (text === 'subscribe') { if (!existingSubscriber) { // New subscriber await subscribersCollection.insertOne({ number: originator, subscribed: true, subscribedAt: new Date(), unsubscribedAt: null, }); logger.info(`New subscriber added: ${originator}`, { originator }); sendConfirmation(originator, 'Thanks for subscribing! Text STOP to unsubscribe.'); } else if (!existingSubscriber.subscribed) { // Re-subscribing await subscribersCollection.updateOne( { number: originator }, { $set: { subscribed: true, subscribedAt: new Date(), unsubscribedAt: null } } ); logger.info(`Subscriber re-subscribed: ${originator}`, { originator }); sendConfirmation(originator, 'Welcome back! You are now subscribed again. Text STOP to unsubscribe.'); } else { // Already subscribed logger.info(`Subscriber already subscribed: ${originator}`, { originator }); sendConfirmation(originator, 'You are already subscribed. Text STOP to unsubscribe.'); } } else if (text === 'stop') { if (existingSubscriber && existingSubscriber.subscribed) { // Opting out await subscribersCollection.updateOne( { number: originator }, { $set: { subscribed: false, unsubscribedAt: new Date() } } ); logger.info(`Subscriber opted out: ${originator}`, { originator }); sendConfirmation(originator, 'You have unsubscribed and will no longer receive messages.'); } else { // Not subscribed or already opted out logger.info(`Unsubscribe request from non-active subscriber: ${originator}`, { originator }); sendConfirmation(originator, 'You are not currently subscribed.'); } } else { // Optional: Handle unrecognized commands logger.info(`Unrecognized command from ${originator}: "${text}"`, { originator, text }); sendConfirmation(originator, 'Unrecognized command. Text SUBSCRIBE to join or STOP to leave.'); } // Acknowledge receipt to MessageBird immediately. // This is crucial to prevent MessageBird from retrying the webhook delivery // due to timeouts. The actual confirmation SMS sending happens asynchronously // in the background via the sendConfirmation function. res.status(200).send('OK'); } catch (error) { logger.error(`Error processing webhook for ${originator}`, { originator: originator, text: text, message: error.message, stack: error.stack, requestBody: req.body }); // Don't send detailed error back to MessageBird, just acknowledge failure internally. // Sending 500 might cause MessageBird to retry, consider if 200 is safer depending on error type. res.status(500).send('Internal Server Error'); } }); // (Add Admin routes below)This route handles incoming
POSTrequests from MessageBird. It parses the sender's number (originator) and the message text (payload), converts the text to lowercase, and performs database operations based on thesubscribeorstopkeywords. It uses theloggerfor detailed logging. It calls thesendConfirmationhelper function (which runs asynchronously) to send confirmation messages back to the user and responds to MessageBird with a200 OKimmediately to acknowledge receipt, explaining why this is done.3. Build the MessageBird API Integration for Bulk SMS Broadcasting
Create an admin interface to send bulk SMS marketing messages to all subscribed users. This section implements batch processing to handle large subscriber lists efficiently, with built-in error handling and delivery tracking. You'll build a password-protected web form for campaign management.
Create Admin Form Route (
GET /admin): This route displays the HTML form. Add this within the// --- Routes ---section inindex.js.// index.js (continued) app.get('/admin', async (req, res) => { let subscriberCount = 0; let countMessage = ''; try { subscriberCount = await subscribersCollection.countDocuments({ subscribed: true }); countMessage = `Current Active Subscribers: ${subscriberCount}`; } catch(error) { logger.error("Failed to get subscriber count for admin page", { message: error.message, stack: error.stack }); countMessage = "Could not retrieve subscriber count."; } // Simple HTML form res.send(` <!DOCTYPE html> <html> <head><title>Send Campaign Message</title></head> <body> <h1>Send SMS Campaign Message</h1> <p>${countMessage}</p> <form action="/send" method="POST"> <div> <label for="message">Message:</label><br> <textarea id="message" name="message" rows="4" cols="50" required></textarea> </div> <div> <label for="password">Admin Password:</label><br> <input type="password" id="password" name="password" required> </div> <br> <button type="submit">Send Message</button> </form> </body> </html> `); });Create Send Message Route (
POST /send): This route handles the form submission, verifies the password, fetches subscribers, and sends the message in batches. Add this within the// --- Routes ---section.// index.js (continued) app.post('/send', async (req, res) => { const { message, password } = req.body; // --- Basic Authentication (INSECURE - Replace in Production) --- if (password !== process.env.ADMIN_PASSWORD) { logger.warn('Admin send attempt failed: Invalid password.', { remoteAddress: req.ip }); return res.status(401).send('Unauthorized: Invalid password.'); } if (!message || message.trim() === '') { logger.warn('Admin send attempt failed: Empty message.', { remoteAddress: req.ip }); return res.status(400).send('Bad Request: Message content cannot be empty.'); } try { // Fetch active subscribers' numbers const subscribers = await subscribersCollection.find( { subscribed: true }, { projection: { number: 1, _id: 0 } } // Only get the number field ).toArray(); if (subscribers.length === 0) { logger.info('Admin send attempt: No active subscribers found.'); return res.send('No active subscribers to send message to.'); } const recipientNumbers = subscribers.map(sub => sub.number); logger.info(`Preparing to send message to ${recipientNumbers.length} subscribers.`, { count: recipientNumbers.length }); // --- Batch Sending (MessageBird limit: 50 recipients per API call) --- const batchSize = 50; let successfulSends = 0; let failedBatches = 0; const totalBatches = Math.ceil(recipientNumbers.length / batchSize); for (let i = 0; i < recipientNumbers.length; i += batchSize) { const batch = recipientNumbers.slice(i, i + batchSize); const batchNumber = Math.floor(i / batchSize) + 1; const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR, recipients: batch, body: message, }; try { // Use await directly with the promise returned by the SDK (v3+) const response = await messagebird.messages.create(params); // Log success/partial success for the batch const firstStatus = response?.recipients?.items[0]?.status; logger.info(`Batch ${batchNumber}/${totalBatches} sent. Count: ${batch.length}. First status: ${firstStatus || 'Unknown'}`, { batchNumber, totalBatches, count: batch.length, firstStatus }); successfulSends += batch.length; // Assume success for count unless specific errors are handled per recipient } catch (batchError) { failedBatches++; logger.error(`Error sending batch ${batchNumber}/${totalBatches}. Count: ${batch.length}`, { batchNumber, totalBatches, count: batch.length, message: batchError.message, errors: batchError.errors // MessageBird SDK often includes detailed errors here }); // Decide if you want to stop or continue on batch failure } } const totalSent = successfulSends; // Adjust if tracking per-recipient status within batches logger.info(`Finished sending campaign. Total potentially sent: ${totalSent}. Failed batches: ${failedBatches}`, { totalSent, failedBatches }); res.send(`Campaign message processed for ${recipientNumbers.length} subscribers. ${failedBatches > 0 ? `Encountered errors in ${failedBatches} batches. Check logs for details.` : 'All batches submitted successfully.'}`); } catch (error) { logger.error('Error during message sending process (fetching subscribers or general failure)', { message: error.message, stack: error.stack }); res.status(500).send('Internal Server Error while sending messages.'); } }); // (Start Server function below)This route first checks the admin password (again, stressing this is INSECURE). If valid, it fetches all subscribed numbers from MongoDB. It then iterates through the numbers in batches of 50 and sends the message using
await messagebird.messages.create(params);, leveraging the native Promise returned by recent SDK versions. Detailed logging usingloggeris included for batch success and failure.Test with
curl: Test the/sendendpoint usingcurl(replace placeholders):curl -X POST http://localhost:8080/send \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "message=Hello subscribers! Special offer today only!" \ -d "password=your_secret_password"(Response: A success or error message from the server)
4. Integrate MessageBird SMS API with Node.js (Summary)
You've already integrated MessageBird in the previous steps. Here's a summary of the key MessageBird API integration points:
npm install messagebirdconst messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY);placed early inindex.js..env(MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY) and load viadotenv. Obtain this from your MessageBird Dashboard → Developers → API access. Ensure it's a Live key for actual sending..env(MESSAGEBIRD_ORIGINATOR=YOUR_VMN_OR_SENDER_ID). This must be a number purchased/verified in your MessageBird account or an approved alphanumeric ID.await messagebird.messages.create(params)(for Promise-based handling) ormessagebird.messages.create(params, callback)(for callback style). Theparamsobject must includeoriginator,recipients(an array of numbers), andbody.MESSAGEBIRD_ORIGINATORnumber viaPOSTto your application's/webhookURL. The webhook handler parsesreq.body.originatorandreq.body.payload.5. Implement Error Handling, Logging, and SMS Retry Mechanisms
Production SMS applications need robust error handling and logging.
Logging Strategy: Integrate Winston for structured JSON logging to files (
error.log,combined.log) and optionally to the console in development.index.js.console.*calls withlogger.info,logger.warn, andlogger.error. Log errors with message and stack trace where applicable. Include contextual information (likeoriginator,batchNumber) in log calls.Error Handling:
try...catch: Wrap database operations and MessageBird API calls intry...catchblocks.originator,payload,message,password), returning appropriate HTTP status codes (400, 401).200 OKquickly to MessageBird.Retry Mechanisms (for Sending): MessageBird API calls might fail temporarily. Implement retries only for critical outgoing messages (like the main campaign send), not usually for webhook processing (to avoid duplicate actions).
Simple Manual Retry Example (Illustrative): The following demonstrates a basic retry loop within the
/sendroute's batch processing. For production, consider a more robust library likeasync-retry.// Inside app.post('/send', ...) batch loop (Illustrative – Use a library for robustness) const MAX_RETRIES = 3; const RETRY_DELAY_MS = 1000; // Base delay for (let i = 0; i < recipientNumbers.length; i += batchSize) { const batch = recipientNumbers.slice(i, i + batchSize); const batchNumber = Math.floor(i / batchSize) + 1; const params = { /* ... */ }; let attempt = 0; let batchSuccess = false; while (attempt < MAX_RETRIES && !batchSuccess) { attempt++; try { const response = await messagebird.messages.create(params); batchSuccess = true; // Assume success if no exception logger.info(`Batch ${batchNumber}/${totalBatches}: Send attempt ${attempt} successful. Count: ${batch.length}.`, { batchNumber, totalBatches, attempt, count: batch.length }); successfulSends += batch.length; } catch (batchError) { logger.warn(`Batch ${batchNumber}/${totalBatches}: Send attempt ${attempt} failed.`, { batchNumber, totalBatches, attempt, count: batch.length, message: batchError.message, errors: batchError.errors }); if (attempt >= MAX_RETRIES) { logger.error(`Batch ${batchNumber}/${totalBatches}: Send failed after ${MAX_RETRIES} attempts. Giving up on this batch.`, { batchNumber, totalBatches, maxAttempts: MAX_RETRIES, count: batch.length, message: batchError.message, errors: batchError.errors }); failedBatches++; // Optionally: Log the specific numbers in the failed batch for manual follow-up } else { // Basic exponential backoff (1s, 2s, 4s…) const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); logger.info(`Batch ${batchNumber}/${totalBatches}: Retrying attempt ${attempt+1} after ${delay}ms…`); await new Promise(resolve => setTimeout(resolve, delay)); } } } } // End of batch loopNote: Implement retry logic carefully. Retrying non-idempotent operations can cause issues. Sending SMS is generally safe to retry if the initial attempt failed definitively.
6. Design MongoDB Schema for SMS Subscriber Management
Use MongoDB with a simple schema defined by the documents you insert for SMS subscription tracking.
Collection:
subscribersDocument Structure:
{ "_id": "ObjectId(...)", "number": "+12025550156", "subscribed": true, "subscribedAt": "ISODate(...)", "unsubscribedAt": null }_id: Automatically generated by MongoDB.number: Phone number in E.164 format (Indexed, Unique).subscribed: Boolean indicating current subscription status.subscribedAt: Timestamp of the last subscription action.unsubscribedAt: Timestamp of the last unsubscription action (null if subscribed).Data Access: Handle via the
mongodbdriver'sMongoClient. Set upmongoClient,db, andsubscribersCollectioninindex.js.Key Operations:
subscribersCollection.findOne({ number: ... })(Used in webhook)subscribersCollection.insertOne({ ... })(Used for new subscribers)subscribersCollection.updateOne({ number: ... }, { $set: { ... } })(Used for re-subscribing or unsubscribing)subscribersCollection.find({ subscribed: true }, { projection: { ... } }).toArray()(Used in/send)subscribersCollection.countDocuments({ subscribed: true })(Used in/admin)Indexing: A unique index on the
numberfield (subscribersCollection.createIndex({ number: 1 }, { unique: true })) is crucial for performance and data integrity, ensuring you don't store duplicate numbers. Create this on application start.Migrations: For this simple schema, migrations aren't strictly necessary. For evolving schemas, tools like
migrate-mongocan manage database changes systematically.Sample Data (Manual Insertion via
mongosh):// Connect using mongosh: mongosh "YOUR_MONGODB_URI" // use sms_campaign; // Select the database db.subscribers.insertOne({ number: "+12025550199", // Replace with a test number subscribed: true, subscribedAt: new Date(), unsubscribedAt: null });7. Add Security Features for SMS Marketing Applications
Security is paramount when handling user data and sending SMS messages.
originatorandpayloadexist. Trimpayload. Consider adding E.164 format validation fororiginator.messageis not empty. Validatepassword. Consider adding length limits or sanitization tomessage.express-rate-limit.Implementation: Add configuration in
index.jsbefore routes.// index.js (Add near other middleware) // Rate limiting for the webhook (adjust limits as needed) const webhookLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); app.use('/webhook', webhookLimiter); // Stricter rate limiting for the admin send endpoint const adminLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, // Limit each IP to 5 send attempts per hour message: 'Too many send attempts from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, }); app.use('/send', adminLimiter); app.use('/admin', adminLimiter); // Also limit access to the admin form pagehelmet.app.use(helmet());added early inindex.js./sendis highly insecure. Replace this immediately in a production environment with a proper authentication system (e.g., Passport.js with username/hashed password stored securely, OAuth, JWT)..envanddotenv. Ensure the.envfile is never committed to version control (.gitignore).npm audit,npm update) to patch known vulnerabilities. Use tools like Snyk or Dependabot for automated scanning.8. Deploy Your SMS Campaign Application to Production
Deploy your Node.js SMS marketing application following these steps.
MESSAGEBIRD_API_KEY,MONGODB_URI,PORT, and crucially, replace the insecureADMIN_PASSWORDwith your secure authentication mechanism's configuration. Do not use a.envfile in production; use the hosting provider's mechanism for setting environment variables.NODE_ENV=production: This environment variable enables optimizations in Express and other libraries and disables development-only features (like verbose console logging in your setup).npm install pm2 -g # Install globally pm2 start index.js --name messagebird-sms-app -i max # Start clustered pm2 startup # Configure PM2 to start on system boot pm2 save # Save current process listhttps://your-app-domain.com/webhook). Ensure this URL is accessible from the internet.9. SMS Marketing Compliance and Next Steps
You've successfully built a foundational SMS marketing campaign application using Node.js, Express, MongoDB, and MessageBird API. Users can subscribe and unsubscribe via SMS, and an administrator can broadcast messages to active subscribers.
Key achievements:
SUBSCRIBEandSTOPmessages.Next Steps:
Hello {name}, …) for targeted campaigns.async-retryor implement a background job queue (e.g., BullMQ, Kue) for handling message sending failures more reliably.