This 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. We'll cover everything from project setup and core logic implementation to security, deployment, and testing.
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.
- 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. (v18+ recommended)
- Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer experience.
- MessageBird: A communication platform API used for sending and receiving SMS messages and managing virtual numbers.
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.@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
- A MessageBird account. Sign up for MessageBird
- A text editor or IDE (like VS Code).
- 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.
1. Setting Up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1.1 Create Project Directory & Initialize:
Open your terminal and run the following commands:
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:
We'll use ES Modules (import/export syntax). Open package.json
and add the following 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+
"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 our database_ @fastify/rate-limit
for protection_ and @fastify/auth
for authentication handling.
npm install fastify messagebird dotenv better-sqlite3 @fastify/rate-limit @fastify/auth
fastify
: The core web framework.messagebird
: The official Node.js SDK for interacting with the MessageBird API.dotenv
: Loads environment variables from a.env
file 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.js
app.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 the following 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. We'll populate this 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=3000
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.
2. Integrating with MessageBird
Now_ let's 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
.env
file 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
.env
file asMESSAGEBIRD_ORIGINATOR
. (Alternatively, use an approved Alphanumeric Sender ID if applicable).
- Webhook Signing Key: We'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.
// 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.decorate
to addmessagebird
to the global Fastify instance.fastify.decorate
: Makes the initializedmbClient
available throughout your application viafastify.messagebird
.
3. Creating the Database Schema and Data Layer
We'll 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.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 thesubscribed
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.
// 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
dbPath
from options, callsinitDb
, decoratesfastify
with the data access functions underfastify.db
, and ensurescloseDb
is called when the server shuts down using theonClose
hook.
4. Implementing the Core Functionality (Webhook Handler)
This route will receive incoming SMS messages from MessageBird, parse them for keywords (JOIN
, STOP
), update the database, and send 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
originator
andpayload
exist. Use@fastify/sensible
orfastify-type-provider-zod
for more complex validation in production. - Keyword Handling: Converts the message to uppercase and checks for
JOIN
orSTOP
. - Database Interaction: Calls the
fastify.db
functions to update subscription status. - Phone Number Normalization: Crucial for consistency. We use utility functions (
normalizePhoneNumber
,isValidE164
) which we'll create 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. Theoriginator
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
: We specify that this route needs authentication/verification usingfastify.auth
and a verification function (verifyMessageBird
) that we will create as a plugin. Note the route path is/messagebird
.
4.2 Add Phone Number Utilities:
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 '+'.
* 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.
*
* @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.
* Does NOT validate country code validity or exact length requirements.
*
* @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 numbers range from minimum length (e.g., +1 plus 10 digits) up to 15 digits total after the '+'.
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.
4.3 Configure MessageBird Webhook:
Now, we need to 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: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 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
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
.
- 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, you'll use your server's permanent public URL.
5. Adding Security Features
Security is paramount, especially when dealing with external webhooks and potentially sending bulk messages.
5.1 Verify MessageBird Webhook Signatures:
MessageBird signs its webhook requests so you can verify they genuinely came from MessageBird. Ensure you have 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/auth
being registered first (handled inapp.js
). request.rawBody
: Crucially relies on the custom content type parser defined inapp.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).
- Decoration: Makes
fastify.verifyMessageBird
available. It's intended to be used withinfastify.auth
in route options.
5.2 Protect the Campaign Sending Endpoint:
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
});