messaging channels
messaging channels
MessageBird Fastify Two-Way SMS: Build Inbound Messaging with Node.js Webhooks
Step-by-step guide to implementing two-way SMS messaging with MessageBird webhooks in Fastify. Learn JWT signature verification, automated SMS replies, rate limiting, and production deployment for interactive SMS applications.
Implementing Inbound Two-Way SMS Messaging with MessageBird, Node.js, and Fastify
Introduction
Build a two-way SMS system with MessageBird (now Bird), Node.js, and Fastify. Learn to receive SMS messages via webhooks, verify their authenticity, and send automated replies—essential for interactive SMS applications like customer support bots, survey systems, and appointment reminders.
Two-way SMS delivers substantially higher engagement than one-way messaging: industry data from 2024-2025 shows SMS achieves 45% response rates compared to email's 6%, with 98% open rates and 90% of messages read within 3 minutes. Two-way SMS excels for time-sensitive interactions requiring customer participation.
What you'll build: A production-ready Fastify server that receives inbound SMS messages through MessageBird webhooks, validates message authenticity using JWT signature verification, and sends automated responses.
Note: MessageBird rebranded to Bird in early 2024. The SMS API and Node.js SDK functionality remain unchanged, though you may see both names in documentation and interfaces.
Prerequisites
Ensure you have:
- Node.js v20 LTS or v22 LTS installed on your development machine. (These are the current LTS versions as of October 2025. While older versions like v16 may work, using current LTS versions ensures the best security updates and performance.)
- MessageBird Account: Sign up at messagebird.com to obtain your API credentials.
- API Key: Generate a live API key from your MessageBird dashboard (found under Developers → API Access).
- Virtual Mobile Number: Purchase a phone number with SMS capabilities from MessageBird to receive inbound messages. Virtual mobile numbers vary by country; US numbers typically cost $1-2/month with instant activation. Setup typically takes 5-15 minutes once you've added account balance.
- Basic Knowledge: Familiarity with JavaScript, async/await patterns, and REST API concepts.
- Development Tools: A code editor (VS Code, Sublime Text, etc.) and terminal access.
For related guides on building SMS applications, see our tutorials on MessageBird bulk SMS broadcasting and SMS marketing campaigns with Node.js.
What Is Two-Way SMS Messaging and Why Use It?
Two-way SMS messaging enables bidirectional communication between your application and mobile users. Unlike one-way SMS broadcasts, two-way messaging allows customers to respond, creating interactive conversations that drive engagement and automate customer service workflows.
Common use cases for two-way SMS:
- Customer support automation — Answer frequently asked questions instantly without human intervention
- Appointment confirmations — Allow customers to confirm, reschedule, or cancel appointments via SMS
- Survey and feedback collection — Gather customer opinions through conversational SMS interactions
- Order status updates — Enable customers to check order status by texting keywords to your number
- Opt-in and consent management — Handle subscription preferences and marketing consent requests
- Interactive marketing campaigns — Run contests, polls, and engagement campaigns through SMS
Why choose Fastify for SMS webhooks?
Fastify excels at webhook handling with high performance (up to 30,000 requests per second), low overhead, and built-in features like schema validation, logging, and plugin architecture. Compared to Express.js, Fastify offers approximately 2-3x better throughput and lower latency for webhook-heavy workloads, with built-in JSON schema validation and TypeScript support.
Understanding Two-Way SMS Webhook Architecture
Two-way SMS messaging involves both sending and receiving messages. Here's how the system works:
- Customer sends SMS to your MessageBird virtual number
- MessageBird receives the message and triggers a webhook to your server
- Your Fastify server receives the webhook, verifies its authenticity, and processes the message
- Your application sends an automated response back through MessageBird's API
- Customer receives your reply on their mobile device
Webhook retry behavior: MessageBird retries failed webhook deliveries over an 8-hour period with exponential backoff. Respond with HTTP 200 within 10 seconds to prevent retries. If your endpoint consistently fails, MessageBird may disable the webhook. Implement idempotency checks using the message id field to handle duplicate deliveries safely.
How to Set Up Your MessageBird Fastify Project
Create a new directory for your project and initialize a Node.js application:
mkdir messagebird-fastify-inbound-sms
cd messagebird-fastify-inbound-sms
npm init -yInstall the required dependencies:
npm install fastify messagebird dotenv pino-pretty qs async-retry @fastify/rate-limitPackage purposes:
fastify— Fast and low-overhead web framework for Node.js (Note: This tutorial uses Fastify v5.x. If you're using an older version, some APIs may differ.)messagebird— Official MessageBird Node.js SDK for sending SMS and accessing the APIdotenv— Loads environment variables from.envfile for secure credential managementpino-pretty— Pretty-prints Fastify's JSON logs for easier development debuggingqs— Parses URL-encoded webhook payloads from MessageBirdasync-retry— Implements retry logic for failed API calls@fastify/rate-limit— Protects your webhook endpoint from abuse
How to Configure Environment Variables for MessageBird
Create a .env file in your project root to store sensitive credentials securely:
# .env
MESSAGEBIRD_API_KEY=your_live_api_key_here
MESSAGEBIRD_WEBHOOK_SIGNING_KEY=your_webhook_signing_key_here
MESSAGEBIRD_ORIGINATOR=+1234567890
PORT=3000
NODE_ENV=developmentConfiguration details:
MESSAGEBIRD_API_KEY— Your live API key from MessageBird dashboardMESSAGEBIRD_WEBHOOK_SIGNING_KEY— Signing key for webhook signature verification (found in webhook settings)MESSAGEBIRD_ORIGINATOR— Your MessageBird virtual number in E.164 format (e.g., +14155552671)PORT— Port number for your Fastify server (default: 3000)NODE_ENV— Environment indicator (developmentorproduction)
How to obtain your webhook signing key: Log into your MessageBird Dashboard → Settings → Developers → Webhooks. When creating or editing a webhook, you'll see a "Signing Key" section. Click "Generate" to create a new signing key, then copy this key to your .env file as MESSAGEBIRD_WEBHOOK_SIGNING_KEY. Store this key securely—you cannot retrieve it again after leaving the page.
Security note: Add .env to your .gitignore file to prevent accidentally committing sensitive credentials to version control.
How to Create a Fastify Server for Inbound SMS Webhooks
Create a file named server.js and set up your basic Fastify server:
// server.js
'use strict';
require('dotenv').config();
const fastify = require('fastify');
const messagebird = require('messagebird');
const qs = require('qs');
const retry = require('async-retry');
// Initialize Fastify with logging
const app = fastify({
logger: {
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
colorize: true
}
}
},
// Enable raw body for signature verification
disableRequestLogging: false,
bodyLimit: 1048576 // 1MB limit
});
// Initialize MessageBird client
const mbClient = messagebird.initClient(process.env.MESSAGEBIRD_API_KEY);
// ... webhook handlers will go here ...
// Start the server
const start = async () => {
try {
const port = process.env.PORT || 3000;
await app.listen({ port, host: '0.0.0.0' });
app.log.info(`Server listening on port ${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();Code explanation:
- Initializes Fastify with pretty logging for development
- Creates a MessageBird client using
messagebird.initClient()with your API key - Configures the server to listen on all network interfaces (
0.0.0.0) for webhook access - Implements graceful error handling in the startup function
- The 1 MB
bodyLimitaccommodates typical webhook payloads (usually 1-5 KB) while preventing memory exhaustion from malformed requests
How to Verify MessageBird Webhook Signatures with JWT
Verify incoming webhooks to ensure they originate from MessageBird. Add this verification function to your server.js:
/**
* Verifies MessageBird webhook JWT signature
* @param {FastifyRequest} request - The Fastify request object
* @param {FastifyReply} reply - The Fastify reply object
*/
async function verifyMessageBirdSignature (request, reply) {
const signatureJWT = request.headers['messagebird-signature-jwt'];
const webhookSigningKey = process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY;
const rawBody = request.rawBodyString;
if (!signatureJWT || !webhookSigningKey || typeof rawBody !== 'string') {
request.log.warn({
signatureJWT: !!signatureJWT,
key: !!webhookSigningKey,
rawBodyType: typeof rawBody
}, 'Missing JWT signature header, signing key, or raw body.');
reply.code(400).send({ error: 'Missing MessageBird signature JWT, key, or raw body' });
return;
}
try {
const isValid = messagebird.webhookSignatureJwt.verifySignature(
signatureJWT,
webhookSigningKey,
request.raw.url,
rawBody
);
if (!isValid) {
request.log.error('Invalid webhook JWT signature received.');
reply.code(401).send({ error: 'Invalid MessageBird signature' });
return;
}
request.log.info('Webhook JWT signature verified successfully.');
} catch (error) {
request.log.error({ err: error }, 'Error during JWT signature verification.');
reply.code(500).send({ error: 'Webhook signature verification failed internally' });
}
}Note: As of October 2025, MessageBird uses the MessageBird-Signature-JWT header for webhook authentication. This replaces older HMAC-based verification methods. Always use the SDK's built-in verification for security.
Security considerations:
- Validates the JWT signature using MessageBird's SDK verification method
- Rejects requests with missing or invalid signatures to prevent spoofed webhooks
- Logs all verification attempts for security auditing
- Uses the raw request body (not parsed) for signature verification
What JWT verification protects against: JWT signature verification with timestamps prevents replay attacks, man-in-the-middle tampering, and forged requests. The JWT includes nbf (not before) and exp (expiration) claims that ensure the webhook was sent recently (typically valid for 5 minutes), preventing attackers from capturing and replaying old webhook requests. The signature also validates that the URL and payload haven't been altered in transit.
How to Create a Webhook Endpoint for Inbound SMS Messages
Add a custom content type parser for raw body access, then create your webhook endpoint:
// Add raw body parser before webhook route
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, function (req, body, done) {
req.rawBodyString = body;
try {
const parsed = qs.parse(body);
done(null, parsed);
} catch (err) {
err.statusCode = 400;
done(err, undefined);
}
});
// Webhook endpoint for inbound SMS
app.post('/webhooks/inbound-sms', {
preHandler: verifyMessageBirdSignature
}, async (request, reply) => {
try {
const payload = request.body;
request.log.info({ payload }, 'Received inbound SMS webhook');
// Extract message details
const {
id: messageId,
originator: senderNumber,
recipient: yourNumber,
body: messageText,
createdDatetime
} = payload;
// Validate required fields
if (!senderNumber || !messageText) {
request.log.error({ payload }, 'Webhook payload missing required fields');
return reply.code(400).send({ error: 'Invalid payload' });
}
request.log.info({
messageId,
from: senderNumber,
to: yourNumber,
text: messageText,
timestamp: createdDatetime
}, 'Processing inbound message');
// Process the message (send auto-reply)
await sendAutoReply(senderNumber, messageText, request.log);
// Acknowledge receipt to MessageBird
reply.code(200).send({ status: 'received' });
} catch (error) {
request.log.error({ err: error }, 'Error processing webhook');
reply.code(500).send({ error: 'Internal server error' });
}
});Webhook handling flow:
- Parse the URL-encoded webhook payload from MessageBird
- Extract essential message details (sender, recipient, content, timestamp)
- Validate required fields
- Process the message and send an automated response
- Return a 200 OK status to acknowledge successful receipt
Complete webhook payload structure: MessageBird inbound SMS webhooks include these fields: id (unique message ID), originator (sender's phone number), recipient (your virtual number), body (message text), createdDatetime (RFC3339 timestamp), direction (always "mo" for mobile-originated), type (usually "sms"), datacoding ("plain" or "unicode"), and messageLength (character count). See the full payload specification in MessageBird's API documentation.
Response handling: If your server returns any non-2xx status code (including 500 errors or timeouts), MessageBird will retry webhook delivery with exponential backoff over 8 hours. Return 200 OK only after successfully processing and storing the message. If you need async processing, queue the message first, then return 200 to prevent retries.
How to Send Automated SMS Replies with MessageBird API
Add a function to send automated SMS responses with retry logic:
/**
* Sends an automated SMS reply
* @param {string} recipient - Phone number to send reply to (E.164 format)
* @param {string} originalMessage - Original message text received
* @param {Object} logger - Pino logger instance
*/
async function sendAutoReply(recipient, originalMessage, logger) {
// Generate dynamic reply based on message content
const replyText = generateReplyMessage(originalMessage);
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [recipient],
body: replyText
};
try {
// Use retry logic for resilient API calls
const response = await retry(
async (bail) => {
return new Promise((resolve, reject) => {
mbClient.messages.create(params, (err, response) => {
if (err) {
// Don't retry client errors (4xx)
if (err.statusCode && err.statusCode < 500) {
logger.error({ err, recipient }, 'Client error sending SMS - not retrying');
bail(err);
return;
}
reject(err);
} else {
resolve(response);
}
});
});
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
onRetry: (err, attempt) => {
logger.warn({ err, attempt, recipient }, 'Retrying SMS send');
}
}
);
logger.info({
messageId: response.id,
recipient,
status: response.recipients.items[0].status
}, 'Auto-reply sent successfully');
} catch (error) {
logger.error({ err: error, recipient }, 'Failed to send auto-reply after retries');
throw error;
}
}
/**
* Generates contextual reply message
* @param {string} incomingMessage - Original message from customer
* @returns {string} Reply message text
*/
function generateReplyMessage(incomingMessage) {
const message = incomingMessage.toLowerCase().trim();
// Simple keyword-based responses
if (message.includes('help') || message.includes('support')) {
return 'Thanks for reaching out! Our support team will respond within 24 hours. For urgent issues, call 1-800-555-0123.';
}
if (message.includes('hours') || message.includes('open')) {
return 'We\'re open Monday-Friday 9 AM-6 PM EST. Visit our website for more information: example.com/hours';
}
if (message.includes('price') || message.includes('cost')) {
return 'For pricing information, visit example.com/pricing or reply with your email address for a detailed quote.';
}
// Default response
return `Thanks for your message: "${incomingMessage}". We'll get back to you shortly!`;
}Implementation highlights:
- Uses retry logic with exponential backoff for API reliability
- Distinguishes between client errors (don't retry) and server errors (retry up to 3 times)
- Generates contextual responses based on message keywords
- Logs all send attempts for debugging and monitoring
- Handles failures gracefully with detailed error logging
SMS character limits and concatenation: Standard SMS messages support 160 characters using GSM-7 encoding or 70 characters using Unicode (UCS-2). Messages exceeding these limits are automatically split into segments of 153 characters (GSM-7) or 67 characters (Unicode), with each segment billed separately. MessageBird supports up to 9 concatenated segments (1,377 GSM-7 or 603 Unicode characters maximum). The recipient's device automatically reassembles segments. Monitor your reply message lengths to control costs—a 161-character message costs 2× a 160-character message.
How to Add Rate Limiting to Your SMS Webhook
Protect your webhook endpoint from abuse by adding rate limiting:
// Register rate limiting plugin (add near top of file, after Fastify initialization)
app.register(require('@fastify/rate-limit'), {
max: 100, // Maximum requests per timeWindow
timeWindow: '1 minute',
cache: 10000,
allowList: [], // Add trusted IPs here if available
redis: null, // Use Redis for distributed rate limiting in production
skipOnError: true,
keyGenerator: (request) => {
// Rate limit by IP address
return request.ip;
},
errorResponseBuilder: (request, context) => {
return {
code: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded, retry in ${context.after}`,
expiresIn: context.after
};
}
});Rate limiting configuration:
- Allows 100 requests per minute per IP address
- Caches rate limit data for 10,000 unique IPs
- Can whitelist MessageBird's webhook IPs for production
- Returns clear error messages when limits are exceeded
- Can scale to distributed systems using Redis in production
MessageBird webhook IP ranges: MessageBird's REST API uses dynamic IP addresses from globally distributed infrastructure and cannot be whitelisted. Instead of IP whitelisting, use the JWT signature verification (shown above) to authenticate webhooks. For additional security, you can rate-limit by sender phone number (extracted from webhook payload) in addition to IP-based limits to prevent spam from specific numbers.
How to Configure MessageBird Webhook Settings
Connect your Fastify server to MessageBird:
- Deploy your server to a publicly accessible URL (use ngrok for local testing)
- Navigate to MessageBird dashboard → Settings → Webhooks
- Create new webhook with these settings:
- Webhook URL:
https://your-domain.com/webhooks/inbound-sms - Webhook Trigger: "Incoming SMS"
- Signing Key: Copy this key to your
.envfile asMESSAGEBIRD_WEBHOOK_SIGNING_KEY
- Webhook URL:
- Test webhook using MessageBird's test feature
- Activate webhook after successful testing
Local development with ngrok:
# Install ngrok
npm install -g ngrok
# Start your Fastify server
node server.js
# In another terminal, expose your local server
ngrok http 3000
# Use the ngrok HTTPS URL (e.g., https://abc123.ngrok.io) in MessageBird webhook settingsHow to Test Your Two-Way SMS Implementation
Test your two-way SMS system:
-
Start your server:
bashnode server.js -
Send a test SMS to your MessageBird virtual number from your mobile phone
-
Monitor server logs to verify:
- Webhook received and signature verified
- Message details extracted correctly
- Auto-reply sent successfully
-
Check your phone for the automated response
Expected log output:
{
"level": 30,
"time": "14:23:45 EST",
"msg": "Webhook JWT signature verified successfully."
}
{
"level": 30,
"time": "14:23:45 EST",
"messageId": "abc123def456",
"from": "+15551234567",
"to": "+14155552671",
"text": "help",
"msg": "Processing inbound message"
}
{
"level": 30,
"time": "14:23:46 EST",
"messageId": "xyz789ghi012",
"recipient": "+15551234567",
"status": "sent",
"msg": "Auto-reply sent successfully"
}Troubleshooting checklist when testing fails:
- No webhook received: Verify webhook URL is publicly accessible (test with
curl https://your-domain.com/webhooks/inbound-sms), check MessageBird dashboard webhook status is "Active", confirm virtual number has webhook configured - 401/400 signature errors: Verify
MESSAGEBIRD_WEBHOOK_SIGNING_KEYmatches the key shown in MessageBird dashboard, ensure raw body parser is registered before route handler, check header name is lowercasemessagebird-signature-jwt - 500 server errors: Check server logs for stack traces, verify
MESSAGEBIRD_API_KEYis valid, ensure all dependencies are installed (npm install) - Reply not received: Verify
MESSAGEBIRD_ORIGINATORis in E.164 format with country code, check MessageBird account balance is sufficient, review MessageBird error codes in logs
Complete Code Example
Here's the full implementation with all components:
// server.js - Complete implementation
'use strict';
require('dotenv').config();
const fastify = require('fastify');
const messagebird = require('messagebird');
const qs = require('qs');
const retry = require('async-retry');
// Initialize Fastify with logging
const app = fastify({
logger: {
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
colorize: true
}
}
},
disableRequestLogging: false,
bodyLimit: 1048576
});
// Initialize MessageBird client
const mbClient = messagebird.initClient(process.env.MESSAGEBIRD_API_KEY);
// Register rate limiting
app.register(require('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute',
cache: 10000,
skipOnError: true,
keyGenerator: (request) => request.ip,
errorResponseBuilder: (request, context) => ({
code: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded, retry in ${context.after}`,
expiresIn: context.after
})
});
// Parse raw body for signature verification
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, function (req, body, done) {
req.rawBodyString = body;
try {
const parsed = qs.parse(body);
done(null, parsed);
} catch (err) {
err.statusCode = 400;
done(err, undefined);
}
});
/**
* Verifies MessageBird webhook JWT signature
*/
async function verifyMessageBirdSignature (request, reply) {
const signatureJWT = request.headers['messagebird-signature-jwt'];
const webhookSigningKey = process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY;
const rawBody = request.rawBodyString;
if (!signatureJWT || !webhookSigningKey || typeof rawBody !== 'string') {
request.log.warn({
signatureJWT: !!signatureJWT,
key: !!webhookSigningKey,
rawBodyType: typeof rawBody
}, 'Missing JWT signature header, signing key, or raw body.');
reply.code(400).send({ error: 'Missing MessageBird signature JWT, key, or raw body' });
return;
}
try {
const isValid = messagebird.webhookSignatureJwt.verifySignature(
signatureJWT,
webhookSigningKey,
request.raw.url,
rawBody
);
if (!isValid) {
request.log.error('Invalid webhook JWT signature received.');
reply.code(401).send({ error: 'Invalid MessageBird signature' });
return;
}
request.log.info('Webhook JWT signature verified successfully.');
} catch (error) {
request.log.error({ err: error }, 'Error during JWT signature verification.');
reply.code(500).send({ error: 'Webhook signature verification failed internally' });
}
}
/**
* Generates contextual reply message
*/
function generateReplyMessage(incomingMessage) {
const message = incomingMessage.toLowerCase().trim();
if (message.includes('help') || message.includes('support')) {
return 'Thanks for reaching out! Our support team will respond within 24 hours. For urgent issues, call 1-800-555-0123.';
}
if (message.includes('hours') || message.includes('open')) {
return 'We\'re open Monday-Friday 9 AM-6 PM EST. Visit our website for more information: example.com/hours';
}
if (message.includes('price') || message.includes('cost')) {
return 'For pricing information, visit example.com/pricing or reply with your email address for a detailed quote.';
}
return `Thanks for your message: "${incomingMessage}". We'll get back to you shortly!`;
}
/**
* Sends an automated SMS reply with retry logic
*/
async function sendAutoReply(recipient, originalMessage, logger) {
const replyText = generateReplyMessage(originalMessage);
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [recipient],
body: replyText
};
try {
const response = await retry(
async (bail) => {
return new Promise((resolve, reject) => {
mbClient.messages.create(params, (err, response) => {
if (err) {
if (err.statusCode && err.statusCode < 500) {
logger.error({ err, recipient }, 'Client error sending SMS - not retrying');
bail(err);
return;
}
reject(err);
} else {
resolve(response);
}
});
});
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
onRetry: (err, attempt) => {
logger.warn({ err, attempt, recipient }, 'Retrying SMS send');
}
}
);
logger.info({
messageId: response.id,
recipient,
status: response.recipients.items[0].status
}, 'Auto-reply sent successfully');
} catch (error) {
logger.error({ err: error, recipient }, 'Failed to send auto-reply after retries');
throw error;
}
}
// Webhook endpoint for inbound SMS
app.post('/webhooks/inbound-sms', {
preHandler: verifyMessageBirdSignature
}, async (request, reply) => {
try {
const payload = request.body;
request.log.info({ payload }, 'Received inbound SMS webhook');
const {
id: messageId,
originator: senderNumber,
recipient: yourNumber,
body: messageText,
createdDatetime
} = payload;
if (!senderNumber || !messageText) {
request.log.error({ payload }, 'Webhook payload missing required fields');
return reply.code(400).send({ error: 'Invalid payload' });
}
request.log.info({
messageId,
from: senderNumber,
to: yourNumber,
text: messageText,
timestamp: createdDatetime
}, 'Processing inbound message');
await sendAutoReply(senderNumber, messageText, request.log);
reply.code(200).send({ status: 'received' });
} catch (error) {
request.log.error({ err: error }, 'Error processing webhook');
reply.code(500).send({ error: 'Internal server error' });
}
});
// Health check endpoint
app.get('/health', async (request, reply) => {
reply.send({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
const start = async () => {
try {
const port = process.env.PORT || 3000;
await app.listen({ port, host: '0.0.0.0' });
app.log.info(`Server listening on port ${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();Production Deployment Best Practices for SMS Webhooks
Implement these best practices before deploying to production:
Security Enhancements
- Use HTTPS only — Never accept webhooks over unencrypted HTTP connections
- Implement signature verification — Always validate JWT signatures (IP whitelisting is not available for MessageBird webhooks)
- Rotate signing keys regularly — Update your
MESSAGEBIRD_WEBHOOK_SIGNING_KEYperiodically (regenerate via MessageBird Dashboard → Developers → Webhooks) - Add request validation — Validate all webhook payload fields before processing
- Enable CORS properly — Configure cross-origin policies for your specific use case
Scalability Improvements
- Use Redis for rate limiting — Distribute rate limits across multiple server instances
- Implement message queues — Process webhooks asynchronously using Bull or RabbitMQ
- Add horizontal scaling — Deploy behind a load balancer for high-traffic scenarios
- Cache frequently used data — Reduce API calls by caching configuration and lookup data
- Monitor performance — Track response times, error rates, and throughput metrics
Reliability Features
- Add dead letter queues — Store failed webhook processing attempts for retry
- Implement circuit breakers — Protect against cascade failures from external dependencies
- Use health checks — Monitor server availability with
/healthendpoints - Set up alerts — Notify your team of webhook failures, rate limit hits, or API errors
- Log aggregation — Centralize logs using services like LogDNA, Datadog, or ELK stack
Error Handling
// Add global error handler
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error, url: request.url }, 'Unhandled error');
if (error.statusCode) {
reply.status(error.statusCode).send({
error: error.message,
statusCode: error.statusCode
});
} else {
reply.status(500).send({
error: 'Internal Server Error',
statusCode: 500
});
}
});
// Graceful shutdown
const closeGracefully = async (signal) => {
app.log.info(`Received ${signal}, closing server gracefully`);
await app.close();
process.exit(0);
};
process.on('SIGTERM', closeGracefully);
process.on('SIGINT', closeGracefully);Troubleshooting Common MessageBird Webhook Issues
Why Is My Webhook Not Receiving Messages?
- Verify URL accessibility — Ensure your webhook URL is publicly accessible via HTTPS
- Check MessageBird configuration — Confirm webhook is active and pointing to correct URL
- Review firewall settings — Allow inbound traffic on your server's webhook port
- Test with ngrok — Use ngrok to expose localhost for initial testing
- Check server logs — Look for incoming requests and any error messages
Testing commands:
# Test webhook endpoint manually
curl -X POST https://your-domain.com/webhooks/inbound-sms \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "id=test123&originator=+15551234567&recipient=+14155552671&body=test&createdDatetime=2025-10-05T12:00:00Z"
# Check if port is accessible
nc -zv your-domain.com 443
# View real-time logs
tail -f server.logWhy Is MessageBird Signature Verification Failing?
- Confirm signing key — Verify your
.envfile contains the correctMESSAGEBIRD_WEBHOOK_SIGNING_KEY - Check header name — Ensure you're reading
messagebird-signature-jwtheader (lowercase) - Verify raw body access — Signature verification requires the unparsed request body
- Update SDK version — Ensure you're using a current version of the MessageBird SDK
- Test with MessageBird tools — Use MessageBird's webhook testing feature to verify configuration
Why Aren't My Auto-Replies Sending?
- Validate API key — Confirm your
MESSAGEBIRD_API_KEYhas sending permissions - Check originator format — Ensure your originator number is in E.164 format (e.g., +14155552671)
- Review account balance — Verify your MessageBird account has sufficient credits
- Monitor rate limits — Check if you're exceeding MessageBird's API rate limits (500 POST requests/second)
- Examine error logs — Review detailed error messages in your Fastify logs
Common MessageBird API error codes:
2— Request not allowed (invalid API key)9— Missing required parameters10— Invalid parameter format20— Resource not found25— Insufficient account balance429— Rate limit exceeded (500 POST/s, 50 GET/s limits)
How to Fix High Latency in SMS Processing
- Optimize reply generation — Cache static responses and minimize processing time
- Move to async processing — Use message queues for non-urgent replies
- Reduce retry timeouts — Adjust
minTimeoutandmaxTimeoutin retry configuration - Check network latency — Test connectivity between your server and MessageBird's API
- Monitor server resources — Ensure adequate CPU, memory, and network bandwidth
Advanced Features for Two-Way SMS Systems
Build upon this foundation with advanced features:
How to Store SMS Messages in a Database
Store inbound messages for analytics and compliance. For a complete implementation guide, see our tutorial on building SMS marketing campaigns with database integration:
// Example with PostgreSQL
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Create table schema
/*
CREATE TABLE inbound_sms (
id SERIAL PRIMARY KEY,
message_id VARCHAR(255) UNIQUE NOT NULL,
sender VARCHAR(20) NOT NULL,
recipient VARCHAR(20) NOT NULL,
body TEXT NOT NULL,
received_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_sender ON inbound_sms(sender);
CREATE INDEX idx_received_at ON inbound_sms(received_at);
*/
async function storeInboundMessage(messageData) {
await pool.query(
'INSERT INTO inbound_sms (message_id, sender, recipient, body, received_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (message_id) DO NOTHING',
[messageData.id, messageData.originator, messageData.recipient, messageData.body, messageData.createdDatetime]
);
}How to Implement AI-Powered SMS Responses
Implement AI-powered responses or integrate with customer support systems:
const { OpenAI } = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function generateAIReply(message, sender) {
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: 'You are a helpful customer service assistant. Keep responses under 160 characters.' },
{ role: 'user', content: message }
],
max_tokens: 50,
temperature: 0.7
});
return response.choices[0].message.content.substring(0, 160);
}How to Track Multi-Step SMS Conversations
Track multi-step conversations using Redis:
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function getConversationState(phoneNumber) {
const state = await redis.get(`conversation:${phoneNumber}`);
return JSON.parse(state) || { step: 0, data: {} };
}
async function updateConversationState(phoneNumber, state) {
await redis.setex(`conversation:${phoneNumber}`, 3600, JSON.stringify(state));
}
// Example: Multi-step appointment booking
async function handleConversationalReply(sender, message) {
const state = await getConversationState(sender);
if (state.step === 0) {
await updateConversationState(sender, { step: 1, data: {} });
return 'Would you like to book an appointment? Reply YES or NO';
} else if (state.step === 1 && message.toLowerCase().includes('yes')) {
await updateConversationState(sender, { step: 2, data: { confirmed: true } });
return 'Great! What date works for you? (Format: MM/DD/YYYY)';
} else if (state.step === 2) {
await updateConversationState(sender, { step: 3, data: { ...state.data, date: message } });
return `Appointment requested for ${message}. We'll confirm within 24 hours!`;
}
return 'Sorry, I didn\'t understand. Reply HELP for assistance.';
}How to Handle SMS Opt-Out Requests Automatically
Handle unsubscribe requests automatically:
const optOutList = new Set(); // In production, use database
function generateReplyMessage(incomingMessage, senderNumber) {
const message = incomingMessage.toLowerCase().trim();
// Handle opt-out per TCPA requirements (effective April 11, 2025)
// Accept any reasonable opt-out language: STOP, QUIT, END, CANCEL, UNSUBSCRIBE, REVOKE, OPT OUT
if (/\b(stop|quit|end|cancel|unsubscribe|revoke|opt out)\b/i.test(message)) {
optOutList.add(senderNumber);
// Must process immediately or within 24 hours per TCPA
return 'You have been unsubscribed. Reply START to resubscribe. Msg&Data rates may apply.';
}
if (message === 'start' && optOutList.has(senderNumber)) {
optOutList.delete(senderNumber);
return 'You have been resubscribed. Reply STOP to opt out. Msg&Data rates may apply.';
}
// Check opt-out status before responding
if (optOutList.has(senderNumber)) {
return null; // Don't send any messages to opted-out numbers
}
// ... rest of your reply logic ...
}Legal compliance for opt-outs: Under the TCPA (effective April 11, 2025), businesses must honor opt-out requests made "in any reasonable manner", including keywords like STOP, QUIT, END, REVOKE, CANCEL, UNSUBSCRIBE, or OPT OUT. Process opt-outs immediately or within 24 hours maximum. Include opt-out instructions in initial message: "Reply STOP to unsubscribe." Maintain opt-out list indefinitely to prevent re-enrollment. For GDPR compliance in EU: honor right-to-erasure requests within 1 month, delete conversation data when no longer needed for the original purpose, maintain audit logs for 6-7 years for legal/tax purposes.
Frequently Asked Questions
How much does MessageBird SMS cost?
MessageBird pricing varies by destination country and message volume. Inbound SMS messages typically cost between $0.005-$0.01 per message, while outbound SMS pricing ranges from $0.01-$0.50+ depending on the destination country. Volume discounts are available for high-traffic applications. Check MessageBird's pricing page for current rates specific to your target countries.
Can I send MMS messages with this setup?
This tutorial focuses on SMS messaging. To add MMS support, modify the sendAutoReply function to include a mediaUrls parameter containing HTTPS URLs of images, videos, or audio files (up to 5 MB per file):
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [recipient],
body: replyText,
mediaUrls: ['https://example.com/image.jpg'] // Add for MMS
};Note that MMS support and pricing vary by country—verify MMS availability for your target regions in the MessageBird dashboard.
How do I handle multiple virtual numbers in one application?
Store a mapping of phone numbers to response logic in your database or configuration file. In your webhook handler, read the recipient field from the webhook payload to identify which MessageBird number received the message, then route to the appropriate response logic. This approach allows you to manage multiple SMS campaigns, departments, or brands from a single Fastify application.
What's the maximum message throughput for this setup?
Fastify can handle 20,000-30,000 requests per second on modern hardware, far exceeding typical SMS webhook volumes. Your actual throughput will be limited by MessageBird's API rate limits (500 POST requests/second for sending messages) and your server's resources. For high-volume scenarios exceeding 100 messages per second, implement message queuing with Redis or RabbitMQ to process webhooks asynchronously.
How do I test MessageBird webhooks locally?
Expose your local development server to the internet with ngrok: ngrok http 3000. Copy the generated HTTPS URL (e.g., https://abc123.ngrok.io) and add your webhook path (/webhooks/inbound-sms) to create the full webhook URL for MessageBird's dashboard. Ngrok provides request inspection for debugging webhook payloads.
Can I use TypeScript with MessageBird and Fastify?
Yes! Both Fastify and MessageBird provide official TypeScript type definitions. Install @types/node and create a tsconfig.json file:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}The MessageBird SDK includes built-in TypeScript types, while Fastify offers comprehensive type support for routes, plugins, and request/reply objects.
How do I implement GDPR compliance for SMS messaging?
Store explicit opt-in consent records with timestamps and IP addresses, honor opt-out requests immediately (within minutes, not hours), provide clear unsubscribe instructions in every marketing message (e.g., "Reply STOP to unsubscribe"), maintain audit logs of all message activity, and implement data retention policies to delete conversation data after your legally required retention period.
Specific requirements: Under GDPR Article 17 (Right to Erasure), respond to deletion requests within 1 month. Delete conversation data when no longer needed for the original purpose (typically 30-90 days for transactional messages, longer for contractual obligations). Retain billing/invoice records for 6-7 years per tax laws, but pseudonymize personal identifiers. Implement automated deletion workflows to purge old conversation data. Document your legal basis for processing (consent, contract, legitimate interest) and retention policies in your privacy policy.
What's the difference between MessageBird and Bird?
MessageBird rebranded to Bird in early 2024 as part of a broader platform consolidation. The SMS API, Node.js SDK (npm package messagebird), and webhook functionality remain unchanged. Existing MessageBird accounts continue to work without migration. The Bird platform adds enhanced CRM and marketing automation features, but the core SMS infrastructure documented in this tutorial remains fully supported and operational.
How do I monitor webhook health and uptime?
Implement a /health endpoint (as shown in the complete code example) for external monitoring services like UptimeRobot or Pingdom. Track key metrics including webhook response time (aim for <500 ms), error rate (should be <1%), signature verification failures, and MessageBird API success rates. Use application performance monitoring (APM) tools like New Relic, Datadog, or Sentry for detailed insights into webhook processing performance. Set alerts for: response time >1 s, error rate >5%, webhook failures >10/hour, or API rate limit hits.
Can I rate limit specific phone numbers that spam my webhook?
Yes. Extend the rate limiting configuration to track both IP addresses and phone numbers:
const phoneRateLimits = new Map(); // In production, use Redis
app.addHook('preHandler', async (request, reply) => {
if (request.url === '/webhooks/inbound-sms') {
const sender = request.body?.originator;
if (sender) {
const now = Date.now();
const limit = phoneRateLimits.get(sender) || { count: 0, resetAt: now + 60000 };
if (now > limit.resetAt) {
limit.count = 0;
limit.resetAt = now + 60000;
}
limit.count++;
phoneRateLimits.set(sender, limit);
if (limit.count > 10) { // Max 10 messages per minute per number
reply.code(429).send({ error: 'Rate limit exceeded for this phone number' });
return;
}
}
}
});Store rate limit violations in Redis to maintain state across server restarts and enable blocking of persistent spam numbers at the application level.
Conclusion
You've built a production-ready two-way SMS messaging system with MessageBird, Node.js, and Fastify. Your implementation includes webhook signature verification, automated reply logic, rate limiting, and error handling.
Key takeaways:
- Receive inbound SMS messages through MessageBird webhooks with JWT signature verification
- Send automated responses based on message content using the MessageBird SDK
- Handle errors gracefully with retry logic and comprehensive logging
- Protect your endpoint with rate limiting and security best practices
- Scale horizontally to handle high-volume SMS traffic
Next steps to enhance your SMS system:
- Deploy to a production environment (AWS, Google Cloud, Azure, Heroku, or Railway)
- Add database integration to store conversation history and enable analytics
- Implement advanced reply logic with natural language processing or AI models
- Set up monitoring and alerting for webhook failures and performance degradation
- Scale horizontally with load balancers and message queues for enterprise traffic
For more advanced MessageBird features, explore their official documentation and SMS API reference. Check out related guides on bulk SMS campaigns, message scheduling, and WhatsApp Business API integration for multi-channel messaging strategies.