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.
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 (messagebird npm 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.
A tool for making API requests (like curl or Postman).
ngrok or 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:
Bash
mkdir fastify-messagebird-sms
cd fastify-messagebird-sms
npm init -y
This 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:
JSON
// 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.
MESSAGEBIRD_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.
Click Show key next to your LIVE API KEY. Copy this value.
Paste the key into your .env file as MESSAGEBIRD_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 .env file as MESSAGEBIRD_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_KEY blank for now or use a placeholder.
2.2 Initialize MessageBird Client Plugin:
Create a Fastify plugin to initialize and share the MessageBird client instance.
JavaScript
// plugins/messagebird.jsimport fp from'fastify-plugin';
import { messagebird } from'messagebird';
asyncfunctionmessagebirdPlugin(fastify, options) {
const { apiKey } = options;
if (!apiKey) {
thrownewError('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');
}
exportdefaultfp(messagebirdPlugin, {
name: 'messagebird',
dependencies: [], // No dependencies for this plugin itself
});
fastify-plugin: Used to prevent Fastify from creating separate encapsulation contexts for the plugin, allowing fastify.decorate to add messagebird to the global Fastify instance.
fastify.decorate: Makes the initialized mbClient available throughout your application via fastify.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:
SQL
-- db/schema.sqlCREATE TABLE IF NOTEXISTS subscriptions (
phone_number TEXT PRIMARY KEYNOT NULL, -- E.164 format recommended
subscribed INTEGERDEFAULT0, -- 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:
JavaScript
// db/database.jsimportDatabasefrom'better-sqlite3';
import fs from'fs';
import path from'path';
// Helper function to ensure the directory existsfunctionensureDirectoryExistence(filePath) {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
returntrue;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
}
// --- Database Initialization ---let db;
exportfunctioninitDb(dbPath) {
ensureDirectoryExistence(dbPath); // Make sure the db directory exists
db = newDatabase(dbPath); // { verbose: console.log } // Enable verbose logging for debugging SQL
db.pragma('journal_mode = WAL'); // Recommended for better concurrency// Read and execute schema filetry {
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)exportfunctionaddSubscriber(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)exportfunctionremoveSubscriber(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 subscribedexportfunctionisSubscribed(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 numbersexportfunctiongetAllSubscribers() {
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)exportfunctioncloseDb() {
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. Uses ON CONFLICT…DO UPDATE (UPSERT) for efficiency.
removeSubscriber: Sets the subscribed flag 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:
JavaScript
// plugins/db.jsimport fp from'fastify-plugin';
import { initDb, closeDb, addSubscriber, removeSubscriber, isSubscribed, getAllSubscribers } from'../db/database.js';
asyncfunctiondbPlugin(fastify, options) {
const { dbPath } = options;
if (!dbPath) {
thrownewError('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 incorrectlythrow err;
}
}
exportdefaultfp(dbPlugin, {
name: 'db',
dependencies: [], // No dependencies for this plugin itself
});
This plugin takes the dbPath from options, calls initDb, decorates fastify with the data access functions under fastify.db, and ensures closeDb is called when the server shuts down using the onClose hook.
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:
JavaScript
// routes/webhook.jsimport { normalizePhoneNumber, isValidE164 } from'../utils/phoneUtils.js'; // We'll create this util soonconst webhookSchema = {
// Basic validation: ensure required fields from MessageBird exist// For production, use a more robust JSON Schema validatorbody: {
type: 'object',
required: ['originator', 'payload'],
properties: {
originator: { type: 'string' }, // Sender's phone numberpayload: { type: 'string' }, // SMS message content// Add other fields you might need from the webhook payload
},
},
};
exportdefaultasyncfunctionwebhookRoutes(fastify, options) {
// This route requires the verifyMessageBird plugin (added later)const routeOptions = {
schema: webhookSchema,
// Apply the verification middleware specifically to this route using fastify-authpreHandler: fastify.auth([fastify.verifyMessageBird]),
};
fastify.post('/messagebird', routeOptions, async (request, reply) => {
const { originator, payload } = request.body;
const messageContent = payload.trim().toUpperCase(); // Normalize keyword checklet replyMessage = '';
// Attempt to normalize the phone numberlet 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.';
} elseif (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 neededif (replyMessage) {
await fastify.messagebird.messages.create({
originator: options.messagebirdOriginator, // Your MessageBird number/sender IDrecipients: [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 originator and payload exist. Use @fastify/sensible or fastify-type-provider-zod for more complex validation in production.
Keyword Handling: Converts the message to uppercase and checks for JOIN or STOP.
Database Interaction: Calls the fastify.db functions 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.create to send an SMS back to the user. The originator here is your MessageBird number/sender ID.
Error Handling: Logs errors but still returns a 200 OK to 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 using fastify.auth and 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:
JavaScript
// 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.
*/exportfunctionnormalizePhoneNumber(phoneNumber) {
if (!phoneNumber) returnnull;
let normalized = String(phoneNumber).trim().replace(/[^\d+]/g, ''); // Remove invalid charsif (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.returnnull; // 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 downstreamreturnnull;
}
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.
*/exportfunctionisValidE164(phoneNumber) {
if (!phoneNumber) returnfalse;
// E.164 standard: maximum 15 digits after the '+' signreturn/^\+\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:
Bash
ngrok http 3000
ngrok will 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 ngrok URL 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 .env file as MESSAGEBIRD_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 ngrok URL + 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.
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:
JavaScript
// plugins/verify-messagebird.jsimport fp from'fastify-plugin';
import crypto from'crypto';
asyncfunctionverifyMessageBirdPlugin(fastify, options) {
const { webhookSigningKey } = options;
if (!webhookSigningKey) {
thrownewError('MessageBird Webhook Signing Key is required for verification');
}
// Verification function to be used as a preHandler hook via fastify.authasyncfunctionverifyRequest(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.jsif (!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 comparisontry {
// Ensure both buffers have the same byte length for timingSafeEqualconst 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 stringif (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: ${newDate(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.jsif (!fastify.auth) {
// This check ensures @fastify/auth is available. It should be registered *before* this plugin.thrownewError('@fastify/auth plugin must be registered before verify-messagebird plugin');
}
fastify.decorate('verifyMessageBird', verifyRequest);
}
exportdefaultfp(verifyMessageBirdPlugin, {
name: 'verify-messagebird',
dependencies: ['@fastify/auth'], // Explicitly declare dependency on @fastify/auth
});
Dependencies: Relies on @fastify/auth being registered first (handled in app.js).
request.rawBody: Crucially relies on the custom content type parser defined in app.js to 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.verifyMessageBird available. It's intended to be used within fastify.auth in route options.
5.2 Implementing API Key Authentication for Campaign Endpoints:
Create a simple API key authentication plugin:
JavaScript
// plugins/auth.jsimport fp from'fastify-plugin';
import crypto from'crypto'; // Need crypto for timingSafeEqualasyncfunctionauthPlugin(fastify, options) {
const { apiSecretKey } = options;
if (!apiSecretKey) {
thrownewError('API Secret Key is required for authentication');
}
// Authentication function to be used with fastify.authasyncfunctionverifyApiKey(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 attackstry {
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 functionif (!fastify.auth) {
thrownewError('@fastify/auth plugin must be registered before the auth plugin');
}
fastify.decorate('verifyApiKey', verifyApiKey);
}
exportdefaultfp(authPlugin, {
name: 'apiKeyAuth',
dependencies: ['@fastify/auth'], // Depends on @fastify/auth
});
Timing-Safe Comparison: Uses crypto.timingSafeEqual to 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.verifyApiKey available for use with fastify.auth in 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:
JavaScript
// routes/campaign.jsconst campaignSchema = {
body: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', minLength: 1, maxLength: 1600 }, // SMS length limit
},
},
};
exportdefaultasyncfunctioncampaignRoutes(fastify, options) {
// Protected route - requires API key authenticationconst routeOptions = {
schema: campaignSchema,
preHandler: fastify.auth([fastify.verifyApiKey]),
};
fastify.post('/campaign/send', routeOptions, async (request, reply) => {
const { message } = request.body;
try {
// Get all active subscribersconst 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 callconst 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 batchfor (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.verifyApiKey to 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.
Environment Variables: Loads from .env file using dotenv/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:
Bash
npm run dev
In another terminal, start ngrok:
Bash
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:
Bash
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
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
JOIN) and opt-outs (STOP).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:
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:
Prerequisites:
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 ngrokFinal 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.jsonfile with default settings.1.2 Enable ES Modules:
Use ES Modules (import/export syntax). Open
package.jsonand 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,
dotenvfor environment variables,better-sqlite3for the database,@fastify/rate-limitfor protection, and@fastify/authfor authentication handling.fastify: 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
.gitignorefile to prevent sensitive information and generated files from being committed:1.6 Set Up Environment Variables (
.env):Create a
.envfile in the root of your project. Populate it with actual values in the next steps.MESSAGEBIRD_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:
.envfile asMESSAGEBIRD_API_KEY.+and country code)..envfile asMESSAGEBIRD_ORIGINATOR. (Alternatively, use an approved Alphanumeric Sender ID if applicable).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-sqlite3for 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.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 });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:
originatorandpayloadexist. Use@fastify/sensibleorfastify-type-provider-zodfor more complex validation in production.JOINorSTOP.fastify.dbfunctions to update subscription status.normalizePhoneNumber,isValidE164) created next. The implementation is basic; stronger validation is recommended for production.fastify.messagebird.messages.createto send an SMS back to the user. Theoriginatorhere is your MessageBird number/sender ID.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
normalizePhoneNumberfunction provided here is intentionally basic and carries significant limitations. For any production system, using a dedicated library likelibphonenumber-jsis 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:ngrokwill give you a public HTTPS URL (e.g.,https://<unique-id>.ngrok-free.app). Copy this URL.Configure Flow Builder or Number Settings:
ngrokURL followed by the webhook path:https://<unique-id>.ngrok-free.app/messagebird.POST..envfile asMESSAGEBIRD_WEBHOOK_SIGNING_KEY.ngrokURL + path:https://<unique-id>.ngrok-free.app/messagebird..env.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 });@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.crypto.timingSafeEqual: Used for secure comparison of signatures to prevent timing attacks. Requires buffers of equal length.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 });crypto.timingSafeEqualto prevent timing attacks that could leak information about the secret key.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:
fastify.verifyApiKeyto protect the endpoint.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:
.envfile usingdotenv/config.Testing Your SMS Marketing Application
Test your application thoroughly before deploying to production.
8.1 Manual Testing with ngrok:
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:
Deployment Considerations for Production
9.1 Environment Configuration:
Set production environment variables:
NODE_ENV=production9.2 Database Migration:
For production, migrate from SQLite to PostgreSQL or MySQL:
9.3 Monitoring and Observability:
Implement monitoring for:
9.4 Security Hardening:
9.5 Scaling Strategies:
SMS Marketing Compliance and Best Practices
10.1 Legal Compliance:
TCPA Compliance (United States):
GDPR Compliance (European Union):
10.2 Best Practices:
Troubleshooting Common Issues
11.1 Webhook Not Receiving Messages:
11.2 Database Connection Errors:
11.3 MessageBird API Errors:
11.4 Authentication Failures:
Next Steps and Advanced Features
12.1 Feature Enhancements:
12.2 Technical Improvements:
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.