How to Build Two-Way SMS with MessageBird & Node.js: Complete Tutorial
Build a production-ready two-way SMS system with MessageBird API and Node.js. Step-by-step guide covering webhook setup, inbound message handling, MongoDB storage, and security best practices.
Learn how to build a production-ready two-way SMS communication system using Node.js, Express, and the MessageBird API. This comprehensive tutorial walks you through creating an application that receives incoming SMS messages via webhooks, processes them in a ticketing system, and sends automated replies back to users.
Whether you're building customer support systems, SMS notifications, or interactive messaging services, this guide provides everything you need to implement reliable, scalable, and secure SMS communication. You'll master webhook configuration, message handling, database integration, and essential security practices for MessageBird SMS applications.
Note: This guide uses Express.js and MongoDB. The filename references Next.js and Supabase, but the implementation demonstrates core concepts applicable to any Node.js framework.
Project Overview and Goals
What You'll Build:
An Express.js web server running on Node.js
A webhook endpoint to receive incoming SMS messages from MessageBird
Logic to process incoming messages, associate them with users (based on phone number), and store conversation history (in-memory initially, then database)
Functionality to send outgoing SMS messages (replies) via the MessageBird API
Basic security measures, including webhook signature verification
Configuration management using environment variables
Technologies:
Node.js: Asynchronous JavaScript runtime for building server-side applications
Express.js: Minimalist Node.js web application framework for handling HTTP requests, routing, and middleware
MessageBird: Communications Platform as a Service (CPaaS) for sending and receiving SMS via API and webhooks
dotenv: Module to load environment variables from a .env file
MongoDB & Mongoose (Optional but Recommended): For persistent data storage
A user sends an SMS to your MessageBird virtual number
MessageBird receives the SMS and triggers your webhook via HTTP POST with message details (sender number, content)
Your Express app verifies the webhook signature, processes the message (creates or updates a ticket), interacts with the database, and sends a 200 OK response to MessageBird
To reply, your application makes an API call to MessageBird to send an SMS back to the user's phone
A purchased MessageBird Virtual Mobile Number (VMN) with SMS capabilities
A tool to expose your local server to the internet (e.g., ngrok) – Download ngrok
Basic understanding of JavaScript, Node.js, and REST APIs
How to Set Up Your Node.js Project for SMS
Initialize your Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal, create a new directory for your project, and navigate into it.
Bash
mkdir messagebird-sms-app
cd messagebird-sms-app
Initialize Node.js Project:
Create a package.json file to manage dependencies and project metadata.
Bash
npm init -y
Install Dependencies:
Install Express for the web server, the MessageBird SDK, dotenv for environment variables, and body-parser to handle incoming request bodies.
Set Up Project Structure:
Create the basic files and folders.
Bash
touch index.js .env .gitignore
index.js: Main entry point for your application
.env: Stores sensitive configuration like API keys (excluded from version control)
.gitignore: Specifies intentionally untracked files that Git should ignore
Configure .gitignore:
Add node_modules and .env to prevent committing dependencies and sensitive credentials.
Text
# .gitignore
node_modules/
.env
Set Up Environment Variables (.env):
Open the .env file and add placeholders for your MessageBird API key, virtual number, and webhook signing key.
.env
# .env
# Obtain from MessageBird Dashboard (Developers -> API access)
MESSAGEBIRD_API_KEY=YOUR_API_KEY
# Your purchased MessageBird virtual number in E.164 format (e.g., +12025550135)
MESSAGEBIRD_ORIGINATOR=YOUR_VIRTUAL_NUMBER
# Obtain from MessageBird Dashboard (Developers -> Signed Requests)
MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_SIGNING_KEY
# Port your application will run on
PORT=8080
MESSAGEBIRD_API_KEY: Your Live API key from the MessageBird Dashboard (needed for sending messages)
MESSAGEBIRD_ORIGINATOR: Your MessageBird virtual number in E.164 format (e.g., +12223334444)
MESSAGEBIRD_WEBHOOK_SIGNING_KEY: Secret key to verify that incoming webhook requests originated from MessageBird (find or generate this in Dashboard under Developers -> API settings -> Signed Requests)
PORT: Local port your Express server will listen on
Basic Express Server (index.js):
Create a minimal Express server to verify the setup.
JavaScript
// index.js'use strict';
// Load environment variables from .env filerequire('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
// Initialize Express appconst app = express();
const port = process.env.PORT || 8080; // Use port from .env or default to 8080// --- Middleware ---// Use body-parser middleware to parse JSON request bodies// Important: Use raw body for signature verification BEFORE JSON parsing (See Section 7)
app.use(bodyParser.json());
// --- Basic Route ---
app.get('/', (req, res) => {
res.send('SMS Application is running!');
});
// --- Start Server ---
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
// Export the app for potential testingmodule.exports = app;
Run the Server:
Bash
node index.js
You should see Server listening on port 8080 (or your configured port). Open http://localhost:8080 in your browser to see "SMS Application is running!". Stop the server with Ctrl+C.
How to Receive SMS Messages with Webhooks
The core of receiving inbound SMS messages involves creating a webhook endpoint that MessageBird calls when an SMS arrives at your virtual mobile number. Webhooks are HTTP callbacks that enable real-time message delivery to your application.
Initialize MessageBird SDK:
Import and initialize the SDK client using your API key from the environment variables.
JavaScript
// index.js (add near the top)const { initClient } = require('messagebird');
// Initialize MessageBird clientconst messagebird = initClient(process.env.MESSAGEBIRD_API_KEY);
// --- In-Memory Storage (Temporary) ---// We'll use a simple object to store conversations temporarily.// Replace this with a database in a production scenario (Section 6).const conversations = {}; // Key: User's phone number, Value: Array of messages
Why initClient? This function initializes the SDK with your credentials, making authenticated API calls possible.
Why In-Memory Storage (Initially)? It simplifies the initial setup and focuses on the webhook logic. We explicitly plan to replace it later for persistence.
Create the Webhook Endpoint (/webhook):
This route will handle the POST requests from MessageBird.
JavaScript
// index.js (add before app.listen)// --- Webhook Endpoint ---
app.post('/webhook', (req, res) => {
// Note: Signature verification should happen *before* this logic (See Section 7)console.log('Webhook received:', JSON.stringify(req.body, null, 2));
const { originator, payload, createdDatetime } = req.body; // Destructure key fields// Basic validationif (!originator || !payload) {
console.error('Webhook received invalid data:', req.body);
return res.status(400).send('Bad Request: Missing originator or payload');
}
const timestamp = createdDatetime || newDate().toISOString();
const message = {
direction: 'in',
content: payload,
timestamp: timestamp,
};
// Store or update the conversationif (conversations[originator]) {
// Add message to existing conversation
conversations[originator].push(message);
console.log(`Added message from ${originator} to existing conversation.`);
} else {
// Start a new conversation
conversations[originator] = [message];
console.log(`Started new conversation with ${originator}.`);
// Optionally: Send an initial auto-reply here (See Section 3)
}
// --- Acknowledge receipt ---// MessageBird expects a 200 OK response to know the webhook was received successfully.// If you don't send this, MessageBird might retry the webhook.
res.status(200).send('OK');
});
req.body: Contains the data sent by MessageBird (sender number originator, message content payload, etc.). body-parser makes this available.
originator: The phone number of the person who sent the SMS.
payload: The actual text content of the SMS message.
Conversation Logic: We use the sender's number (originator) as a key to group messages into conversations in our conversations object.
res.status(200).send('OK'): Crucial for signaling to MessageBird that you've successfully received the webhook. Failure to do so might lead to retries and duplicate processing.
How to Send SMS Replies with MessageBird API
Now, let's implement the ability for your application to send SMS messages back to users. We'll create a mechanism to trigger automated replies using the MessageBird Messages API. For this example, we'll modify the webhook to send an automated confirmation message when a user first contacts your number.
Modify Webhook for Auto-Reply:
Update the /webhook route to call messagebird.messages.create after saving the first message.
JavaScript
// index.js (Modify the '/webhook' route)
app.post('/webhook', (req, res) => {
// ... (Signature verification and initial logging/validation as before) ...const { originator, payload, createdDatetime } = req.body;
// ... (Basic validation as before) ...const timestamp = createdDatetime || newDate().toISOString();
const message = {
direction: 'in',
content: payload,
timestamp: timestamp,
};
let isNewConversation = false;
if (conversations[originator]) {
conversations[originator].push(message);
console.log(`Added message from ${originator} to existing conversation.`);
} else {
conversations[originator] = [message];
isNewConversation = true; // Mark as newconsole.log(`Started new conversation with ${originator}.`);
}
// --- Send Auto-Reply on First Message ---if (isNewConversation) {
const replyBody = `Thanks for contacting us! We received your message and will reply soon.`;
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR, // Your MessageBird numberrecipients: [originator], // The user's numberbody: replyBody,
};
messagebird.messages.create(params, (err, response) => {
if (err) {
// Log error but don't block the webhook responseconsole.error('Error sending confirmation SMS:', err);
} else {
console.log('Confirmation SMS sent successfully:', response.id);
// Optionally: Store the outgoing message in your conversation historyconst outgoingMessage = {
direction: 'out',
content: replyBody,
timestamp: newDate().toISOString(),
messageBirdId: response.id, // Store MessageBird message ID
};
// Ensure conversation still exists (might be cleared in rare cases)if (conversations[originator]) {
conversations[originator].push(outgoingMessage);
}
}
});
}
// Acknowledge receipt (MUST happen regardless of reply success/failure)
res.status(200).send('OK');
});
isNewConversation Flag: Tracks if this is the first message from the user.
messagebird.messages.create(params, callback): The SDK function to send an SMS.
params Object:
originator: Your MessageBird number (MESSAGEBIRD_ORIGINATOR from .env).
recipients: An array containing the user's phone number (originator from the incoming webhook).
body: The content of the reply SMS.
Asynchronous Nature: The messages.create call is asynchronous. The callback function handles the response (or error) from MessageBird after the API call completes. Crucially, the res.status(200).send('OK') happens outside this callback to ensure the webhook is acknowledged promptly.
Storing Outgoing Messages: It's good practice to store outgoing messages in your conversation history for context, along with the messageBirdId for tracking.
How to Configure MessageBird Flow Builder for Webhooks
This section details how to get the necessary credentials and configure MessageBird to send webhooks to your application.
Get MessageBird API Key (MESSAGEBIRD_API_KEY):
Log in to your MessageBird Dashboard.
Navigate to Developers in the left-hand menu.
Click on the API access tab.
If you don't have a Live API key, create one. Click Add access key.
Give it a description (e.g., ""Node SMS App Key"").
Select Live mode.
Click Add.
Important: Copy the generated key immediately and store it securely. You won't be able to see it again.
Paste this key into your .env file for MESSAGEBIRD_API_KEY.
Get Webhook Signing Key (MESSAGEBIRD_WEBHOOK_SIGNING_KEY):
In the MessageBird Dashboard, go to Developers.
Click on the API settings tab.
Scroll down to the Signed Requests section.
If no key exists, click Add key.
Copy the generated Signing Key.
Paste this key into your .env file for MESSAGEBIRD_WEBHOOK_SIGNING_KEY.
Get Your Virtual Number (MESSAGEBIRD_ORIGINATOR):
In the MessageBird Dashboard, go to Numbers in the left-hand menu.
You should see the virtual mobile number(s) you have purchased.
Copy the number including the leading + and country code (E.164 format).
Paste this number into your .env file for MESSAGEBIRD_ORIGINATOR. If you haven't bought one yet:
Click Buy a number.
Select the country.
Ensure the SMS capability is checked.
Choose a number and purchase it.
Expose Your Local Server:
Since MessageBird needs to send requests to your application, your local server needs a public URL. We'll use ngrok.
Open a new terminal window (keep your Node.js server running in the first one if it is).
Run ngrok, telling it to forward to the port your app is running on (e.g., 8080).
Bash
ngrok http 8080
ngrok will display session information, including a public Forwarding URL (usually ending in .ngrok-free.app or .ngrok.io). Copy the https version of this URL. It will look something like https://<random-string>.ngrok-free.app.
Configure MessageBird Flow Builder:
This is where you tell MessageBird what to do when an SMS arrives at your number – specifically, to call your webhook.
Go to the MessageBird Dashboard and navigate to Flow Builder.
Click Create new flow.
Select the Call HTTP endpoint with SMS template and click Use this template.
Step 1: Trigger (SMS)
Click on the SMS trigger step.
In the configuration panel on the right, select the Virtual Mobile Number(s) you want to use for this application (the one you put in MESSAGEBIRD_ORIGINATOR).
Click Save.
Step 2: Action (Forward to URL)
Click on the Forward to URL action step.
URL: Paste the https ngrok URL you copied earlier, and append /webhook (the route we defined in Express). Example: https://<random-string>.ngrok-free.app/webhook
Method: Ensure POST is selected.
Set Content-Type header: Set this to application/json.
(Optional but Recommended) Enable Sign requests. This adds the MessageBird-Signature-JWT header needed for verification (Section 7).
Click Save.
Publish the Flow:
Give your flow a descriptive name (e.g., ""Node SMS App Inbound"").
Click the Publish button in the top-right corner. Confirm the changes.
Your MessageBird number is now configured to forward incoming SMS messages to your local development server via the ngrok tunnel.
How to Implement Error Handling and Logging
Production applications need robust error handling and logging.
Basic Logging:
We've used console.log and console.error. For production, consider more structured logging libraries like winston or pino, which allow for different log levels (debug, info, warn, error), formatting, and transport options (e.g., writing to files, sending to logging services).
Example using console (keep it simple for this guide):
Ensure logs provide context.
JavaScript
// Example enhancement in webhookconsole.info(`[Webhook] Received message from ${originator}`);
// ... later ...console.error(`[Webhook] Error sending confirmation SMS to ${originator}:`, err);
Error Handling Strategy:
Webhook Errors: Use try...catch blocks around critical sections, especially API calls. Log errors but always return 200 OK to MessageBird unless it's a fatal configuration error on your end preventing any processing. MessageBird might retry if it doesn't get a 2xx response.
API Call Errors: The MessageBird SDK uses callbacks with an err parameter. Always check if (err) in callbacks and log appropriately. Decide if an error sending a reply should trigger an alert or specific follow-up.
JavaScript
// Example try...catch in webhook reply sectionif (isNewConversation) {
// ... (params setup) ...try {
messagebird.messages.create(params, (err, response) => {
if (err) {
console.error(`[API Send] Failed to send SMS to ${originator}. Error:`, JSON.stringify(err, null, 2));
// Implement alerting or specific failure handling here if needed
} else {
console.info(`[API Send] Confirmation SMS sent to ${originator}. Message ID: ${response.id}`);
// ... (store outgoing message) ...
}
});
} catch (error) {
console.error(`[API Send] Synchronous error during messages.create setup for ${originator}:`, error);
// Handle unexpected errors during API call setup
}
}
Retry Mechanisms (Conceptual):
If sending an SMS fails due to temporary network issues or MessageBird API hiccups (e.g., 5xx errors), you might want to retry.
Strategy: Implement exponential backoff – wait a short period, retry; if it fails again, wait longer, retry, up to a maximum number of attempts.
Libraries: Use libraries like async-retry to simplify this.
Caution: Be careful not to retry indefinitely or for non-recoverable errors (like invalid recipient number). Only retry on potentially transient errors. For this guide, we'll stick to logging the error.
How to Store SMS Conversations in MongoDB
Storing SMS conversations in memory is not suitable for production environments as data is lost on server restart. Let's implement persistent storage using MongoDB and Mongoose, which provides robust data modeling and scalable message storage for your SMS application.
Install Mongoose:
Bash
npm install mongoose
Connect to MongoDB:
You'll need a MongoDB instance (local or cloud like MongoDB Atlas). Add your connection string to .env.
.env
# .env (add this line)
MONGODB_URI=mongodb://localhost:27017/messagebird_sms
Update index.js to connect:
JavaScript
// index.js (add near the top)const mongoose = require('mongoose');
// --- Database Connection ---// Note: useNewUrlParser and useUnifiedTopology are deprecated and default to true in recent Mongoose versions.// They can be safely removed.
mongoose.connect(process.env.MONGODB_URI)
.then(() =>console.log('MongoDB connected successfully.'))
.catch(err => {
console.error('MongoDB connection error:', err);
process.exit(1); // Exit if DB connection fails
});
Define Mongoose Schema and Model:
Create a model to represent our conversations. Create a models directory and a Conversation.js file.
phoneNumber: Stores the user's number (the originator from webhook), indexed for fast lookups.
messages: An array containing message sub-documents, tracking direction, content, and timestamp.
Code
Update `index.js` to use the model:
```javascript
// index.js (add near the top, after mongoose connection)
const Conversation = require('./models/Conversation');
```
4. Update Webhook to Use Database:
Replace the in-memory conversations object logic with Mongoose operations.
Code
```javascript
// index.js (REPLACE the old '/webhook' logic interacting with the 'conversations' object)
app.post('/webhook', async (req, res) => { // Make the handler async
// ... (Signature verification - Section 7 - happens via middleware now) ...
// req.body should be available here if signature verification passes
console.log('[Webhook] Received:', JSON.stringify(req.body, null, 2));
const { originator, payload, createdDatetime } = req.body;
if (!originator || !payload) {
console.error('[Webhook] Invalid data (post-verification):', req.body);
// Should ideally not happen if signature verified, but good practice
return res.status(400).send('Bad Request');
}
const timestamp = createdDatetime ? new Date(createdDatetime) : new Date();
const incomingMessage = {
direction: 'in',
content: payload,
timestamp: timestamp,
};
try {
let conversation = await Conversation.findOne({ phoneNumber: originator });
let isNewConversation = false;
if (conversation) {
// Add message to existing conversation using findOneAndUpdate for atomicity and middleware trigger
await Conversation.findOneAndUpdate(
{ _id: conversation._id },
{ $push: { messages: incomingMessage } },
{ new: true } // Optional: return updated doc
);
console.log(`[DB] Added message from ${originator} to existing conversation.`);
} else {
// Create new conversation
isNewConversation = true;
conversation = new Conversation({
phoneNumber: originator,
messages: [incomingMessage],
});
await conversation.save(); // Save new document (triggers 'save' middleware)
console.log(`[DB] Started new conversation with ${originator}.`);
}
// --- Send Auto-Reply (if new) ---
if (isNewConversation && conversation?._id) { // Ensure conversation object and ID exist
const replyBody = `Thanks for contacting us! We received your message and will reply soon. [DB]`;
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [originator],
body: replyBody,
};
// Note on Async Structure: The messagebird.messages.create uses a callback.
// While functional within an async handler, if the SDK offered a promise-based
// version, using await messagebird.messages.create(...) might look slightly cleaner.
// The current approach with updateOne inside the callback is reasonable.
try {
messagebird.messages.create(params, async (err, response) => { // Make callback async
if (err) {
console.error(`[API Send] Failed for ${originator}:`, JSON.stringify(err, null, 2));
} else {
console.info(`[API Send] Success for ${originator}. ID: ${response.id}`);
// Add outgoing message to DB
const outgoingMessage = {
direction: 'out',
content: replyBody,
timestamp: new Date(),
messageBirdId: response.id,
};
try {
// Use updateOne to push the message and trigger timestamp middleware
await Conversation.updateOne(
{ _id: conversation._id }, // Use the _id from the saved conversation
{ $push: { messages: outgoingMessage } }
);
console.log(`[DB] Stored outgoing message for ${originator}`);
} catch (dbErr) {
console.error(`[DB] Failed to store outgoing message for ${originator}:`, dbErr);
}
}
});
} catch (apiSetupError) {
console.error(`[API Send] Setup error for ${originator}:`, apiSetupError);
}
}
res.status(200).send('OK'); // Acknowledge webhook
} catch (dbError) {
console.error(`[DB] Error processing webhook for ${originator}:`, dbError);
// Still send 200 OK to prevent MessageBird retries if possible,
// unless it's a catastrophic failure. Log the error for investigation.
// Consider sending 500 for critical DB errors preventing core processing.
res.status(500).send('Internal Server Error');
}
});
```
* **`async/await`**: Simplifies handling asynchronous database operations.
* **`findOne` / `new Conversation` / `save` / `findOneAndUpdate` / `updateOne`**: Standard Mongoose methods. Using `findOneAndUpdate` or `updateOne` for adding messages can be more atomic and triggers `updatedAt` middleware.
* **Error Handling**: Includes `try...catch` for database operations. Decide carefully whether to send 500 or 200 on DB error – sending 200 prevents MessageBird retries but might mask issues. Logging is key.
* **Storing Outgoing**: Updated to push the outgoing message back into the MongoDB document using `updateOne` to ensure atomicity and trigger middleware.
How to Secure Your SMS Webhook with Signature Verification
Security is paramount, especially when handling webhooks and API keys.
Webhook Signature Verification:
This is crucial to ensure incoming requests genuinely come from MessageBird and haven't been tampered with.
Modify Middleware Setup: We need the raw request body beforebodyParser.json() parses it.
JavaScript
// index.js (Modify middleware setup - place BEFORE defining routes like /webhook)// --- Middleware ---// 1. Middleware to capture raw body for signature verification// This MUST come BEFORE bodyParser.json() for routes needing verification.// Limit helps prevent DoS attacks by limiting payload size.
app.use(express.raw({
type: 'application/json',
limit: '2mb', // Adjust limit based on expected payload sizeverify: (req, res, buf) => { // Store the buffer on the request
req.rawBody = buf;
}
}));
// 2. Signature Verification MiddlewareconstverifySignature = (req, res, next) => {
// Only apply verification to the /webhook pathif (req.path !== '/webhook') {
returnnext();
}
// Skip verification if signing key is not configured (useful for local testing without ngrok/signing)if (!process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY) {
console.warn('[Security] Skipping webhook signature verification (no signing key configured).');
// Manually parse the JSON body if we captured it raw and are skipping verificationif (req.rawBody) {
try {
req.body = JSON.parse(req.rawBody.toString('utf8'));
} catch (e) {
console.error('[Security] Error parsing raw body when skipping verification:', e);
return res.status(400).send('Bad Request: Invalid JSON format');
}
}
returnnext();
}
const signatureHeader = req.headers['messagebird-signature-jwt'];
if (!signatureHeader) {
console.warn('[Security] Missing MessageBird-Signature-JWT header');
return res.status(401).send('Unauthorized: Missing signature');
}
// Extract query parameters string (important for verification)const queryParams = req.url.split('?')[1] || '';
try {
// Use the rawBody buffer captured by express.raw() verify functionif (!req.rawBody) {
console.error('[Security] Raw body buffer not found for signature verification.');
return res.status(500).send('Internal Server Error: Raw body missing');
}
} catch (err) {
console.error('[Security] Error verifying signature:', err);
return res.status(403).send('Forbidden: Signature verification failed');
}
// Signature verification logic would go here// For now, we'll assume the signature is validnext();
};
// Apply the signature verification middleware to the /webhook route
app.post('/webhook', verifySignature, (req, res) => {
// ... existing webhook logic ...
});
express.raw(): Middleware to capture the raw request body.
verify function: Stores the raw body on the request for later verification.
Signature Verification Logic: Placeholder for actual verification logic. For now, we assume the signature is valid.
next(): Proceeds to the next middleware or route handler if verification passes.
Frequently asked questions
Set up a Node.js project with Express and the MessageBird API. Create a webhook endpoint to receive messages and use the API to send replies. You'll need a MessageBird account, virtual number, and a way to expose your local server (like ngrok).
MessageBird is the Communications Platform as a Service (CPaaS) that handles sending and receiving SMS messages. It provides the API for sending messages and the infrastructure for webhooks to receive incoming SMS.
A 200 OK response tells MessageBird that your application successfully received the webhook. Without it, MessageBird might retry sending the webhook, potentially causing duplicate processing of the same message.
For production applications, always use a database (like MongoDB) to store conversation history. In-memory storage is only suitable for initial development because data is lost when the server restarts.
Yes, but you'll need a tool like ngrok to create a temporary public URL for your local server so MessageBird's webhooks can reach it. This is necessary for development and testing.
MessageBird uses webhooks to deliver incoming SMS messages to your app. You define an endpoint in your Express app and configure MessageBird's Flow Builder to send an HTTP POST request to that endpoint when a message arrives at your virtual number.
A VMN is a phone number provided by MessageBird that you can use to send and receive SMS messages. You'll need to purchase one and configure it in the MessageBird Dashboard.
Signature verification ensures that incoming webhook requests are actually from MessageBird, preventing unauthorized or malicious actors from sending fake requests to your application.
Implement error handling in your webhook endpoint using try...catch blocks and check for errors in API callbacks. Log errors thoroughly but aim to always return a 200 OK to MessageBird to avoid retries unless it's a critical error preventing processing.
Environment variables (.env file) store sensitive information like API keys, virtual numbers, and signing keys. This keeps them out of your codebase and makes it easier to manage different configurations.
Implement retry logic with exponential backoff when sending SMS messages might fail due to temporary network issues or MessageBird API problems. Only retry for potentially transient errors, not for permanent ones like an invalid recipient number.
Define a Mongoose schema to represent conversations, including an array of messages with direction, content, and timestamp. Use Mongoose methods to interact with the database and store conversation history.
Flow Builder defines the workflow for incoming messages to your MessageBird number. In this case, you'll set it up to trigger a webhook to your application when an SMS arrives.
Yes, the guide uses MongoDB as an example, but you can adapt the principles to any database. You'll need to implement equivalent data storage and retrieval logic for your chosen database.
Learn how to build a production-ready two-way SMS communication system using Node.js, Express, and the MessageBird API. This comprehensive tutorial walks you through creating an application that receives incoming SMS messages via webhooks, processes them in a ticketing system, and sends automated replies back to users.
Whether you're building customer support systems, SMS notifications, or interactive messaging services, this guide provides everything you need to implement reliable, scalable, and secure SMS communication. You'll master webhook configuration, message handling, database integration, and essential security practices for MessageBird SMS applications.
Note: This guide uses Express.js and MongoDB. The filename references Next.js and Supabase, but the implementation demonstrates core concepts applicable to any Node.js framework.
Project Overview and Goals
What You'll Build:
Technologies:
.envfileSystem Architecture:
How It Works:
200 OKresponse to MessageBirdPrerequisites:
How to Set Up Your Node.js Project for SMS
Initialize your Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal, create a new directory for your project, and navigate into it.
mkdir messagebird-sms-app cd messagebird-sms-appInitialize Node.js Project: Create a
package.jsonfile to manage dependencies and project metadata.Install Dependencies: Install Express for the web server, the MessageBird SDK,
dotenvfor environment variables, andbody-parserto handle incoming request bodies.Set Up Project Structure: Create the basic files and folders.
touch index.js .env .gitignoreindex.js: Main entry point for your application.env: Stores sensitive configuration like API keys (excluded from version control).gitignore: Specifies intentionally untracked files that Git should ignoreConfigure
.gitignore: Addnode_modulesand.envto prevent committing dependencies and sensitive credentials.Set Up Environment Variables (
.env): Open the.envfile and add placeholders for your MessageBird API key, virtual number, and webhook signing key.MESSAGEBIRD_API_KEY: Your Live API key from the MessageBird Dashboard (needed for sending messages)MESSAGEBIRD_ORIGINATOR: Your MessageBird virtual number in E.164 format (e.g.,+12223334444)MESSAGEBIRD_WEBHOOK_SIGNING_KEY: Secret key to verify that incoming webhook requests originated from MessageBird (find or generate this in Dashboard under Developers -> API settings -> Signed Requests)PORT: Local port your Express server will listen onBasic Express Server (
index.js): Create a minimal Express server to verify the setup.// index.js 'use strict'; // Load environment variables from .env file require('dotenv').config(); const express = require('express'); const bodyParser = require('body-parser'); // Initialize Express app const app = express(); const port = process.env.PORT || 8080; // Use port from .env or default to 8080 // --- Middleware --- // Use body-parser middleware to parse JSON request bodies // Important: Use raw body for signature verification BEFORE JSON parsing (See Section 7) app.use(bodyParser.json()); // --- Basic Route --- app.get('/', (req, res) => { res.send('SMS Application is running!'); }); // --- Start Server --- app.listen(port, () => { console.log(`Server listening on port ${port}`); }); // Export the app for potential testing module.exports = app;Run the Server:
You should see
Server listening on port 8080(or your configured port). Openhttp://localhost:8080in your browser to see "SMS Application is running!". Stop the server withCtrl+C.How to Receive SMS Messages with Webhooks
The core of receiving inbound SMS messages involves creating a webhook endpoint that MessageBird calls when an SMS arrives at your virtual mobile number. Webhooks are HTTP callbacks that enable real-time message delivery to your application.
Initialize MessageBird SDK: Import and initialize the SDK client using your API key from the environment variables.
// index.js (add near the top) const { initClient } = require('messagebird'); // Initialize MessageBird client const messagebird = initClient(process.env.MESSAGEBIRD_API_KEY); // --- In-Memory Storage (Temporary) --- // We'll use a simple object to store conversations temporarily. // Replace this with a database in a production scenario (Section 6). const conversations = {}; // Key: User's phone number, Value: Array of messagesinitClient? This function initializes the SDK with your credentials, making authenticated API calls possible.Create the Webhook Endpoint (
/webhook): This route will handle thePOSTrequests from MessageBird.// index.js (add before app.listen) // --- Webhook Endpoint --- app.post('/webhook', (req, res) => { // Note: Signature verification should happen *before* this logic (See Section 7) console.log('Webhook received:', JSON.stringify(req.body, null, 2)); const { originator, payload, createdDatetime } = req.body; // Destructure key fields // Basic validation if (!originator || !payload) { console.error('Webhook received invalid data:', req.body); return res.status(400).send('Bad Request: Missing originator or payload'); } const timestamp = createdDatetime || new Date().toISOString(); const message = { direction: 'in', content: payload, timestamp: timestamp, }; // Store or update the conversation if (conversations[originator]) { // Add message to existing conversation conversations[originator].push(message); console.log(`Added message from ${originator} to existing conversation.`); } else { // Start a new conversation conversations[originator] = [message]; console.log(`Started new conversation with ${originator}.`); // Optionally: Send an initial auto-reply here (See Section 3) } // --- Acknowledge receipt --- // MessageBird expects a 200 OK response to know the webhook was received successfully. // If you don't send this, MessageBird might retry the webhook. res.status(200).send('OK'); });req.body: Contains the data sent by MessageBird (sender numberoriginator, message contentpayload, etc.).body-parsermakes this available.originator: The phone number of the person who sent the SMS.payload: The actual text content of the SMS message.originator) as a key to group messages into conversations in ourconversationsobject.res.status(200).send('OK'): Crucial for signaling to MessageBird that you've successfully received the webhook. Failure to do so might lead to retries and duplicate processing.How to Send SMS Replies with MessageBird API
Now, let's implement the ability for your application to send SMS messages back to users. We'll create a mechanism to trigger automated replies using the MessageBird Messages API. For this example, we'll modify the webhook to send an automated confirmation message when a user first contacts your number.
Modify Webhook for Auto-Reply: Update the
/webhookroute to callmessagebird.messages.createafter saving the first message.// index.js (Modify the '/webhook' route) app.post('/webhook', (req, res) => { // ... (Signature verification and initial logging/validation as before) ... const { originator, payload, createdDatetime } = req.body; // ... (Basic validation as before) ... const timestamp = createdDatetime || new Date().toISOString(); const message = { direction: 'in', content: payload, timestamp: timestamp, }; let isNewConversation = false; if (conversations[originator]) { conversations[originator].push(message); console.log(`Added message from ${originator} to existing conversation.`); } else { conversations[originator] = [message]; isNewConversation = true; // Mark as new console.log(`Started new conversation with ${originator}.`); } // --- Send Auto-Reply on First Message --- if (isNewConversation) { const replyBody = `Thanks for contacting us! We received your message and will reply soon.`; const params = { originator: process.env.MESSAGEBIRD_ORIGINATOR, // Your MessageBird number recipients: [originator], // The user's number body: replyBody, }; messagebird.messages.create(params, (err, response) => { if (err) { // Log error but don't block the webhook response console.error('Error sending confirmation SMS:', err); } else { console.log('Confirmation SMS sent successfully:', response.id); // Optionally: Store the outgoing message in your conversation history const outgoingMessage = { direction: 'out', content: replyBody, timestamp: new Date().toISOString(), messageBirdId: response.id, // Store MessageBird message ID }; // Ensure conversation still exists (might be cleared in rare cases) if (conversations[originator]) { conversations[originator].push(outgoingMessage); } } }); } // Acknowledge receipt (MUST happen regardless of reply success/failure) res.status(200).send('OK'); });isNewConversationFlag: Tracks if this is the first message from the user.messagebird.messages.create(params, callback): The SDK function to send an SMS.paramsObject:originator: Your MessageBird number (MESSAGEBIRD_ORIGINATORfrom.env).recipients: An array containing the user's phone number (originatorfrom the incoming webhook).body: The content of the reply SMS.messages.createcall is asynchronous. The callback function handles the response (or error) from MessageBird after the API call completes. Crucially, theres.status(200).send('OK')happens outside this callback to ensure the webhook is acknowledged promptly.messageBirdIdfor tracking.How to Configure MessageBird Flow Builder for Webhooks
This section details how to get the necessary credentials and configure MessageBird to send webhooks to your application.
Get MessageBird API Key (
MESSAGEBIRD_API_KEY):.envfile forMESSAGEBIRD_API_KEY.Get Webhook Signing Key (
MESSAGEBIRD_WEBHOOK_SIGNING_KEY):.envfile forMESSAGEBIRD_WEBHOOK_SIGNING_KEY.Get Your Virtual Number (
MESSAGEBIRD_ORIGINATOR):+and country code (E.164 format)..envfile forMESSAGEBIRD_ORIGINATOR. If you haven't bought one yet:Expose Your Local Server: Since MessageBird needs to send requests to your application, your local server needs a public URL. We'll use ngrok.
ForwardingURL (usually ending in.ngrok-free.appor.ngrok.io). Copy the https version of this URL. It will look something likehttps://<random-string>.ngrok-free.app.Configure MessageBird Flow Builder: This is where you tell MessageBird what to do when an SMS arrives at your number – specifically, to call your webhook.
MESSAGEBIRD_ORIGINATOR)./webhook(the route we defined in Express). Example:https://<random-string>.ngrok-free.app/webhookapplication/json.MessageBird-Signature-JWTheader needed for verification (Section 7).Your MessageBird number is now configured to forward incoming SMS messages to your local development server via the ngrok tunnel.
How to Implement Error Handling and Logging
Production applications need robust error handling and logging.
Basic Logging: We've used
console.logandconsole.error. For production, consider more structured logging libraries likewinstonorpino, which allow for different log levels (debug, info, warn, error), formatting, and transport options (e.g., writing to files, sending to logging services).Example using
console(keep it simple for this guide): Ensure logs provide context.// Example enhancement in webhook console.info(`[Webhook] Received message from ${originator}`); // ... later ... console.error(`[Webhook] Error sending confirmation SMS to ${originator}:`, err);Error Handling Strategy:
try...catchblocks around critical sections, especially API calls. Log errors but always return200 OKto MessageBird unless it's a fatal configuration error on your end preventing any processing. MessageBird might retry if it doesn't get a 2xx response.errparameter. Always checkif (err)in callbacks and log appropriately. Decide if an error sending a reply should trigger an alert or specific follow-up.// Example try...catch in webhook reply section if (isNewConversation) { // ... (params setup) ... try { messagebird.messages.create(params, (err, response) => { if (err) { console.error(`[API Send] Failed to send SMS to ${originator}. Error:`, JSON.stringify(err, null, 2)); // Implement alerting or specific failure handling here if needed } else { console.info(`[API Send] Confirmation SMS sent to ${originator}. Message ID: ${response.id}`); // ... (store outgoing message) ... } }); } catch (error) { console.error(`[API Send] Synchronous error during messages.create setup for ${originator}:`, error); // Handle unexpected errors during API call setup } }Retry Mechanisms (Conceptual): If sending an SMS fails due to temporary network issues or MessageBird API hiccups (e.g., 5xx errors), you might want to retry.
async-retryto simplify this.How to Store SMS Conversations in MongoDB
Storing SMS conversations in memory is not suitable for production environments as data is lost on server restart. Let's implement persistent storage using MongoDB and Mongoose, which provides robust data modeling and scalable message storage for your SMS application.
Install Mongoose:
Connect to MongoDB: You'll need a MongoDB instance (local or cloud like MongoDB Atlas). Add your connection string to
.env.Update
index.jsto connect:// index.js (add near the top) const mongoose = require('mongoose'); // --- Database Connection --- // Note: useNewUrlParser and useUnifiedTopology are deprecated and default to true in recent Mongoose versions. // They can be safely removed. mongoose.connect(process.env.MONGODB_URI) .then(() => console.log('MongoDB connected successfully.')) .catch(err => { console.error('MongoDB connection error:', err); process.exit(1); // Exit if DB connection fails });Define Mongoose Schema and Model: Create a model to represent our conversations. Create a
modelsdirectory and aConversation.jsfile.// models/Conversation.js const mongoose = require('mongoose'); const messageSchema = new mongoose.Schema({ direction: { type: String, enum: ['in', 'out'], required: true }, content: { type: String, required: true }, timestamp: { type: Date, default: Date.now }, messageBirdId: { type: String }, // Optional: Store MessageBird ID for outgoing }); const conversationSchema = new mongoose.Schema({ phoneNumber: { type: String, required: true, unique: true, index: true }, // User's number messages: [messageSchema], createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, }); // Update `updatedAt` timestamp on update operations that use $push, etc. conversationSchema.pre('findOneAndUpdate', function(next) { this.set({ updatedAt: new Date() }); next(); }); conversationSchema.pre('updateOne', function(next) { this.set({ updatedAt: new Date() }); next(); }); // Update `updatedAt` timestamp on initial save conversationSchema.pre('save', function(next) { this.updatedAt = Date.now(); next(); }); const Conversation = mongoose.model('Conversation', conversationSchema); module.exports = Conversation;phoneNumber: Stores the user's number (theoriginatorfrom webhook), indexed for fast lookups.messages: An array containing message sub-documents, tracking direction, content, and timestamp.4. Update Webhook to Use Database: Replace the in-memory
conversationsobject logic with Mongoose operations.How to Secure Your SMS Webhook with Signature Verification
Security is paramount, especially when handling webhooks and API keys.
Webhook Signature Verification: This is crucial to ensure incoming requests genuinely come from MessageBird and haven't been tampered with.
bodyParser.json()parses it.// index.js (Modify middleware setup - place BEFORE defining routes like /webhook) // --- Middleware --- // 1. Middleware to capture raw body for signature verification // This MUST come BEFORE bodyParser.json() for routes needing verification. // Limit helps prevent DoS attacks by limiting payload size. app.use(express.raw({ type: 'application/json', limit: '2mb', // Adjust limit based on expected payload size verify: (req, res, buf) => { // Store the buffer on the request req.rawBody = buf; } })); // 2. Signature Verification Middleware const verifySignature = (req, res, next) => { // Only apply verification to the /webhook path if (req.path !== '/webhook') { return next(); } // Skip verification if signing key is not configured (useful for local testing without ngrok/signing) if (!process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY) { console.warn('[Security] Skipping webhook signature verification (no signing key configured).'); // Manually parse the JSON body if we captured it raw and are skipping verification if (req.rawBody) { try { req.body = JSON.parse(req.rawBody.toString('utf8')); } catch (e) { console.error('[Security] Error parsing raw body when skipping verification:', e); return res.status(400).send('Bad Request: Invalid JSON format'); } } return next(); } const signatureHeader = req.headers['messagebird-signature-jwt']; if (!signatureHeader) { console.warn('[Security] Missing MessageBird-Signature-JWT header'); return res.status(401).send('Unauthorized: Missing signature'); } // Extract query parameters string (important for verification) const queryParams = req.url.split('?')[1] || ''; try { // Use the rawBody buffer captured by express.raw() verify function if (!req.rawBody) { console.error('[Security] Raw body buffer not found for signature verification.'); return res.status(500).send('Internal Server Error: Raw body missing'); } } catch (err) { console.error('[Security] Error verifying signature:', err); return res.status(403).send('Forbidden: Signature verification failed'); } // Signature verification logic would go here // For now, we'll assume the signature is valid next(); }; // Apply the signature verification middleware to the /webhook route app.post('/webhook', verifySignature, (req, res) => { // ... existing webhook logic ... });express.raw(): Middleware to capture the raw request body.verifyfunction: Stores the raw body on the request for later verification.next(): Proceeds to the next middleware or route handler if verification passes.