code examples
code examples
How to Build SMS Marketing Campaigns with MessageBird, Node.js & Fastify
Learn how to build production-ready SMS marketing campaigns using MessageBird API, Node.js, and Fastify. Complete tutorial covering webhook handling, subscriber management, E.164 validation, security best practices, and bulk messaging for SMS marketing automation.
Build SMS Marketing Campaigns with MessageBird, Node.js, and Fastify
Meta Description: Learn to build production-ready SMS marketing campaigns using MessageBird API, Node.js, and Fastify. Complete tutorial with webhook handling, subscriber management, security, and bulk messaging.
This comprehensive guide provides a step-by-step walkthrough for building a production-ready SMS marketing campaign management system using Node.js, the Fastify framework, and the MessageBird API. You'll implement project setup, core logic, security, deployment, and testing for a fully functional SMS subscription service.
By the end of this tutorial, you'll have a functional application that can:
- Handle incoming SMS messages for subscription opt-ins (
JOIN) and opt-outs (STOP). - Store subscriber information securely in a database.
- Provide an API endpoint to send marketing messages to all subscribed users.
- Validate incoming webhooks from MessageBird for security.
Target Audience: Developers familiar with Node.js and basic web framework concepts. Prior experience with Fastify or MessageBird is helpful but not required.
Technologies Used:
- Node.js: The JavaScript runtime environment. (v20+ recommended for Fastify v5)
- Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer experience. (v5 current as of 2024, v4 stable). Fastify documentation
- MessageBird: A communication platform API used for sending and receiving SMS messages and managing virtual numbers. Note: The official Node.js SDK (
messagebirdnpm package v4.0.1) was last updated in 2021–2022 and may not receive frequent updates. Verify current support status at MessageBird Developers. MessageBird API enforces rate limits: 500 req/s for POST (SMS sending), 50 req/s for GET/PATCH/DELETE operations (MessageBird SMS API documentation). better-sqlite3: A simple, file-based SQL database, suitable for this example. For production, consider PostgreSQL or MySQL.dotenv: For managing environment variables.@fastify/rate-limit: Plugin for basic API rate limiting. Documentation@fastify/auth: Plugin for authentication strategy integration.
System Architecture:
+---------+ +--------------+ +---------------+ +--------------+ +------------+
| User |----->| Mobile Device|----->| MessageBird |----->| Fastify App |<-----| Admin User |
| (SMS) | | (SMS: JOIN/ | | (Virtual Num) | | (Webhook) | | (API Call)|
+---------+ | STOP) | +-------+-------+ +-------+------+ +-----+------+
+--------------+ | | |
| SMS Sent | Send Campaign |
v v |
+-----+------+ +-----+------+ |
| Fastify App|-------->| MessageBird|-----------+
| (Send Reply| | (Send SMS) |
| /Campaign)| +------------+
+-----+------+
|
v
+-----+------+
| Database |
| (SQLite) |
+------------+Prerequisites:
- Node.js and npm (or yarn) installed. Install Node.js & npm – Node.js v20+ recommended for Fastify v5 compatibility.
- A MessageBird account. Sign up for MessageBird
- A text editor or IDE (like VS Code).
- A tool for making API requests (like
curlor Postman). ngrokor a similar tunneling service: Essential for exposing your local development server to MessageBird's webhooks. For production, you'll need a stable public URL for your deployed application (see Section 10). Install ngrok
Final Outcome:
A robust Node.js application capable of managing SMS subscriptions and sending broadcasts, ready for further extension and deployment.
How to Set Up Your Node.js Project with Fastify
Initialize your Node.js project and install the necessary dependencies.
1.1 Create Project Directory & Initialize:
Open your terminal and run these commands:
mkdir fastify-messagebird-sms
cd fastify-messagebird-sms
npm init -yThis creates a new directory, navigates into it, and initializes a package.json file with default settings.
1.2 Enable ES Modules:
Use ES Modules (import/export syntax). Open package.json and add this top-level key:
// package.json
{
"name": "fastify-messagebird-sms",
"version": "1.0.0",
"description": "",
"main": "app.js",
"type": "module", // <-- Add this line
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js", // Requires Node.js 18.11+ (Node.js 20+ recommended)
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}Why ES Modules? It's the standard module system for JavaScript and offers benefits like static analysis and better tooling support compared to CommonJS (require).
1.3 Install Dependencies:
Install Fastify, the MessageBird SDK, dotenv for environment variables, better-sqlite3 for the database, @fastify/rate-limit for protection, and @fastify/auth for authentication handling.
npm install fastify messagebird dotenv better-sqlite3 @fastify/rate-limit @fastify/authfastify: The core web framework.messagebird: The official Node.js SDK for interacting with the MessageBird API.dotenv: Loads environment variables from a.envfile intoprocess.env. Essential for managing sensitive credentials.better-sqlite3: A high-performance SQLite driver for Node.js. Chosen for simplicity in this guide.@fastify/rate-limit: A plugin to protect API endpoints from abuse.@fastify/auth: A plugin to handle various authentication strategies.
1.4 Create Project Structure:
Organize the project for clarity and maintainability:
mkdir routes plugins db utils
touch app.js .env .gitignore db/schema.sql db/database.js routes/webhook.js routes/campaign.js plugins/messagebird.js plugins/db.js plugins/auth.js plugins/verify-messagebird.js utils/phoneUtils.jsapp.js: The main application entry point..env: Stores environment variables (API keys, secrets). Never commit this file..gitignore: Specifies intentionally untracked files that Git should ignore.db/: Contains database-related files (schema, connection logic).routes/: Defines the application's API endpoints.plugins/: Holds reusable Fastify plugins (like DB connection, MessageBird client setup, authentication).utils/: Contains utility functions (like phone number normalization).
1.5 Configure .gitignore:
Add this content to your .gitignore file to prevent sensitive information and generated files from being committed:
# .gitignore
node_modules
.env
*.sqlite
*.sqlite-journal
npm-debug.log*
yarn-debug.log*
yarn-error.log*1.6 Set Up Environment Variables (.env):
Create a .env file in the root of your project. Populate it with actual values in the next steps.
# .env
# MessageBird Credentials
MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_LIVE_API_KEY
MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_MESSAGEBIRD_WEBHOOK_SIGNING_KEY
MESSAGEBIRD_ORIGINATOR=YOUR_MESSAGEBIRD_VIRTUAL_NUMBER_OR_ALPHANUMERIC
# Application Security
API_SECRET_KEY=GENERATE_A_STRONG_RANDOM_SECRET_KEY_HERE
# Database
DATABASE_PATH=./db/subscriptions.sqlite
# Server Configuration (Optional – Fastify defaults work well)
# HOST=0.0.0.0
# PORT=3000MESSAGEBIRD_API_KEY: Your live API key from the MessageBird dashboard.MESSAGEBIRD_WEBHOOK_SIGNING_KEY: The key used to verify incoming webhooks. Found in MessageBird webhook settings.MESSAGEBIRD_ORIGINATOR: The MessageBird virtual number (e.g.,+12025550142) or approved Alphanumeric Sender ID (e.g.,MyCompany) used to send messages.API_SECRET_KEY: A secret key you generate to protect the campaign sending endpoint. Use a strong, random string.DATABASE_PATH: The file path for the SQLite database.
How to Integrate MessageBird SMS API with Node.js
Configure the connection to MessageBird and set up the necessary components on their platform.
2.1 Get MessageBird Credentials:
- API Key:
- Log in to your MessageBird Dashboard.
- Navigate to Developers > API access.
- Click Show key next to your LIVE API KEY. Copy this value.
- Paste the key into your
.envfile asMESSAGEBIRD_API_KEY.
- Virtual Number (Originator):
- Navigate to Numbers.
- If you don't have a number, purchase one capable of sending/receiving SMS in your desired region.
- Copy the full number (including the
+and country code). - Paste it into your
.envfile asMESSAGEBIRD_ORIGINATOR. (Alternatively, use an approved Alphanumeric Sender ID if applicable).
- Webhook Signing Key: You'll get this when configuring the webhook later (Section 4.3). Leave
MESSAGEBIRD_WEBHOOK_SIGNING_KEYblank for now or use a placeholder.
2.2 Initialize MessageBird Client Plugin:
Create a Fastify plugin to initialize and share the MessageBird client instance.
// plugins/messagebird.js
import fp from 'fastify-plugin';
import { messagebird } from 'messagebird';
async function messagebirdPlugin(fastify, options) {
const { apiKey } = options;
if (!apiKey) {
throw new Error('MessageBird API Key is required');
}
const mbClient = messagebird(apiKey);
// Decorate fastify instance with the client
fastify.decorate('messagebird', mbClient);
fastify.log.info('MessageBird client initialized');
}
export default fp(messagebirdPlugin, {
name: 'messagebird',
dependencies: [], // No dependencies for this plugin itself
});fastify-plugin: Used to prevent Fastify from creating separate encapsulation contexts for the plugin, allowingfastify.decorateto addmessagebirdto the global Fastify instance.fastify.decorate: Makes the initializedmbClientavailable throughout your application viafastify.messagebird.
Creating a Database Schema for SMS Subscriber Management
Use better-sqlite3 for storing subscriber phone numbers and their status.
3.1 Define Database Schema:
Create the SQL schema definition:
-- db/schema.sql
CREATE TABLE IF NOT EXISTS subscriptions (
phone_number TEXT PRIMARY KEY NOT NULL, -- E.164 format recommended
subscribed INTEGER DEFAULT 0, -- 1 for true, 0 for false
subscribed_at DATETIME,
unsubscribed_at DATETIME
);
-- Optional: Add an index for faster lookups if the table grows large
-- CREATE INDEX IF NOT EXISTS idx_subscribed ON subscriptions (subscribed);phone_number: Stores the subscriber's number, acting as the unique identifier. Storing in E.164 format (e.g.,+14155552671) is highly recommended for consistency. ITU-T Recommendation E.164 defines the international public telecommunication numbering plan with a maximum of 15 digits after the '+' sign.subscribed: A simple flag (integer 0 or 1) to indicate active subscription status.- Timestamps: Track when users subscribe and unsubscribe.
3.2 Implement Database Connection Plugin:
Create a plugin to manage the SQLite database connection and provide helper functions:
// db/database.js
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
// Helper function to ensure the directory exists
function ensureDirectoryExistence(filePath) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
// --- Database Initialization ---
let db;
export function initDb(dbPath) {
ensureDirectoryExistence(dbPath); // Make sure the db directory exists
db = new Database(dbPath); // { verbose: console.log } // Enable verbose logging for debugging SQL
db.pragma('journal_mode = WAL'); // Recommended for better concurrency
// Read and execute schema file
try {
const schema = fs.readFileSync(path.join(path.dirname(dbPath), 'schema.sql'), 'utf8');
db.exec(schema);
console.log('Database schema loaded successfully.');
} catch (err) {
console.error('Error loading database schema:', err);
throw err; // Rethrow to prevent application start if schema fails
}
}
// --- Data Access Functions ---
// Add or update a subscriber, setting their status to subscribed (1)
export function addSubscriber(phoneNumber) {
const stmt = db.prepare(`
INSERT INTO subscriptions (phone_number, subscribed, subscribed_at, unsubscribed_at)
VALUES (?, 1, datetime('now'), NULL)
ON CONFLICT(phone_number) DO UPDATE SET
subscribed = 1,
subscribed_at = datetime('now'),
unsubscribed_at = NULL;
`);
return stmt.run(phoneNumber);
}
// Mark a subscriber as unsubscribed (0)
export function removeSubscriber(phoneNumber) {
const stmt = db.prepare(`
UPDATE subscriptions
SET subscribed = 0, unsubscribed_at = datetime('now')
WHERE phone_number = ?;
`);
return stmt.run(phoneNumber);
}
// Check if a phone number is currently subscribed
export function isSubscribed(phoneNumber) {
const stmt = db.prepare('SELECT subscribed FROM subscriptions WHERE phone_number = ?');
const result = stmt.get(phoneNumber);
return result?.subscribed === 1;
}
// Get all currently subscribed phone numbers
export function getAllSubscribers() {
const stmt = db.prepare('SELECT phone_number FROM subscriptions WHERE subscribed = 1');
return stmt.all().map(row => row.phone_number);
}
// Close the database connection (important for graceful shutdown)
export function closeDb() {
if (db) {
db.close();
console.log('Database connection closed.');
}
}initDb: Connects to the SQLite file and executes the schema definition.addSubscriber: Inserts a new number or updates an existing one to be subscribed. UsesON CONFLICT…DO UPDATE(UPSERT) for efficiency.removeSubscriber: Sets thesubscribedflag to 0 for a given number.isSubscribed: Checks the subscription status.getAllSubscribers: Retrieves a list of all active subscribers' phone numbers.closeDb: Closes the database connection.
3.3 Create the Fastify DB Plugin:
Wrap the database initialization in a Fastify plugin:
// plugins/db.js
import fp from 'fastify-plugin';
import { initDb, closeDb, addSubscriber, removeSubscriber, isSubscribed, getAllSubscribers } from '../db/database.js';
async function dbPlugin(fastify, options) {
const { dbPath } = options;
if (!dbPath) {
throw new Error('Database path (dbPath) is required');
}
try {
initDb(dbPath);
fastify.log.info(`Database initialized at ${dbPath}`);
// Decorate fastify with DB utility functions
fastify.decorate('db', {
addSubscriber,
removeSubscriber,
isSubscribed,
getAllSubscribers,
});
// Add hook to close DB connection on server shutdown
fastify.addHook('onClose', (instance, done) => {
closeDb();
done();
});
} catch (err) {
fastify.log.error('Failed to initialize database:', err);
// Propagate the error to prevent the server from starting incorrectly
throw err;
}
}
export default fp(dbPlugin, {
name: 'db',
dependencies: [], // No dependencies for this plugin itself
});- This plugin takes the
dbPathfrom options, callsinitDb, decoratesfastifywith the data access functions underfastify.db, and ensurescloseDbis called when the server shuts down using theonClosehook.
How to Handle SMS Webhooks from MessageBird
This route receives incoming SMS messages from MessageBird, parses them for keywords (JOIN, STOP), updates the database, and sends a confirmation reply.
4.1 Create the Webhook Route:
// routes/webhook.js
import { normalizePhoneNumber, isValidE164 } from '../utils/phoneUtils.js'; // We'll create this util soon
const webhookSchema = {
// Basic validation: ensure required fields from MessageBird exist
// For production, use a more robust JSON Schema validator
body: {
type: 'object',
required: ['originator', 'payload'],
properties: {
originator: { type: 'string' }, // Sender's phone number
payload: { type: 'string' }, // SMS message content
// Add other fields you might need from the webhook payload
},
},
};
export default async function webhookRoutes(fastify, options) {
// This route requires the verifyMessageBird plugin (added later)
const routeOptions = {
schema: webhookSchema,
// Apply the verification middleware specifically to this route using fastify-auth
preHandler: fastify.auth([fastify.verifyMessageBird]),
};
fastify.post('/messagebird', routeOptions, async (request, reply) => {
const { originator, payload } = request.body;
const messageContent = payload.trim().toUpperCase(); // Normalize keyword check
let replyMessage = '';
// Attempt to normalize the phone number
let normalizedNumber;
try {
// Ensure E.164 format for consistent storage and use
normalizedNumber = normalizePhoneNumber(originator);
if (!normalizedNumber || !isValidE164(normalizedNumber)) {
fastify.log.warn(`Received webhook from invalid or non-normalizable originator format: ${originator}`);
// Don't reply to potentially spoofed/malformed numbers. Acknowledge receipt.
return reply.code(200).send({ message: 'Originator format invalid, processing skipped.' });
}
} catch (error) {
fastify.log.error({ err: error, originator }, 'Error normalizing phone number');
// Acknowledge receipt but indicate an error occurred.
return reply.code(200).send({ message: 'Error processing originator number.' });
}
try {
if (messageContent === 'JOIN') {
fastify.db.addSubscriber(normalizedNumber);
fastify.log.info(`Subscriber added: ${normalizedNumber}`);
replyMessage = 'Thanks for subscribing! Reply STOP to unsubscribe.';
} else if (messageContent === 'STOP') {
fastify.db.removeSubscriber(normalizedNumber);
fastify.log.info(`Subscriber removed: ${normalizedNumber}`);
replyMessage = 'You have been unsubscribed.';
} else {
// Optional: Handle unrecognized messages
fastify.log.info(`Received unrecognized message from ${normalizedNumber}: ${payload}`);
// replyMessage = 'Sorry, I only understand JOIN or STOP.';
// Decide if you want to reply to unrecognized messages. Often it's better not to.
// If no replyMessage is set, we won't send an SMS back.
}
// Send confirmation SMS if needed
if (replyMessage) {
await fastify.messagebird.messages.create({
originator: options.messagebirdOriginator, // Your MessageBird number/sender ID
recipients: [normalizedNumber],
body: replyMessage,
});
fastify.log.info(`Sent confirmation to ${normalizedNumber}: ${replyMessage}`);
}
// Always reply to MessageBird webhook with a 2xx status code quickly
// to acknowledge receipt and prevent retries.
reply.code(200).send({ message: 'Webhook processed successfully' });
} catch (error) {
fastify.log.error({ err: error, originator: normalizedNumber }, 'Error processing webhook');
// Don't reveal internal errors in the response to MessageBird.
// A 200 OK is still sent to MessageBird to prevent retries for potentially
// non-recoverable application errors (like DB errors). Log the error for investigation.
reply.code(200).send({ message: 'Internal processing error occurred.' });
}
});
}Key Points:
- Schema Validation: Basic validation ensures
originatorandpayloadexist. Use@fastify/sensibleorfastify-type-provider-zodfor more complex validation in production. - Keyword Handling: Converts the message to uppercase and checks for
JOINorSTOP. - Database Interaction: Calls the
fastify.dbfunctions to update subscription status. - Phone Number Normalization: Crucial for consistency. Uses utility functions (
normalizePhoneNumber,isValidE164) created next. The implementation is basic; stronger validation is recommended for production. - Confirmation Reply: Uses
fastify.messagebird.messages.createto send an SMS back to the user. Theoriginatorhere is your MessageBird number/sender ID. - Error Handling: Logs errors but still returns a
200 OKto MessageBird. This acknowledges receipt and prevents MessageBird from retrying potentially failing requests indefinitely. Monitor logs for actual errors. preHandler: Specifies that this route needs authentication/verification usingfastify.authand a verification function (verifyMessageBird) created as a plugin. Note the route path is/messagebird.
4.2 Implementing Phone Number Validation with E.164 Format:
Create a utility file for handling phone numbers:
// utils/phoneUtils.js
/**
* Basic normalization: Attempts to convert a phone number string towards E.164 format.
* Removes non-digit characters except a leading '+'.
*
* **E.164 Standard:** ITU-T Recommendation E.164 defines the international public
* telecommunication numbering plan. E.164 numbers contain a maximum of 15 digits
* (after the '+' sign) and follow the format: +[country code][subscriber number].
*
* WARNING: This normalization is extremely basic and likely insufficient for many
* real-world scenarios. It makes naive assumptions and doesn't validate number
* structure or country codes. **Strongly consider using a robust library like
* `libphonenumber-js` for production environments** to handle diverse
* international formats correctly. See: https://www.npmjs.com/package/libphonenumber-js
*
* @param {string | null | undefined} phoneNumber The phone number string to normalize.
* @returns {string | null} The normalized number (e.g., '+14155552671') or null if input is invalid/empty or normalization fails basic checks.
*/
export function normalizePhoneNumber(phoneNumber) {
if (!phoneNumber) return null;
let normalized = String(phoneNumber).trim().replace(/[^\d+]/g, ''); // Remove invalid chars
if (normalized.startsWith('+')) {
// Keep the leading '+', remove any others that might have slipped through regex
normalized = '+' + normalized.substring(1).replace(/\+/g, '');
} else {
// Very naive: assume '+' is missing if it doesn't start with it.
// This is often WRONG for international numbers not already in E.164.
// normalized = '+' + normalized; // Commented out due to high risk of incorrectness
// For this basic example, we'll only accept numbers that *already* start with '+'
// or are purely digits (which might be local format – still risky).
// A better approach requires a library. If it doesn't start with +, return null for stricter E.164.
return null; // Require numbers to include the '+' prefix from the source.
}
// Final basic check: Must start with '+' and contain only digits afterward.
if (!/^\+\d+$/.test(normalized)) {
console.warn(`Could not normalize "${phoneNumber}" to a basic E.164 format. Result: "${normalized}"`);
// Return null if strict format is required downstream
return null;
}
return normalized;
}
/**
* Basic E.164 format check: Starts with '+' followed by 1 to 15 digits.
* Validates against the ITU-T E.164 standard which specifies a maximum
* of 15 digits after the '+' sign.
* Does NOT validate country code validity or exact length requirements per country.
*
* @param {string | null | undefined} phoneNumber The phone number string to check.
* @returns {boolean} True if the format looks like a basic E.164 number, false otherwise.
*/
export function isValidE164(phoneNumber) {
if (!phoneNumber) return false;
// E.164 standard: maximum 15 digits after the '+' sign
return /^\+\d{1,15}$/.test(phoneNumber);
}Important Note: The normalizePhoneNumber function provided here is intentionally basic and carries significant limitations. For any production system, using a dedicated library like libphonenumber-js is strongly recommended for accurate parsing, validation, and formatting of international phone numbers according to the E.164 standard.
4.3 How to Configure MessageBird Webhook Settings:
Tell MessageBird where to send incoming SMS messages.
-
Start
ngrok: Expose your local development server to the internet. If your app runs on port 3000:bashngrok http 3000ngrokwill give you a public HTTPS URL (e.g.,https://<unique-id>.ngrok-free.app). Copy this URL. -
Configure Flow Builder or Number Settings:
- Using Flow Builder (Recommended):
- Go to Flow Builder in the MessageBird Dashboard.
- Create a new flow or edit an existing one.
- Add a Webhook / Call HTTP endpoint step.
- Set the URL to your
ngrokURL followed by the webhook path:https://<unique-id>.ngrok-free.app/messagebird. - Set the Method to
POST. - Go to the Response tab within the step configuration.
- Enable the Sign requests option. MessageBird will generate a Signing Key. Copy this key immediately.
- Paste the Signing Key into your
.envfile asMESSAGEBIRD_WEBHOOK_SIGNING_KEY. - Save and publish the flow.
- Go to Numbers, select your virtual number, and attach this flow to the number under the SMS settings.
- Using Number Settings (Simpler, Less Flexible):
- Go to Numbers, select your virtual number.
- Scroll down to the Incoming SMS section or similar (interface might vary).
- Look for a Webhook or Forward to URL option.
- Enter your
ngrokURL + path:https://<unique-id>.ngrok-free.app/messagebird. - Find the webhook signing settings (might be under Developer settings or advanced options for the number). Enable signing and copy the generated Signing Key into your
.env.
- Using Flow Builder (Recommended):
Important: Every time you restart ngrok, you get a new public URL. You must update the webhook URL in your MessageBird Flow/Number settings accordingly. For production, use your server's permanent public URL.
Securing Your SMS Application with Webhook Verification
Security is paramount when dealing with external webhooks and sending bulk messages.
5.1 How to Verify MessageBird Webhook Signatures:
MessageBird signs its webhook requests using HMAC-SHA256 so you can verify they genuinely came from MessageBird. The signature is generated from the request URL and request body and is signed using your signing key (MessageBird webhook signature documentation). Ensure you installed @fastify/auth (npm install @fastify/auth).
Create a plugin for this verification logic:
// plugins/verify-messagebird.js
import fp from 'fastify-plugin';
import crypto from 'crypto';
async function verifyMessageBirdPlugin(fastify, options) {
const { webhookSigningKey } = options;
if (!webhookSigningKey) {
throw new Error('MessageBird Webhook Signing Key is required for verification');
}
// Verification function to be used as a preHandler hook via fastify.auth
async function verifyRequest(request, reply) {
const signature = request.headers['messagebird-signature'];
const timestamp = request.headers['messagebird-request-timestamp'];
// MessageBird signs the *raw* request body.
// Accessing request.rawBody requires a contentTypeParser that stores it,
// like the one added in app.js. If rawBody is not available, verification will fail.
const body = request.rawBody; // Relies on the custom JSON parser in app.js
if (!signature || !timestamp || body === undefined || body === null) {
fastify.log.warn('Webhook verification failed: Missing signature, timestamp, or raw body');
// Use 400 Bad Request as the required elements for verification are missing
reply.code(400).send({ error: 'Missing signature headers or body for verification' });
return; // Stop processing
}
// 1. Prepare the signed payload string (timestamp + '.' + rawBody)
const signedPayload = `${timestamp}.${body}`;
// 2. Calculate the expected signature (HMAC-SHA256)
let expectedSignature;
try {
expectedSignature = crypto
.createHmac('sha256', webhookSigningKey)
.update(signedPayload)
.digest('hex');
} catch (error) {
fastify.log.error({ err: error }, 'Error calculating HMAC signature, check signing key.');
reply.code(500).send({ error: 'Internal server error during signature calculation' });
return;
}
// 3. Compare signatures using timing-safe comparison
try {
// Ensure both buffers have the same byte length for timingSafeEqual
const sigBuffer = Buffer.from(signature, 'hex');
const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
if (sigBuffer.length !== expectedSigBuffer.length) {
fastify.log.warn('Webhook verification failed: Signature length mismatch.');
reply.code(403).send({ error: 'Invalid signature' });
return;
}
const signaturesMatch = crypto.timingSafeEqual(sigBuffer, expectedSigBuffer);
if (!signaturesMatch) {
fastify.log.warn('Webhook verification failed: Invalid signature');
reply.code(403).send({ error: 'Invalid signature' });
return; // Stop processing
}
} catch (error) {
// Handles cases like invalid hex strings in signature or comparison errors
fastify.log.error({ err: error }, 'Error during signature comparison');
// Indicate bad request data if comparison fails due to format issues
reply.code(400).send({ error: 'Invalid signature format or comparison error' });
return; // Stop processing
}
// Optional but Recommended: Check timestamp validity (e.g., within 5 minutes)
const requestTimeMs = Date.parse(timestamp); // Parses ISO 8601 string
if (isNaN(requestTimeMs)) {
fastify.log.warn(`Webhook verification failed: Invalid timestamp format. Timestamp: ${timestamp}`);
reply.code(400).send({ error: 'Invalid request timestamp format' });
return;
}
const nowMs = Date.now();
const fiveMinutesMs = 5 * 60 * 1000;
if (Math.abs(nowMs - requestTimeMs) > fiveMinutesMs) {
fastify.log.warn(`Webhook verification failed: Timestamp out of tolerance. Timestamp: ${timestamp}, Now: ${new Date(nowMs).toISOString()}`);
reply.code(408).send({ error: 'Request timestamp out of tolerance (replay attack?)' });
return; // Stop processing
}
fastify.log.info('MessageBird webhook signature verified successfully');
// If verification passes, fastify-auth allows continuation to the route handler
}
// Decorate fastify with the verification function so it can be used in routes via fastify.auth
// We rely on @fastify/auth being registered globally in app.js
if (!fastify.auth) {
// This check ensures @fastify/auth is available. It should be registered *before* this plugin.
throw new Error('@fastify/auth plugin must be registered before verify-messagebird plugin');
}
fastify.decorate('verifyMessageBird', verifyRequest);
}
export default fp(verifyMessageBirdPlugin, {
name: 'verify-messagebird',
dependencies: ['@fastify/auth'], // Explicitly declare dependency on @fastify/auth
});- Dependencies: Relies on
@fastify/authbeing registered first (handled inapp.js). request.rawBody: Crucially relies on the custom content type parser defined inapp.jsto access the raw, unparsed request body. This is essential because the signature is calculated over the raw bytes, not the parsed JSON.- HMAC-SHA256: Calculates the expected signature using the timestamp, raw body, and your signing key.
crypto.timingSafeEqual: Used for secure comparison of signatures to prevent timing attacks. Requires buffers of equal length.- Timestamp Check: Prevents replay attacks by ensuring the request isn't too old or from the future (allowing for slight clock skew of up to 5 minutes).
- Decoration: Makes
fastify.verifyMessageBirdavailable. It's intended to be used withinfastify.authin route options.
5.2 Implementing API Key Authentication for Campaign Endpoints:
Create a simple API key authentication plugin:
// plugins/auth.js
import fp from 'fastify-plugin';
import crypto from 'crypto'; // Need crypto for timingSafeEqual
async function authPlugin(fastify, options) {
const { apiSecretKey } = options;
if (!apiSecretKey) {
throw new Error('API Secret Key is required for authentication');
}
// Authentication function to be used with fastify.auth
async function verifyApiKey(request, reply) {
const providedKey = request.headers['x-api-key'];
if (!providedKey) {
fastify.log.warn('API Key verification failed: Missing x-api-key header');
reply.code(401).send({ error: 'Missing API Key' });
return; // Stop processing
}
// Use timing-safe comparison to prevent timing attacks
try {
const keyBuffer = Buffer.from(providedKey);
const secretBuffer = Buffer.from(apiSecretKey);
// Important: timingSafeEqual requires buffers of the same length.
if (keyBuffer.length !== secretBuffer.length) {
fastify.log.warn('API Key verification failed: Key length mismatch');
// Still perform a comparison to obscure length differences, but know it will fail.
// Use a dummy buffer of the same length as the expected key for the comparison.
// This prevents leaking length info via timing.
crypto.timingSafeEqual(secretBuffer, secretBuffer); // Compare secret against itself
reply.code(403).send({ error: 'Invalid API Key' });
return; // Stop processing
}
const keysMatch = crypto.timingSafeEqual(keyBuffer, secretBuffer);
if (!keysMatch) {
fastify.log.warn('API Key verification failed: Invalid key');
reply.code(403).send({ error: 'Invalid API Key' });
return; // Stop processing
}
fastify.log.info('API Key verified successfully');
// If verification passes, fastify-auth allows continuation
} catch (error) {
fastify.log.error({ err: error }, 'Error during API Key comparison');
reply.code(500).send({ error: 'Internal server error during authentication' });
return; // Stop processing
}
}
// Decorate fastify with the verification function
if (!fastify.auth) {
throw new Error('@fastify/auth plugin must be registered before the auth plugin');
}
fastify.decorate('verifyApiKey', verifyApiKey);
}
export default fp(authPlugin, {
name: 'apiKeyAuth',
dependencies: ['@fastify/auth'], // Depends on @fastify/auth
});- Timing-Safe Comparison: Uses
crypto.timingSafeEqualto prevent timing attacks that could leak information about the secret key. - Length Check: Validates that the provided key has the same length as the expected key before comparison, performing a dummy comparison to prevent timing leaks.
- Decoration: Makes
fastify.verifyApiKeyavailable for use withfastify.authin route protection.
Building the Campaign Send Endpoint with Bulk SMS
Create an API endpoint for administrators to send marketing campaigns to all subscribed users.
6.1 Create Campaign Route:
// routes/campaign.js
const campaignSchema = {
body: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', minLength: 1, maxLength: 1600 }, // SMS length limit
},
},
};
export default async function campaignRoutes(fastify, options) {
// Protected route - requires API key authentication
const routeOptions = {
schema: campaignSchema,
preHandler: fastify.auth([fastify.verifyApiKey]),
};
fastify.post('/campaign/send', routeOptions, async (request, reply) => {
const { message } = request.body;
try {
// Get all active subscribers
const subscribers = fastify.db.getAllSubscribers();
if (subscribers.length === 0) {
return reply.code(200).send({
success: true,
message: 'No active subscribers',
sent: 0,
});
}
fastify.log.info(`Sending campaign to ${subscribers.length} subscribers`);
// MessageBird allows up to 50 recipients per API call
const batchSize = 50;
const batches = [];
for (let i = 0; i < subscribers.length; i += batchSize) {
batches.push(subscribers.slice(i, i + batchSize));
}
let successCount = 0;
let failCount = 0;
// Send to each batch
for (const [index, batch] of batches.entries()) {
try {
await fastify.messagebird.messages.create({
originator: options.messagebirdOriginator,
recipients: batch,
body: message,
});
successCount += batch.length;
fastify.log.info(`Batch ${index + 1}/${batches.length} sent successfully (${batch.length} recipients)`);
} catch (error) {
failCount += batch.length;
fastify.log.error({ err: error, batch: index + 1 }, `Failed to send batch ${index + 1}`);
}
}
return reply.code(200).send({
success: true,
message: 'Campaign sent',
total: subscribers.length,
sent: successCount,
failed: failCount,
});
} catch (error) {
fastify.log.error({ err: error }, 'Error sending campaign');
return reply.code(500).send({
success: false,
error: 'Failed to send campaign',
});
}
});
}Key Points:
- Authentication: Uses
fastify.verifyApiKeyto protect the endpoint. - Schema Validation: Validates the message content and length.
- Batch Processing: Sends messages in batches of 50 (MessageBird's limit).
- Error Handling: Tracks success and failure counts, logs errors.
- Response: Returns detailed information about the campaign send.
Main Application Setup
Create the main application file that ties everything together.
7.1 Create Main App File:
// app.js
import 'dotenv/config';
import Fastify from 'fastify';
import fastifyAuth from '@fastify/auth';
import fastifyRateLimit from '@fastify/rate-limit';
// Import plugins
import messagebirdPlugin from './plugins/messagebird.js';
import dbPlugin from './plugins/db.js';
import authPlugin from './plugins/auth.js';
import verifyMessageBirdPlugin from './plugins/verify-messagebird.js';
// Import routes
import webhookRoutes from './routes/webhook.js';
import campaignRoutes from './routes/campaign.js';
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
},
});
// Register rate limiting globally
await fastify.register(fastifyRateLimit, {
max: 100,
timeWindow: '15 minutes',
});
// Register auth plugin
await fastify.register(fastifyAuth);
// Add content type parser to preserve raw body for webhook signature verification
fastify.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
try {
const json = JSON.parse(body);
req.rawBody = body; // Store raw body for signature verification
done(null, json);
} catch (err) {
err.statusCode = 400;
done(err, undefined);
}
});
// Register plugins
await fastify.register(messagebirdPlugin, {
apiKey: process.env.MESSAGEBIRD_API_KEY,
});
await fastify.register(dbPlugin, {
dbPath: process.env.DATABASE_PATH || './db/subscriptions.sqlite',
});
await fastify.register(authPlugin, {
apiSecretKey: process.env.API_SECRET_KEY,
});
await fastify.register(verifyMessageBirdPlugin, {
webhookSigningKey: process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY,
});
// Register routes with options
await fastify.register(webhookRoutes, {
messagebirdOriginator: process.env.MESSAGEBIRD_ORIGINATOR,
});
await fastify.register(campaignRoutes, {
messagebirdOriginator: process.env.MESSAGEBIRD_ORIGINATOR,
});
// Health check endpoint
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// Start server
const start = async () => {
try {
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
await fastify.listen({ port, host });
fastify.log.info(`Server listening on ${host}:${port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
// Graceful shutdown
const signals = ['SIGINT', 'SIGTERM'];
signals.forEach((signal) => {
process.on(signal, async () => {
fastify.log.info(`Received ${signal}, closing server gracefully`);
await fastify.close();
process.exit(0);
});
});Key Features:
- Environment Variables: Loads from
.envfile usingdotenv/config. - Logging: Configured with Pino for structured logging.
- Rate Limiting: Global rate limiting to protect against abuse.
- Custom Content Parser: Preserves raw body for webhook signature verification.
- Plugin Registration: Registers all plugins in the correct order.
- Route Registration: Registers webhook and campaign routes.
- Health Check: Simple endpoint for monitoring.
- Graceful Shutdown: Handles SIGINT and SIGTERM for clean shutdown.
Testing Your SMS Marketing Application
Test your application thoroughly before deploying to production.
8.1 Manual Testing with ngrok:
- Start your application:
npm run dev- In another terminal, start ngrok:
ngrok http 3000-
Update MessageBird webhook URL with the ngrok URL.
-
Send an SMS with "JOIN" to your MessageBird number.
-
Check logs for successful subscription.
-
Test campaign sending:
curl -X POST http://localhost:3000/campaign/send \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_SECRET_KEY" \
-d '{"message": "Hello subscribers! Special offer today."}'8.2 Testing Unsubscribe Flow:
Send "STOP" to your MessageBird number and verify:
- Database updated
- Confirmation SMS received
- User excluded from future campaigns
Deployment Considerations for Production
9.1 Environment Configuration:
Set production environment variables:
NODE_ENV=production- Use secure, production-ready API keys
- Configure proper DATABASE_PATH for production database
9.2 Database Migration:
For production, migrate from SQLite to PostgreSQL or MySQL:
- Better concurrency handling
- Improved scalability
- Advanced query optimization
- Better backup and recovery options
9.3 Monitoring and Observability:
Implement monitoring for:
- API response times
- Database query performance
- MessageBird API rate limits
- Error rates and types
- Subscriber growth metrics
9.4 Security Hardening:
- Enable HTTPS/TLS
- Implement rate limiting per IP
- Add request validation
- Use environment-specific secrets
- Regular security audits
- Keep dependencies updated
9.5 Scaling Strategies:
- Use PM2 or similar for process management
- Implement horizontal scaling with load balancers
- Consider message queue for campaign sending (Bull, BullMQ)
- Database read replicas for high traffic
- Caching layer (Redis) for subscriber counts
SMS Marketing Compliance and Best Practices
10.1 Legal Compliance:
TCPA Compliance (United States):
- Obtain prior express written consent before sending marketing messages
- Honor opt-out requests within 24 hours (10 business days maximum as of April 11, 2025)
- Don't send messages before 8:00 AM or after 9:00 PM recipient's local time
- Include clear identification of your business
- Provide easy opt-out instructions in every message
- One-to-one consent requirement effective January 26, 2026
GDPR Compliance (European Union):
- Obtain explicit consent for SMS marketing
- Provide data access and deletion rights
- Maintain consent records
- Include privacy policy information
- Allow easy withdrawal of consent
10.2 Best Practices:
- Keep messages concise and valuable
- Personalize when possible
- Test messages before bulk sending
- Monitor delivery rates and engagement
- Respect frequency preferences
- Maintain clean subscriber lists
- Provide customer support channels
Troubleshooting Common Issues
11.1 Webhook Not Receiving Messages:
- Verify ngrok is running and URL is updated in MessageBird
- Check MessageBird Flow is published and attached to number
- Verify webhook endpoint returns 200 OK quickly
- Check firewall and network settings
- Review MessageBird webhook logs in dashboard
11.2 Database Connection Errors:
- Verify DATABASE_PATH is correct and writable
- Check file permissions on database directory
- Ensure schema.sql is in correct location
- Review database initialization logs
11.3 MessageBird API Errors:
- Verify API key is correct and active
- Check originator number is configured properly
- Review MessageBird rate limits
- Ensure recipients are in E.164 format
- Check message content for restricted characters
11.4 Authentication Failures:
- Verify API_SECRET_KEY matches in requests
- Check webhook signing key is correctly configured
- Review timing of webhook timestamp validation
- Ensure headers are correctly formatted
Next Steps and Advanced Features
12.1 Feature Enhancements:
- Scheduled campaign sending
- Message personalization with subscriber data
- A/B testing for message content
- Analytics dashboard for campaign performance
- Subscriber segmentation and targeting
- Message templates and automation
- Multi-language support
- Integration with CRM systems
12.2 Technical Improvements:
- Add comprehensive test suite (unit, integration, e2e)
- Implement CI/CD pipeline
- Add API documentation (Swagger/OpenAPI)
- Implement webhook retry logic with exponential backoff
- Add message queue for reliable campaign delivery
- Database migrations management
- Performance optimization and caching
- Advanced monitoring and alerting
This comprehensive guide provides everything you need to build, deploy, and maintain a production-ready SMS marketing campaign system using MessageBird, Node.js, and Fastify. Follow security best practices, comply with regulations, and continuously monitor and improve your system for optimal performance.
Frequently Asked Questions
How to set up MessageBird webhook for SMS campaign?
Configure a webhook in the MessageBird dashboard to forward incoming SMS messages to your application's endpoint (e.g., your-ngrok-url/messagebird). Use Flow Builder for attaching it to a number or the number's settings directly. Ensure you set the method to POST and enable request signing to get a signing key for verification on your side.
What is Fastify and why use it for SMS campaigns?
Fastify is a high-performance Node.js web framework known for its speed and extensibility. It is an ideal choice for building efficient, scalable SMS applications. Its plugin architecture and developer-friendly features make development smoother.
How to send bulk SMS messages using MessageBird API?
The guide details setting up a campaign sending endpoint (not fully implemented in the provided snippet). This would use MessageBird's messages.create API endpoint to dispatch messages to multiple recipients retrieved from the database. Be sure to protect this endpoint with authentication (an API Key is used as an example).
Why does MessageBird sign its webhook requests?
MessageBird uses request signing as a security measure to allow you to verify that incoming webhooks actually originate from MessageBird and haven't been tampered with in transit. This is important to prevent fraudulent requests.
When should I normalize phone numbers in my SMS application?
Phone number normalization should be done *as early as possible* in the process—ideally immediately after receiving a number. Consistent formatting, such as E.164, is crucial for accurate storage, lookup, and sending of messages. This guide uses a basic normalization function, but a robust library is recommended for production.
What database is used in this SMS marketing campaign example?
The tutorial uses `better-sqlite3`, a lightweight SQLite library for Node.js, for simplicity. For production systems with higher load and scalability requirements, PostgreSQL or MySQL would be more suitable.
How to handle incoming SMS keywords like JOIN and STOP?
The webhook handler checks for keywords in uppercase (e.g., 'JOIN', 'STOP') in the message payload. If 'JOIN', it adds the subscriber to the database. If 'STOP', it unsubscribes them. The database functions are accessed through `fastify.db` provided by the database plugin.
Can I extend the SMS campaign app to handle other keywords?
Yes, the provided webhook handler logic can be easily extended. Add additional checks for other keywords and define corresponding actions. For more complex menu systems or automated responses, a more sophisticated message processing logic might be needed.
What is ngrok and why is it used in this setup?
`ngrok` is a tunneling service that creates a publicly accessible URL to your local development server. This is needed so MessageBird can send webhook notifications to your application during development. For production, your server would have a dedicated public URL.
What is the role of package.json type module in this Node.js application?
Setting "type": "module" in `package.json` tells Node.js to treat all files as ES modules, allowing you to use the import/export syntax. This is the modern standard for modules in JavaScript and offers several advantages.
How to install required dependencies for the Fastify MessageBird project?
Navigate to your project folder in your terminal, then use `npm install fastify messagebird dotenv better-sqlite3 @fastify/rate-limit @fastify/auth` command to install the necessary packages. This command downloads the necessary libraries for your application.
What is the purpose of dotenv in this Fastify MessageBird SMS campaign project?
The `dotenv` package is used to load environment variables from a `.env` file. This is essential for securely managing sensitive information such as API keys and other secrets without committing them directly to your code.
Why is it important to store phone numbers in E.164 format?
E.164 format (e.g., `+14155552671`) ensures consistent and internationally recognized storage of phone numbers. It simplifies processing, validation, and avoids ambiguity when handling numbers from different regions. The database schema recommends this format.
How can I get a MessageBird API key for my SMS marketing project?
You can obtain a MessageBird API key by signing up for or logging in to your MessageBird account. Go to the Developers > API access section of your dashboard, and retrieve the key from there.