This guide provides a step-by-step walkthrough for building a production-ready SMS marketing campaign application using Node.js, Express, and the MessageBird API. You'll learn how to handle subscriptions via SMS keywords, manage subscriber lists in a database, provide an interface for sending bulk messages, and deploy the application securely and reliably.
We'll cover everything from initial project setup and core functionality development to integrating with MessageBird, handling errors, securing your application, and deploying it to production. By the end, you'll have a functional system enabling users to opt-in and opt-out of SMS campaigns via keywords and allowing administrators to broadcast messages to subscribers.
Project Overview and Goals
What We'll Build:
- An Express.js web application that listens for incoming SMS messages via a MessageBird webhook.
- Logic to handle
SUBSCRIBE
andSTOP
keywords to manage user subscriptions. - A MongoDB database to store subscriber phone numbers and their subscription status.
- A simple web interface (password-protected) for administrators to send bulk SMS messages to all active subscribers.
- Integration with the MessageBird API for receiving incoming SMS and sending outgoing confirmation and campaign messages.
Problem Solved:
This application provides businesses with a simple, automated way to manage SMS marketing lists, ensuring compliance with opt-in/opt-out requirements and facilitating direct communication with interested customers via SMS.
Technologies Used:
- Node.js: A JavaScript runtime for building the backend server application.
- Express.js: A minimal and flexible Node.js web application framework used to handle routing and HTTP requests (web interface, webhook).
- MessageBird API & SDK: Used for sending and receiving SMS messages, managing virtual numbers, and configuring webhooks.
- MongoDB: A NoSQL database used to store subscriber information. The official
mongodb
Node.js driver will be used. dotenv
: A module to load environment variables from a.env
file intoprocess.env
.- (Optional) EJS or Handlebars: A templating engine for rendering the simple web interface (we'll use EJS for simplicity in examples).
- (Development)
localtunnel
: A tool to expose your local development server to the internet for testing MessageBird webhooks. Warning:localtunnel
is suitable for development purposes only and is not secure or stable enough for production webhook handling.
System Architecture:
+-----------------+ +----------------------+ +---------------------+ +-------------------+ +-------------+
| User's Phone |----->| MessageBird Platform |----->| Webhook URL |----->| Node.js/Express App |----->| MongoDB |
| (Sends Keyword) | | (VMN, Flow) | | (via localtunnel/ | | (/webhook endpoint) | | (Subscribers)|
+-----------------+ +----------------------+ | public domain) | +-------------------+ +-------------+
^ /|\ | |
| | | |
(Receives Confirmation/ | | | |
Campaign SMS) | | \----------------------/
| /|\ | |
+-----------------+ | +----------------------+ +---------------------+ +-------------------+
| Admin Interface |----- | Node.js/Express App |----->| MessageBird Platform | | (Sends campaign) |
| (Web Browser) | | (/send endpoint) | | (API Call) | +-------------------+
+-----------------+ +---------------------+ +----------------------+
(Note: The ASCII diagram above illustrates the basic flow. A graphical diagram might offer better clarity and maintainability.)
Prerequisites:
- Node.js (v18 or later recommended for consistency with deployment example) and npm (or yarn) installed. Node.js Downloads
- A MessageBird account. Sign up for MessageBird
- A purchased Virtual Mobile Number (VMN) with SMS capabilities from MessageBird.
- Basic familiarity with JavaScript, Node.js, and REST APIs.
- Access to a terminal or command prompt.
- (Optional) Git for version control.
- (Optional) Postman or
curl
for testing API endpoints.
Final Outcome:
A deployed Node.js application capable of managing SMS subscriptions and sending bulk messages via MessageBird, including basic security, logging, and error handling.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it:
mkdir messagebird-sms-campaign cd messagebird-sms-campaign
-
Initialize Node.js Project: Create a
package.json
file to manage project dependencies and scripts:npm init -y
-
Install Dependencies: We need Express for the web server, the MessageBird SDK, the MongoDB driver,
dotenv
for environment variables, andejs
for templating.npm install express messagebird mongodb dotenv ejs basic-auth # Added basic-auth here for Section 3
-
Install Development Dependencies: We'll use
nodemon
for easier development (automatically restarts the server on file changes) andlocaltunnel
for testing webhooks locally.npm install --save-dev nodemon localtunnel
-
Create Project Structure: Set up a basic folder structure for better organization:
mkdir views # For EJS templates touch index.js .env .gitignore
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing them to version control:# .gitignore node_modules/ .env *.log
-
Set up
.env
File: Create placeholders for your MessageBird credentials, originator number, database connection string, and a basic password for the admin interface. Never commit this file to Git.# .env MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY MESSAGEBIRD_ORIGINATOR=YOUR_VIRTUAL_NUMBER_OR_SENDER_ID # e.g., +12005550199 or MarketingApp MONGO_URI=mongodb://localhost:27017/sms_campaign # Or your MongoDB Atlas URI ADMIN_PASSWORD=your_secret_password # Change this immediately! Example only. PORT=8080 # Optional: default port
MESSAGEBIRD_API_KEY
: Your Live API key from the MessageBird Dashboard.MESSAGEBIRD_ORIGINATOR
: The phone number (VMN) or alphanumeric Sender ID messages will come from. Check MessageBird's country restrictions for alphanumeric IDs.MONGO_URI
: Your MongoDB connection string.ADMIN_PASSWORD
: WARNING: This is a highly insecure example password. Change it immediately to a strong, unique password. For production, use robust authentication methods as detailed in Section 7.PORT
: The port your Express app will listen on.
-
Add
npm
Scripts: Update thescripts
section in yourpackage.json
for easier starting and development:// package.json "scripts": { "start": "node index.js", "dev": "nodemon index.js", "tunnel": "lt --port 8080 --subdomain your-unique-sms-webhook" // Replace with a unique subdomain },
npm start
: Runs the application normally.npm run dev
: Runs the application usingnodemon
for development.npm run tunnel
: Startslocaltunnel
to expose port 8080. Make sure to choose a unique subdomain.
2. Implementing Core Functionality (Webhook Handler)
The core logic resides in the webhook handler, which processes incoming SMS messages.
-
Basic Express Setup (
index.js
): Start by setting up Express, loading environment variables, and initializing the MessageBird and MongoDB clients.// index.js require('dotenv').config(); // Load .env variables first const express = require('express'); const { MongoClient } = require('mongodb'); const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY); const auth = require('basic-auth'); // Require basic-auth for Section 3 const ejs = require('ejs'); // Require EJS for Section 3 const app = express(); const port = process.env.PORT || 8080; const mongoUri = process.env.MONGO_URI; const dbName = 'sms_campaign'; // Or extract from MONGO_URI if needed const collectionName = 'subscribers'; let db; // Database connection variable // Middleware to parse URL-encoded bodies (form submissions) and JSON app.use(express.urlencoded({ extended: true })); app.use(express.json()); // Needed for MessageBird webhook (assuming JSON payload) // --- Database Connection --- async function connectDB() { try { const client = new MongoClient(mongoUri); await client.connect(); db = client.db(dbName); console.log(`Successfully connected to MongoDB: ${dbName}`); // Ensure index on phone number for faster lookups await db.collection(collectionName).createIndex({ number: 1 }, { unique: true }); console.log(`Index created/ensured on 'number' field in ${collectionName}`); } catch (err) { console.error(""Failed to connect to MongoDB"", err); process.exit(1); // Exit if DB connection fails } } // --- Webhook Handler --- app.post('/webhook', async (req, res) => { // Check if DB is connected if (!db) { console.error(""Webhook received but database not connected.""); return res.status(500).send(""Internal Server Error: Database not ready.""); } // MessageBird sends webhook data in req.body. // NOTE: Verify the exact payload structure against current MessageBird documentation. // This code assumes { originator: 'sender_number', payload: 'message_text' }. const { originator, payload } = req.body; // Basic validation if (!originator || !payload) { console.warn('Received incomplete webhook payload:', req.body); return res.status(400).send('Bad Request: Missing originator or payload.'); } const number = originator; // Phone number of the sender const text = payload.trim().toLowerCase(); // Message content console.log(`Webhook received from ${number}: ""${text}""`); const subscribersCollection = db.collection(collectionName); try { if (text === 'subscribe') { // Add or update subscriber to subscribed: true const updateResult = await subscribersCollection.findOneAndUpdate( { number: number }, { $set: { subscribed: true, updatedAt: new Date() }, $setOnInsert: { number: number, subscribedAt: new Date() } }, { upsert: true, returnDocument: 'after' } // Upsert: insert if not exists, return the updated doc ); console.log('Subscription processed:', updateResult); // Send confirmation message (use retry version from Section 5 if implemented) sendConfirmation(number, 'Thanks for subscribing! Text STOP to unsubscribe.'); // Example if using retry function: await sendConfirmationWithRetry(number, 'Thanks for subscribing! Text STOP to unsubscribe.'); } else if (text === 'stop') { // Update subscriber to subscribed: false const updateResult = await subscribersCollection.findOneAndUpdate( { number: number }, { $set: { subscribed: false, unsubscribedAt: new Date(), updatedAt: new Date() } }, { returnDocument: 'after' } // Only update if exists ); // Check if a document was found and updated (or found but already unsubscribed) if (updateResult) { console.log('Unsubscription processed:', updateResult); // Send confirmation message (use retry version from Section 5 if implemented) sendConfirmation(number, 'You have unsubscribed. Text SUBSCRIBE to join again.'); // Example if using retry function: await sendConfirmationWithRetry(number, 'You have unsubscribed. Text SUBSCRIBE to join again.'); } else { console.log(`STOP received from non-subscriber: ${number}`); // Optional: Send a message like ""You are not currently subscribed."" // sendConfirmation(number, 'You are not currently subscribed.'); } } else { // Optional: Handle unrecognized keywords console.log(`Unrecognized keyword ""${text}"" from ${number}`); // Optional: Send help message // sendConfirmation(number, 'Unknown command. Text SUBSCRIBE to join or STOP to leave.'); } // Acknowledge receipt to MessageBird res.status(200).send('Webhook processed successfully.'); } catch (error) { console.error(`Error processing webhook for ${number}:`, error); // Don't send error details back, just acknowledge receipt if possible // Or send a 500 if it was a critical processing failure res.status(500).send('Internal Server Error'); } }); // --- Helper Function to Send Confirmation SMS --- // NOTE: Consider replacing this with the sendConfirmationWithRetry function from Section 5 function sendConfirmation(recipient, messageBody) { const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR, recipients: [recipient], body: messageBody, }; messagebird.messages.create(params, (err, response) => { if (err) { console.error(`Error sending confirmation SMS to ${recipient}:`, err); // Implement retry logic if necessary (See Section 5) } else { console.log(`Confirmation SMS sent to ${recipient}:`, response.id); // console.log(response); // Log full response if needed } }); } // --- Admin Interface Routes (Implement in next section) --- // Placeholder routes are defined in Section 3 // --- Start Server --- async function startServer() { await connectDB(); // Wait for DB connection before starting server app.listen(port, () => { console.log(`SMS Campaign App listening on port ${port}`); console.log(`Webhook endpoint available at /webhook`); console.log(`Admin interface available at /admin`); }); } startServer(); // Initialize DB connection and start the server // --- Export app for potential testing (See Section 13) --- // module.exports = app; // Uncomment if separating server start for tests
-
Explanation:
- We initialize Express, MongoDB client, and MessageBird SDK.
- The
connectDB
function establishes the connection to MongoDB and ensures a unique index on thenumber
field for efficiency and data integrity. - The
POST /webhook
route handles incoming messages. - It extracts the sender's number (
originator
) and the message text (payload
), assuming a specific payload structure that should be verified against current MessageBird documentation. - It converts the text to lowercase for case-insensitive keyword matching (
subscribe
,stop
). subscribe
logic: UsesfindOneAndUpdate
withupsert: true
. If the number exists, it setssubscribed: true
. If not, it inserts a new document with the number,subscribed: true
, and timestamps.stop
logic: UsesfindOneAndUpdate
(no upsert). If the number exists (regardless of currentsubscribed
status), it attempts to setsubscribed: false
and recordsunsubscribedAt
. The confirmation is sent if the user was found in the database, even if they were already unsubscribed.- A confirmation SMS is sent using the
sendConfirmation
helper function after processing the keyword. Consider replacing this with the retry version from Section 5. - Basic error handling and logging are included.
- Crucially, the webhook responds with a
200 OK
status to MessageBird to acknowledge receipt. Failure to respond quickly can cause MessageBird to retry the webhook.
3. Building the Admin Interface and Sending Logic
Now, let's create a simple, password-protected web page for administrators to send messages.
-
Install
basic-auth
: (Already done in Section 1, Step 3 if followed sequentially) If you haven't installed it yet:npm install basic-auth
-
Create EJS Template (
views/admin.ejs
): This file will contain the HTML form for sending messages.<!-- views/admin.ejs --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width_ initial-scale=1.0"> <title>SMS Campaign Admin</title> <style> body { font-family: sans-serif; padding: 20px; } label { display: block; margin-bottom: 5px; } textarea { width: 100%; min-height: 100px; margin-bottom: 10px; } button { padding: 10px 15px; cursor: pointer; } .message { padding: 10px; margin-bottom: 15px; border-radius: 4px; } .success { background-color: #e6ffed; border: 1px solid #34d399; color: #065f46; } .error { background-color: #fee2e2; border: 1px solid #f87171; color: #991b1b; } </style> </head> <body> <h1>Send Campaign Message</h1> <p>Total Active Subscribers: <strong><%= subscriberCount %></strong></p> <% if (message) { %> <div class="message <%= message.type %>"><%= message.text %></div> <% } %> <form action="/send" method="POST"> <div> <label for="message">Message Body (Max 160 chars recommended):</label> <textarea id="message" name="message" required maxlength="1000"></textarea> <!-- Maxlength prevents overly long messages --> </div> <button type="submit">Send to Subscribers</button> </form> </body> </html>
-
Implement Admin Routes (
index.js
): Add the authentication middleware and the GET/POST routes for the admin interface within yourindex.js
file.// index.js (add these parts or ensure they exist) // --- Basic Authentication Middleware --- const checkAuth = (req, res, next) => { const credentials = auth(req); const expectedPassword = process.env.ADMIN_PASSWORD; // Loaded from .env // WARNING: This basic comparison is vulnerable to timing attacks. // For production, use a constant-time comparison function (e.g., from crypto module or bcrypt.compare). // See Section 7 for more details. if (!credentials || !credentials.pass || credentials.pass !== expectedPassword) { res.setHeader('WWW-Authenticate', 'Basic realm="Admin Area"'); return res.status(401).send('Authentication required.'); } // Authentication successful return next(); }; // --- Set EJS as the templating engine --- app.set('view engine', 'ejs'); app.set('views', './views'); // Specify the directory for templates // --- Admin Interface Route (GET) --- app.get('/admin', checkAuth, async (req, res) => { if (!db) return res.status(503).send("Service Unavailable: Database not ready."); try { const subscribersCollection = db.collection(collectionName); const count = await subscribersCollection.countDocuments({ subscribed: true }); // Pass null message initially res.render('admin', { subscriberCount: count, message: null }); } catch (error) { console.error("Error fetching subscriber count:", error); res.status(500).send("Error loading admin page."); } }); // --- Send Message Route (POST) --- app.post('/send', checkAuth, async (req, res) => { if (!db) return res.status(503).send("Service Unavailable: Database not ready."); const messageBody = req.body.message; if (!messageBody || messageBody.trim() === '') { return res.status(400).send('Message body cannot be empty.'); // Basic validation } const subscribersCollection = db.collection(collectionName); let subscriberCount = 0; // Recalculate count before sending let messageInfo = null; // To pass feedback to the template try { // Find active subscribers const activeSubscribers = await subscribersCollection.find({ subscribed: true }).project({ number: 1, _id: 0 }).toArray(); subscriberCount = activeSubscribers.length; // Update count if (activeSubscribers.length === 0) { console.log("No active subscribers found to send message to."); messageInfo = { type: 'error', text: 'No active subscribers to send message to.' }; // Re-render admin page with current count and message return res.render('admin', { subscriberCount, message: messageInfo }); } const recipients = activeSubscribers.map(sub => sub.number); console.log(`Attempting to send message to ${recipients.length} recipients.`); // MessageBird API allows up to 50 recipients per call. Batch if necessary. const batchSize = 50; let sentCount = 0; let errorCount = 0; for (let i = 0; i < recipients.length; i += batchSize) { const batch = recipients.slice(i_ i + batchSize); const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR_ recipients: batch_ body: messageBody_ }; // Use Promises for cleaner async handling with MessageBird try { const response = await new Promise((resolve_ reject) => { messagebird.messages.create(params, (err, resp) => { if (err) { reject(err); } else { resolve(resp); } }); }); console.log(`Batch sent (ID: ${response?.id}), count: ${batch.length}`); sentCount += batch.length; // Approximate count, actual delivery depends on MessageBird } catch (batchError) { console.error(`Error sending batch starting at index ${i}:`, batchError); errorCount++; // Decide how to handle batch errors - stop? continue? log? // For now, we log and continue. } } if (errorCount > 0) { messageInfo = { type: 'error', text: `Message sent to approximately ${sentCount} subscribers, but ${errorCount} batches failed. Check logs.` }; } else { messageInfo = { type: 'success', text: `Message successfully queued for ${sentCount} subscribers.` }; } // Render the admin page again with the feedback message and updated count res.render('admin', { subscriberCount, message: messageInfo }); } catch (error) { console.error("Error sending campaign message:", error); // Fetch count again in case of error before rendering try { subscriberCount = await subscribersCollection.countDocuments({ subscribed: true }); } catch (countError) { console.error("Error fetching count after send error:", countError); } messageInfo = { type: 'error', text: 'An unexpected error occurred while sending messages. Check logs.' }; // Render admin page with error message res.render('admin', { subscriberCount, message: messageInfo }); } }); // Ensure startServer() is called after all route definitions // async function startServer() { ... } defined earlier // startServer(); // called earlier
-
Explanation:
- We require
basic-auth
. - The
checkAuth
middleware intercepts requests to protected routes (/admin
,/send
). It checksAuthorization
headers and compares the provided password againstADMIN_PASSWORD
from.env
. Crucially, the code uses a simple!==
comparison which is vulnerable to timing attacks; Section 7 discusses secure alternatives. If auth fails, it sends a401 Unauthorized
. - The
GET /admin
route (protected bycheckAuth
) fetches the count of active subscribers and renders theadmin.ejs
template. - The
POST /send
route (also protected) receives the message body. - It fetches active subscribers (
subscribed: true
) from MongoDB. - It implements batching, splitting recipients into chunks of 50 for separate MessageBird API calls.
- It uses Promises for cleaner handling of asynchronous MessageBird calls within the loop.
- Basic error handling is included for the sending process.
- After sending, it re-renders the
admin.ejs
page with a success/error message and the updated subscriber count.
- We require
4. Integrating with MessageBird (Configuration Details)
This section details how to get your MessageBird credentials and configure the necessary components in their dashboard.
-
Get MessageBird API Key:
- Log in to your MessageBird Dashboard.
- Navigate to the Developers section -> API access tab.
- Copy the Live API key (create one if needed).
- Paste this key into your
.env
file asMESSAGEBIRD_API_KEY
. Keep this key secret.
-
Purchase a Virtual Mobile Number (VMN):
- Go to the Numbers section. Click ""Buy a number"".
- Select country, ensure SMS capability, choose a number, and purchase.
- Copy the purchased number (E.164 format, e.g.,
+12005550199
) and paste it into.env
asMESSAGEBIRD_ORIGINATOR
. Alternatively, use an alphanumeric sender ID (check restrictions) if desired for outgoing messages, but incoming messages still target the VMN.
-
Configure a Flow for Incoming Messages: This connects your VMN to your application's webhook.
- Go to the Numbers section, find your VMN.
- Click the ""Add flow"" icon or go to the ""Flows"" tab for the number.
- Click ""Create Flow"" -> ""Create Custom Flow"".
- Trigger: Choose SMS.
- Name: Give it a descriptive name (e.g., ""SMS Campaign Webhook"").
- Steps:
- The SMS trigger step (linked to your number) should be present.
- Click + below the SMS step. Choose ""Forward to URL"".
- Method: Select POST.
- URL: Enter the public URL of your webhook endpoint.
- For Development: Start
localtunnel
(npm run tunnel
). Copy thehttps://your-unique-sms-webhook.loca.lt
URL and append/webhook
. Full URL:https://your-unique-sms-webhook.loca.lt/webhook
. Rememberlocaltunnel
is not for production. - For Production: Use your deployed application's public domain/IP address followed by
/webhook
(e.g.,https://yourdomain.com/webhook
). Must be HTTPS.
- For Development: Start
- Click Save.
- (Optional but Recommended) Add Contact Step: Click + before ""Forward to URL"". Choose ""Add Contact"". Configure as needed.
- Publish: Click ""Publish changes"" to activate the flow.
Your flow should essentially be: SMS (Trigger) -> [Optional Add Contact] -> Forward to URL (POST to your webhook).
-
Environment Variables Recap:
MESSAGEBIRD_API_KEY
: Your live API key. Used by the SDK for authentication.MESSAGEBIRD_ORIGINATOR
: Your VMN or alphanumeric sender ID. Used as the 'From' for outgoing messages.- Ensure these are correctly set in
.env
(local) and configured securely in production.
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
-
Consistent Error Handling:
- Use
try...catch
around asynchronous operations. - Log errors clearly with context.
- Respond appropriately to HTTP requests (
500
for server errors,400
for bad input). Avoid leaking sensitive details. - In
/webhook
, always try to send200 OK
to MessageBird upon receiving the request, even if internal processing fails (log the failure). Send400
for malformed requests.
- Use
-
Logging:
- Use
console.log
/console.error
for simplicity here. For production, use structured loggers like Winston or Pino. - Log key events: Server start, DB connection, webhook receipt, subscribe/unsubscribe actions, admin sends, MessageBird API results, errors.
- Example Winston setup (install
winston
):
// Example: Setting up Winston (replace console.log/error) const winston = require('winston'); const logger = winston.createLogger({ /* ... Winston config ... */ }); // Replace console.log('Message') with logger.info('Message') // Replace console.error('Error', err) with logger.error('Error message', { error: err.message, stack: err.stack })
(See original article section for full Winston example config)
- Use
-
Retry Mechanisms (MessageBird API Calls):
- Network issues or API glitches can cause failures. Implement retries for critical sends (confirmations, campaigns).
- Use exponential backoff (wait 1s, 2s, 4s...). Limit retries (3-5 attempts).
- Action Required: Replace the original
sendConfirmation
function inindex.js
with the improvedsendConfirmationWithRetry
version below, or modify the original to include this logic.
// index.js - Replace the original sendConfirmation with this: async function sendConfirmationWithRetry(recipient, messageBody, maxRetries = 3, attempt = 1) { const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR, recipients: [recipient], body: messageBody, }; try { const response = await new Promise((resolve, reject) => { messagebird.messages.create(params, (err, resp) => { if (err) { reject(err); } else { resolve(resp); } }); }); // Assuming 'logger' is defined (e.g., from Winston setup or use console.log) console.log(`Confirmation SMS sent to ${recipient} (Attempt ${attempt})`, { messageId: response?.id }); return response; // Success } catch (err) { console.error(`Error sending confirmation SMS to ${recipient} (Attempt ${attempt})`, { error: err }); if (attempt < maxRetries) { const delay = Math.pow(2_ attempt -1) * 1000; // 1s_ 2s_ 4s... console.warn(`Retrying confirmation SMS to ${recipient} in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); // Recursive call for retry return sendConfirmationWithRetry(recipient, messageBody, maxRetries, attempt + 1); } else { console.error(`Max retries reached for sending confirmation to ${recipient}. Giving up.`); throw err; // Re-throw the error after max retries } } }