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.
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.
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.
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
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 API
dotenv — Loads environment variables from .env file for secure credential management
pino-pretty — Pretty-prints Fastify's JSON logs for easier development debugging
qs — Parses URL-encoded webhook payloads from MessageBird
async-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:
MESSAGEBIRD_API_KEY — Your live API key from MessageBird dashboard
MESSAGEBIRD_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 (development or production)
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:
JavaScript
// 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 loggingconst app = fastify({
logger: {
transport: {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
colorize: true
}
}
},
// Enable raw body for signature verificationdisableRequestLogging: false,
bodyLimit: 1048576// 1MB limit
});
// Initialize MessageBird clientconst mbClient = messagebird.initClient(process.env.MESSAGEBIRD_API_KEY);
// ... webhook handlers will go here ...// Start the serverconststart = 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 bodyLimit accommodates 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:
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
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:
JavaScript
/**
* 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
*/asyncfunctionsendAutoReply(recipient, originalMessage, logger) {
// Generate dynamic reply based on message contentconst replyText = generateReplyMessage(originalMessage);
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR,
recipients: [recipient],
body: replyText
};
try {
// Use retry logic for resilient API callsconst response = awaitretry(
async (bail) => {
returnnewPromise((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
*/functiongenerateReplyMessage(incomingMessage) {
const message = incomingMessage.toLowerCase().trim();
// Simple keyword-based responsesif (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 responsereturn`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:
JavaScript
// Register rate limiting plugin (add near top of file, after Fastify initialization)
app.register(require('@fastify/rate-limit'), {
max: 100, // Maximum requests per timeWindowtimeWindow: '1 minute',
cache: 10000,
allowList: [], // Add trusted IPs here if availableredis: null, // Use Redis for distributed rate limiting in productionskipOnError: true,
keyGenerator: (request) => {
// Rate limit by IP addressreturn 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
Signing Key: Copy this key to your .env file as MESSAGEBIRD_WEBHOOK_SIGNING_KEY
Test webhook using MessageBird's test feature
Activate webhook after successful testing
Local development with ngrok:
Bash
# 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 settings
How to Test Your Two-Way SMS Implementation
Test your two-way SMS system:
Start your server:
Bash
node server.js
Send a test SMS to your MessageBird virtual number from your mobile phone
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_KEY matches the key shown in MessageBird dashboard, ensure raw body parser is registered before route handler, check header name is lowercase messagebird-signature-jwt
500 server errors: Check server logs for stack traces, verify MESSAGEBIRD_API_KEY is valid, ensure all dependencies are installed (npm install)
Reply not received: Verify MESSAGEBIRD_ORIGINATOR is 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:
// Example with PostgreSQLconst { Pool } = require('pg');
const pool = newPool({ 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);
*/asyncfunctionstoreInboundMessage(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:
constRedis = require('ioredis');
const redis = newRedis(process.env.REDIS_URL);
asyncfunctiongetConversationState(phoneNumber) {
const state = await redis.get(`conversation:${phoneNumber}`);
returnJSON.parse(state) || { step: 0, data: {} };
}
asyncfunctionupdateConversationState(phoneNumber, state) {
await redis.setex(`conversation:${phoneNumber}`, 3600, JSON.stringify(state));
}
// Example: Multi-step appointment bookingasyncfunctionhandleConversationalReply(sender, message) {
const state = awaitgetConversationState(sender);
if (state.step === 0) {
awaitupdateConversationState(sender, { step: 1, data: {} });
return'Would you like to book an appointment? Reply YES or NO';
} elseif (state.step === 1 && message.toLowerCase().includes('yes')) {
awaitupdateConversationState(sender, { step: 2, data: { confirmed: true } });
return'Great! What date works for you? (Format: MM/DD/YYYY)';
} elseif (state.step === 2) {
awaitupdateConversationState(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:
JavaScript
const optOutList = newSet(); // In production, use databasefunctiongenerateReplyMessage(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 OUTif (/\b(stop|quit|end|cancel|unsubscribe|revoke|opt out)\b/i.test(message)) {
optOutList.add(senderNumber);
// Must process immediately or within 24 hours per TCPAreturn'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 respondingif (optOutList.has(senderNumber)) {
returnnull; // 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):
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:
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:
JavaScript
const phoneRateLimits = newMap(); // 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
Start by setting up a new Node.js project and installing Fastify, the MessageBird SDK, and necessary dependencies like dotenv, qs, async-retry, and @fastify/rate-limit. Create a project structure with directories for source code, routes, and environment variables. Finally, set up your .env file with your MessageBird API key, webhook signing key, and originator information from your MessageBird dashboard. Use npm run dev to start the server in development mode with automatic restarts, or npm start for production.
It's crucial for verifying the authenticity of incoming webhooks, ensuring they originate from MessageBird. It's used to generate a signature that your application compares against the signature received in the webhook request headers, preventing unauthorized access to your application from external sources and potential malicious actors.
Because MessageBird webhooks use the 'application/x-www-form-urlencoded' content type. It's necessary to handle webhook signature verification which requires the raw request body. The custom parser added in app.js stores the raw body string and then parses the form data reliably using the qs library. This raw body is needed in src/routes/webhook.js within the verifyMessageBirdSignature function.
Once your server is running, send a POST request to the /api/send-message endpoint with a JSON payload containing the recipient's phone number and message content. Use a tool like curl or Postman to send the request, ensuring a valid E.164 formatted number is used. You should receive a JSON response with the message ID and status. Make sure that validation is done at the API endpoint. Failure to comply with the format will result in error messages being returned.
In the MessageBird dashboard, go to Flow Builder, create a custom flow triggered by SMS, and choose your number. In the next step, select "Forward to URL" and paste your ngrok HTTPS URL with the secret path from your .env file. Use POST and no authentication, then save and publish. Ensure that ngrok is used to tunnel the SMS message between your Fastify app and MessageBird. Note that changes to the webhook path should also be reflected in the Flow Builder configuration.
ngrok creates a secure tunnel from a public URL to your localhost server, essential for development as it makes your local app accessible to MessageBird's webhooks. Run 'ngrok http 3000' to create the tunnel and use the HTTPS URL it provides when configuring the webhook in MessageBird's flow builder.
The provided code implements a pre-handler hook that verifies the signature using an HMAC-SHA256 algorithm with your webhook signing key. The hook extracts necessary data from the header and combines the raw body of the request with the message timestamp to recompute the signature. If verification fails, the hook returns a 401 error, thereby blocking all other handlers.
Use async-retry for outgoing API calls (like sending SMS replies) to gracefully handle temporary network issues or MessageBird API hiccups. The code example demonstrates its use with messagebird.messages.create, retrying up to 3 times with exponential backoff. The provided code demonstrates the use of async-retry and how to use the library.
It signals to MessageBird that you've received and processed the webhook successfully, preventing MessageBird from retrying the webhook. This should be done even if your app fails to send the reply. Reply failures should be handled asynchronously using techniques such as queues. This helps make the application robust against transient failures without being retried unnecessarily by MessageBird.
Use a long, random secret path for your webhook URL, stored in the WEBHOOK_SECRET_PATH environment variable. The code checks for this variable and prevents start-up if the path is too short or missing. This increases security making it more difficult to discover and trigger your endpoint from external sources.
The app.js file demonstrates a best practice for handling errors. The provided code includes a global error handler, which helps to consistently format all error responses from the application and enhances maintainability. The response formatting also distinguishes between client errors and server errors. This helps the user differentiate between errors which were caused by them and those where the server was responsible.
pino-pretty formats Fastify's log output for better readability during development. In production, Fastify uses JSON logging which can be processed by logging and monitoring systems. Log levels are set via the LOG_LEVEL environment variable. This helps enhance debugging locally during development while optimizing for log aggregators.
Create separate directories for routes (webhook.js, api.js), keep your main application logic in app.js, and manage environment variables with .env. The provided project structure uses /src/routes which enhances maintainability and keeps the logic for separate concerns isolated.
Yes, Prisma can be integrated for database interactions (optional). You would install the necessary Prisma dependencies and configure the DATABASE_URL environment variable. The code provides comments and .gitignore entries related to Prisma. It is up to the user to further configure the application to use the database if desired.
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:
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:
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:
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
idfield 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:
Package 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 abuseHow to Configure Environment Variables for MessageBird
Create a
.envfile 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
.envfile asMESSAGEBIRD_WEBHOOK_SIGNING_KEY. Store this key securely—you cannot retrieve it again after leaving the page.Security note: Add
.envto your.gitignorefile to prevent accidentally committing sensitive credentials to version control.How to Create a Fastify Server for Inbound SMS Webhooks
Create a file named
server.jsand 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:
messagebird.initClient()with your API key0.0.0.0) for webhook accessbodyLimitaccommodates typical webhook payloads (usually 1-5 KB) while preventing memory exhaustion from malformed requestsHow 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-JWTheader for webhook authentication. This replaces older HMAC-based verification methods. Always use the SDK's built-in verification for security.Security considerations:
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) andexp(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:
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"), andmessageLength(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:
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:
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:
https://your-domain.com/webhooks/inbound-sms.envfile asMESSAGEBIRD_WEBHOOK_SIGNING_KEYLocal 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:
Send a test SMS to your MessageBird virtual number from your mobile phone
Monitor server logs to verify:
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:
curl https://your-domain.com/webhooks/inbound-sms), check MessageBird dashboard webhook status is "Active", confirm virtual number has webhook configuredMESSAGEBIRD_WEBHOOK_SIGNING_KEYmatches the key shown in MessageBird dashboard, ensure raw body parser is registered before route handler, check header name is lowercasemessagebird-signature-jwtMESSAGEBIRD_API_KEYis valid, ensure all dependencies are installed (npm install)MESSAGEBIRD_ORIGINATORis in E.164 format with country code, check MessageBird account balance is sufficient, review MessageBird error codes in logsComplete 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
MESSAGEBIRD_WEBHOOK_SIGNING_KEYperiodically (regenerate via MessageBird Dashboard → Developers → Webhooks)Scalability Improvements
Reliability Features
/healthendpointsError 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?
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?
.envfile contains the correctMESSAGEBIRD_WEBHOOK_SIGNING_KEYmessagebird-signature-jwtheader (lowercase)Why Aren't My Auto-Replies Sending?
MESSAGEBIRD_API_KEYhas sending permissionsCommon 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
minTimeoutandmaxTimeoutin retry configurationAdvanced 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
sendAutoReplyfunction to include amediaUrlsparameter 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
recipientfield 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/nodeand create atsconfig.jsonfile:{ "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
/healthendpoint (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:
Next steps to enhance your SMS system:
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.