code examples
code examples
MessageBird Webhook Tutorial: Track SMS Delivery Status with Node.js Express
Build a production-ready MessageBird webhook system for real-time SMS delivery tracking. Complete Node.js Express tutorial with HMAC signature verification, status code handling, MongoDB integration, and ngrok testing setup.
MessageBird SMS Delivery Status & Webhooks: Complete Node.js Express Guide
Build a production-ready webhook system to track SMS delivery status in real time using MessageBird's REST API. This comprehensive tutorial shows you how to send SMS messages and receive instant delivery updates through MessageBird webhooks using Node.js and Express. You'll implement HMAC-SHA256 signature verification, status code handling, retry logic, and production security with complete working code examples.
What You'll Build
Create an SMS tracking system with:
- MessageBird SMS API Integration: Send messages programmatically through the official Node.js SDK
- Webhook Endpoint: Receive real-time delivery status updates from MessageBird
- Signature Verification: Secure your webhooks with HMAC-SHA256 cryptographic validation
- Database Tracking: Store message status in MongoDB for audit trails and analytics
- Ngrok Tunneling: Expose your local Express server for webhook testing
- Production Security: Implement rate limiting, HTTPS enforcement, and replay attack prevention
Why Use Webhooks for SMS Delivery Tracking?
Webhook vs. polling performance comparison:
| Metric | Webhooks | API Polling (every 30s) |
|---|---|---|
| Average notification delay | <2 seconds | 15 seconds (average) |
| API requests per 1,000 messages | 1–5 (webhook deliveries) | 2,000+ (polling calls) |
| Server overhead | Minimal (passive receiver) | High (active polling loop) |
| Scalability | Linear (no polling load) | Degrades with message volume |
| Cost efficiency | Free webhook notifications | Consumes API quota/rate limits |
Webhooks deliver immediate notification when message status changes, eliminating polling. MessageBird sends instant updates when messages are sent, delivered, failed, or expired.
Benefits of webhook-based delivery tracking:
- Real-time status updates: Receive notifications within seconds of status changes
- Reduced API calls: Eliminate repeated polling of status endpoints
- Cost efficiency: Minimize API requests and bandwidth usage
- Better user experience: Update customers immediately about message delivery
- Comprehensive tracking: Capture all status transitions including intermediate states
- Scalability: Handle high-volume messaging without performance degradation
How to Set Up Your MessageBird Node.js Project
System requirements:
- Node.js 18+ (minimum 0.10; 18.18 LTS recommended for production)
- npm or yarn package manager
- MongoDB 4.0+ for message tracking
- Valid MessageBird account with API access
According to MessageBird's official Node.js documentation, the MessageBird Node.js SDK requires Node.js 0.10 at minimum. For production deployments in 2025, use Node.js 18.18 LTS or later for security updates and performance improvements.
Initialize your Node.js project and install dependencies:
mkdir messagebird-webhooks
cd messagebird-webhooks
npm init -y
npm install messagebird express body-parser mongoose dotenv ngrokPackage overview:
- messagebird: Official MessageBird Node.js SDK (supports Node.js >= 0.10)
- express: Lightweight web framework for webhook endpoints
- body-parser: Middleware for parsing JSON webhook payloads
- mongoose: MongoDB ODM for message tracking
- dotenv: Environment variable management for API keys
- ngrok: Creates secure tunnels to localhost for webhook testing
Where to Obtain MessageBird API Keys
To get your MessageBird Access Key:
- Sign up for a free account at MessageBird Dashboard
- After login, your API key appears on the main Dashboard page
- If not visible, navigate to Developers → API Access
- Click Add access key to generate a new key
- Copy the key (starts with
live_for production ortest_for testing)
To get your MessageBird Signing Key:
- In the MessageBird Dashboard, navigate to Developers → Settings
- Click Webhooks in the left sidebar
- Locate the Signing Key section
- Copy the signing key (starts with
sk_) - If compromised, click Regenerate to create a new signing key
Official MessageBird API documentation provides detailed information on API key management and security best practices.
Create your .env file with MessageBird credentials:
MESSAGEBIRD_ACCESS_KEY=your_live_access_key_here
MESSAGEBIRD_SIGNING_KEY=your_signing_key_here
MONGO_URI=mongodb://localhost:27017/messagebird
PORT=3000Security note: Never commit your .env file to version control. Add it to .gitignore immediately.
How to Send SMS Messages with MessageBird
Create a function to send SMS using the MessageBird SDK. Before receiving webhook delivery updates, send SMS messages:
// sendSMS.js
require('dotenv').config();
const messagebird = require('messagebird').initClient(process.env.MESSAGEBIRD_ACCESS_KEY);
async function sendSMS(recipient, message, originator = 'YourBrand') {
return new Promise((resolve, reject) => {
messagebird.messages.create({
originator: originator, // Sender ID (max 11 alphanumeric chars)
recipients: [recipient], // Array of E.164 formatted numbers
body: message // Message content (160 chars per SMS part)
}, (err, response) => {
if (err) {
console.error('MessageBird API Error:', err);
return reject(err);
}
console.log('✓ Message sent successfully');
console.log('Message ID:', response.id);
console.log('Recipients:', response.recipients);
resolve(response);
});
});
}
// Example usage
sendSMS('+1234567890', 'Hello from MessageBird!')
.then(response => {
console.log('Message ID for tracking:', response.id);
})
.catch(error => {
console.error('Failed to send SMS:', error);
});
module.exports = sendSMS;Key parameters explained:
- originator: Sender ID displayed to recipients. Can be alphanumeric (max 11 chars) or E.164 phone number. Note: Alphanumeric sender IDs are not supported in all countries including the United States. Use a purchased phone number for US messaging.
- recipients: Array of phone numbers in E.164 format (e.g.,
+14155552671) - body: Message content. SMS messages are limited to 160 characters. Longer messages split into multiple parts, each billed separately.
Test the SMS function:
node sendSMS.jsWhen the message sends successfully, MessageBird returns a message ID. This ID appears in webhook payloads for delivery status tracking. According to MessageBird's SMS tutorial, you can track message delivery through webhooks or by polling the Messages API. For more information on phone number formatting, see the E.164 phone number format guide.
How to Verify Webhook Signatures with HMAC-SHA256
MessageBird signs all webhook requests with HMAC-SHA256 cryptographic signatures to prevent unauthorized access and replay attacks. Always verify webhook signatures before processing the payload to ensure security.
Understanding Webhook Signature Verification
Webhook signature verification ensures incoming requests originate from MessageBird and haven't been tampered with. MessageBird generates a cryptographic signature using your signing key and includes it in the MessageBird-Signature-JWT header.
Why signature verification matters:
- Prevents spoofing: Blocks malicious actors from sending fake webhook requests
- Data integrity: Confirms the payload hasn't been modified during transmission
- Replay attack prevention: Detects and blocks duplicate webhook deliveries
- Compliance: Meets security requirements for PCI DSS and GDPR
How to Implement Signature Verification
Create a middleware file to verify all incoming webhooks:
// middleware/webhookVerify.js
const mbWebhookSignatureJwt = require('messagebird/lib/webhook-signature-jwt');
function createWebhookVerifier() {
const signingKey = process.env.MESSAGEBIRD_SIGNING_KEY;
if (!signingKey) {
throw new Error('MESSAGEBIRD_SIGNING_KEY environment variable is required');
}
const verifyOptions = new mbWebhookSignatureJwt.VerifyOptions();
// Add jti (JWT ID) verification to prevent replay attacks
// Note: In-memory Set works for single-server deployments
// For production clusters, use Redis or similar distributed cache
const seenJtis = new Set();
verifyOptions.jwtVerifyJti = (jti) => {
// Check if this JWT ID has been processed before
if (seenJtis.has(jti)) {
console.warn(`Duplicate webhook detected with JTI: ${jti}`);
return false; // Reject duplicate
}
// Mark this JWT ID as processed
seenJtis.add(jti);
// In production: implement TTL cleanup to prevent memory growth
// Webhooks expire after ~5 minutes, so JTIs older than 1 hour can be purged
return true;
};
// Return Express middleware that validates signature automatically
return new mbWebhookSignatureJwt.ExpressMiddlewareVerify(signingKey, verifyOptions);
}
module.exports = createWebhookVerifier;Important security considerations:
- Signing key location: Find your signing key in the MessageBird Dashboard under Developers → Settings → Webhooks
- JWT structure: MessageBird uses JWT format with these claims:
url_hash,payload_hash,jti,nbf,exp - Expiration: Webhooks include an
exp(expiration) claim to prevent processing stale requests - JTI tracking limitations: The in-memory
Setworks for single-server deployments but doesn't persist across restarts. For production clusters with multiple servers, store JTIs in Redis with 1-hour TTL. Memory usage: ~50 bytes per JTI; 1 million webhooks ≈ 50 MB memory. - Error handling: Return 401 Unauthorized for invalid signatures to signal MessageBird for retry
Where to Find Your MessageBird Signing Key
- Log into MessageBird Dashboard
- Navigate to Developers → Settings → Webhooks
- Locate the Signing Key section
- Copy the signing key (starts with
sk_) - Add it to your
.envfile asMESSAGEBIRD_SIGNING_KEY
Security warning: Never expose your signing key in client-side code or public repositories.
Understanding SMS Delivery Status Codes
MessageBird provides three levels of status information to track every stage of SMS delivery:
Primary Status Codes
| Status | Description | Typical Timing | Action Required |
|---|---|---|---|
sent | Message accepted by carrier and awaiting delivery | Immediate (0–2s) | Monitor for final delivery confirmation |
buffered | Message queued for delivery during carrier congestion | 0–30 seconds | Normal – delivery continues automatically |
delivered | Message successfully delivered to recipient's device | 2–30 seconds | No action – final success state |
delivery_failed | Message failed to deliver to recipient | 30s – 48 hours | Check statusReason and statusErrorCode for root cause |
expired | Message exceeded TTL without delivery | 48 hours (default) | Review carrier connectivity and phone number validity |
Status transition timing expectations:
- Most messages reach
deliveredstatus within 2–10 seconds - Carrier congestion can delay delivery up to 30 seconds (
bufferedstatus) - MessageBird retries failed deliveries for up to 48 hours before marking as
expired - Temporary failures (
statusErrorCode: 6) may resolve within minutes to hours
Secondary Status Reasons
The statusReason field provides additional context about delivery failures:
- absent_subscriber: Recipient's phone is off or outside coverage area
- invalid_destination: Phone number is invalid or no longer in service
- unknown_subscriber: Phone number doesn't exist in carrier network
- rejected_by_carrier: Carrier blocked message (spam filters, content violations)
- blacklisted: Number is on your MessageBird blacklist
Tertiary Error Codes
The statusErrorCode field provides carrier-specific error details:
- 0: No error (successful delivery)
- 5: Permanent error – number invalid or non-existent
- 6: Temporary error – recipient unavailable, retry possible
- 9: Unknown error – contact MessageBird support
Example webhook payload with all status levels:
{
"message": {
"id": "e8077d803532c0b5937c639b60216938",
"status": "delivery_failed",
"statusReason": "absent_subscriber",
"statusErrorCode": 6
}
}How to Handle SMS Delivery Status Codes
Implement appropriate actions based on status with exponential backoff retry logic:
// statusHandler.js
function handleDeliveryStatus(status, statusReason, statusErrorCode, messageId) {
switch(status) {
case 'delivered':
// Success – update database, notify user
console.log('✓ Message delivered successfully');
// Mark message as delivered in database
// Trigger success webhook to your application
break;
case 'delivery_failed':
if (statusErrorCode === 6) {
// Temporary failure – schedule retry with exponential backoff
console.log('⚠ Temporary failure, scheduling retry');
scheduleRetry(messageId, statusReason);
} else if (statusErrorCode === 5) {
// Permanent failure – mark invalid, don't retry
console.log('✗ Permanent failure, number invalid');
markNumberInvalid(messageId);
// Remove from future send lists
} else {
// Unknown error (code 9) – log for investigation
console.log(`⚠ Unknown error: ${statusReason}`);
// Alert monitoring system
}
break;
case 'expired':
// TTL exceeded – log for investigation
console.log('⏱ Message expired before delivery');
// Check carrier connectivity issues
// Review message validity period settings
break;
case 'buffered':
// Message queued – normal operation
console.log('📦 Message buffered by carrier');
// No action needed, wait for delivered status
break;
default:
console.log(`Status: ${status}`);
}
}
// Exponential backoff retry scheduler for temporary failures
function scheduleRetry(messageId, reason) {
const retryAttempts = getRetryCount(messageId); // Get from database
if (retryAttempts >= 5) {
console.log(`Max retries exceeded for message ${messageId}`);
return;
}
// Exponential backoff: 1min, 5min, 15min, 1hr, 3hr
const delays = [60, 300, 900, 3600, 10800];
const delaySeconds = delays[retryAttempts];
console.log(`Scheduling retry #${retryAttempts + 1} in ${delaySeconds}s`);
// Use job queue (Bull/BullMQ) for production
setTimeout(() => {
retryMessage(messageId);
}, delaySeconds * 1000);
}
function markNumberInvalid(messageId) {
// Add to blacklist or invalid numbers table
console.log(`Marking message ${messageId} recipient as invalid`);
}
function getRetryCount(messageId) {
// Retrieve from database
return 0; // Placeholder
}
function retryMessage(messageId) {
// Resend message logic
console.log(`Retrying message ${messageId}`);
}
module.exports = handleDeliveryStatus;How to Build a Webhook Endpoint in Express
Create your Express server with webhook handling and SMS sending integration:
// server.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const createWebhookVerifier = require('./middleware/webhookVerify');
const sendSMS = require('./sendSMS');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Database connection
mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
}).then(() => {
console.log('✓ MongoDB connected');
}).catch(err => {
console.error('MongoDB connection error:', err);
});
// Message schema
const messageSchema = new mongoose.Schema({
messageId: { type: String, required: true, unique: true },
recipient: String,
originator: String,
body: String,
status: String,
statusReason: String,
statusErrorCode: Number,
statusDatetime: Date,
retryCount: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const Message = mongoose.model('Message', messageSchema);
// API endpoint to send SMS
app.post('/api/send-sms', async (req, res) => {
try {
const { recipient, message, originator } = req.body;
if (!recipient || !message) {
return res.status(400).json({ error: 'recipient and message are required' });
}
// Send SMS via MessageBird
const response = await sendSMS(recipient, message, originator);
// Store message in database for tracking
await Message.create({
messageId: response.id,
recipient: recipient,
originator: response.originator,
body: message,
status: 'sent',
createdAt: new Date()
});
res.status(200).json({
success: true,
messageId: response.id,
status: 'sent'
});
} catch (error) {
console.error('SMS sending error:', error);
res.status(500).json({ error: 'Failed to send SMS' });
}
});
// Webhook endpoint with signature verification
const webhookVerifier = createWebhookVerifier();
app.post('/webhooks/delivery-status', webhookVerifier, async (req, res) => {
try {
const payload = req.body;
console.log('Received webhook:', JSON.stringify(payload, null, 2));
const { message } = payload;
if (!message || !message.id) {
return res.status(400).json({ error: 'Invalid payload' });
}
// Update or create message record
await Message.findOneAndUpdate(
{ messageId: message.id },
{
messageId: message.id,
recipient: message.recipients?.[0]?.recipient,
status: message.status,
statusReason: message.statusReason,
statusErrorCode: message.statusErrorCode,
statusDatetime: message.statusDatetime,
updatedAt: new Date()
},
{ upsert: true, new: true }
);
console.log(`✓ Updated message ${message.id} with status: ${message.status}`);
// Return 200 OK immediately to acknowledge receipt
// Process heavy operations asynchronously
res.status(200).json({ received: true });
// Handle status asynchronously after responding
handleDeliveryStatus(
message.status,
message.statusReason,
message.statusErrorCode,
message.id
);
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
// Query message status
app.get('/api/message/:messageId', async (req, res) => {
try {
const message = await Message.findOne({ messageId: req.params.messageId });
if (!message) {
return res.status(404).json({ error: 'Message not found' });
}
res.status(200).json(message);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/delivery-status`);
});Key implementation details:
- Signature verification: Applied via middleware before processing payload
- Idempotency:
findOneAndUpdatewithupsertprevents duplicate processing - Error handling: Returns appropriate HTTP status codes to signal retry behavior
- Response timing: Returns 200 OK quickly to prevent MessageBird timeouts (respond within 5 seconds)
- Database indexing:
messageIdfield is unique and indexed for fast lookups - Send/Receive integration:
/api/send-smsendpoint sends messages and stores initial records; webhook updates delivery status
How to Test Webhooks Locally with Ngrok
Ngrok creates a secure HTTPS tunnel to your local development server, enabling MessageBird to deliver webhook callbacks during testing. Learn more about webhook testing best practices in MessageBird's developer documentation.
Understanding Ngrok for Local Webhook Testing
Ngrok exposes your localhost server to the internet with a public URL. MessageBird requires HTTPS webhooks, which ngrok provides automatically with valid SSL certificates.
Install and start ngrok:
# Install ngrok globally
npm install -g ngrok
# Start ngrok tunnel on port 3000
ngrok http 3000Ngrok output:
Forwarding https://abc123.ngrok.io -> http://localhost:3000
Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io).
Configuring Webhooks in MessageBird Dashboard
- Log into MessageBird Dashboard
- Navigate to Developers → Settings → Webhooks
- Click Add Webhook
- Enter your ngrok URL + endpoint path:
https://abc123.ngrok.io/webhooks/delivery-status - Select Message Status as the webhook type
- Click Save
Testing tips:
- Free tier limitation: Ngrok free tier generates new URLs on restart – update MessageBird dashboard each time
- Webhook logs: View delivery logs in MessageBird Dashboard under Developers → Webhooks → Activity
- Request inspection: Access ngrok inspector at
http://localhost:4040to debug webhook payloads - Timeout prevention: Ensure your webhook endpoint responds within 5 seconds
Common ngrok troubleshooting:
- "Tunnel not found" error: Restart ngrok and update webhook URL in MessageBird Dashboard
- Connection refused: Verify your Express server runs on the correct port
- HTTPS certificate errors: Ngrok automatically provides valid SSL certificates; if issues persist, update ngrok:
npm update -g ngrok - Rate limiting: Ngrok free tier limits 40 connections/minute; upgrade for production testing
Production Security Best Practices for Webhooks
Implement these security measures before deploying to production:
1. HTTPS Enforcement
Force HTTPS for all webhook endpoints:
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.status(403).json({ error: 'HTTPS required' });
}
next();
});2. Rate Limiting
Install the rate limiting package:
npm install express-rate-limitPrevent abuse with rate limiting:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // Limit each IP to 1,000 requests per window
message: 'Too many webhook requests',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/webhooks/delivery-status', webhookLimiter, webhookVerifier, async (req, res) => {
// Webhook handler code
});3. Timestamp Validation
Integrate timestamp validation into webhook handler to reject stale requests:
function validateTimestamp(timestamp, maxAgeSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const age = now - timestamp;
return age >= 0 && age <= maxAgeSeconds;
}
// Use in webhook handler
app.post('/webhooks/delivery-status', webhookVerifier, async (req, res) => {
try {
const payload = req.body;
// Extract timestamp from JWT claims (nbf or iat)
// MessageBird SDK validates expiration automatically
// Additional check for message freshness
if (payload.message?.statusDatetime) {
const statusTimestamp = new Date(payload.message.statusDatetime).getTime() / 1000;
if (!validateTimestamp(statusTimestamp, 3600)) { // 1 hour max age
console.warn('Webhook payload too old, rejecting');
return res.status(400).json({ error: 'Stale webhook' });
}
}
// Continue processing...
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});4. IP Allowlisting
Important note: According to MessageBird's official documentation, MessageBird's REST API uses dynamic IP addresses from globally distributed infrastructure, making IP allowlisting impractical. MessageBird recommends using signature verification (covered above) instead of IP filtering.
Alternative: Signature verification is mandatory
MessageBird explicitly states: "It will not be possible to whitelist the IP range for our REST API since our IP addresses are dynamic." Instead, always implement webhook signature verification using the signing key method described in this guide. This provides stronger security than IP allowlisting while accommodating MessageBird's distributed architecture.
If your organization requires network-level security, implement these alternatives:
- Use a reverse proxy (nginx, Cloudflare) with rate limiting
- Deploy webhook endpoints behind a VPN or private network
- Implement request logging and anomaly detection
- Monitor webhook activity in MessageBird Dashboard for suspicious patterns
5. JTI Persistence with Redis
For production deployments with multiple servers, store JWT IDs in Redis:
npm install redisImplement distributed JTI tracking:
const redis = require('redis');
// Create Redis client with connection handling
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
return new Error('Redis connection failed');
}
return retries * 100; // Exponential backoff
}
}
});
client.on('error', (err) => {
console.error('Redis client error:', err);
});
client.on('connect', () => {
console.log('✓ Redis connected');
});
// Connect to Redis
client.connect().catch(console.error);
// Check and store JTI with automatic expiration
async function checkJti(jti) {
try {
const key = `jti:${jti}`;
const exists = await client.get(key);
if (exists) {
console.warn(`Duplicate JTI detected: ${jti}`);
return false; // Reject duplicate
}
// Store JTI with 1-hour TTL (3,600 seconds)
// Webhooks expire after ~5 minutes, 1 hour provides safety margin
await client.setEx(key, 3600, '1');
return true; // Accept new JTI
} catch (error) {
console.error('Redis JTI check error:', error);
// Fail open: allow webhook if Redis is down
// Alternative: fail closed by returning false
return true;
}
}
// Update webhook verifier to use Redis
function createWebhookVerifier() {
const signingKey = process.env.MESSAGEBIRD_SIGNING_KEY;
if (!signingKey) {
throw new Error('MESSAGEBIRD_SIGNING_KEY environment variable is required');
}
const verifyOptions = new mbWebhookSignatureJwt.VerifyOptions();
// Use Redis for distributed JTI verification
verifyOptions.jwtVerifyJti = async (jti) => {
return await checkJti(jti);
};
return new mbWebhookSignatureJwt.ExpressMiddlewareVerify(signingKey, verifyOptions);
}
module.exports = { createWebhookVerifier, redisClient: client };Monitoring and Debugging Webhook Deliveries
Viewing Webhook Delivery Logs in Dashboard
MessageBird Dashboard provides comprehensive webhook activity logs:
- Navigate to Developers → Webhooks → Activity
- View delivery attempts with timestamps
- Check response status codes from your endpoint
- Review retry attempts for failed deliveries
Common debugging scenarios:
- 401 Unauthorized: Signature verification failed – verify signing key matches dashboard
- 500 Internal Server Error: Your endpoint crashed – check application logs
- Timeout errors: Endpoint didn't respond within 5 seconds – optimize processing
- Connection refused: Endpoint unreachable – verify URL and firewall rules
Testing Webhook Endpoints Locally
Important note: Testing webhooks with curl requires generating valid JWT signatures. The MessageBird SDK handles signature generation automatically for real webhooks. For local testing, use these approaches:
Option 1: Bypass signature verification for testing (NOT for production):
// test-webhook.js
const express = require('express');
const app = express();
app.use(express.json());
// Test endpoint WITHOUT signature verification
app.post('/webhooks/delivery-status-test', async (req, res) => {
console.log('Test webhook received:', JSON.stringify(req.body, null, 2));
res.status(200).json({ received: true });
});
app.listen(3001, () => {
console.log('Test server running on port 3001');
});Test with curl:
curl -X POST http://localhost:3001/webhooks/delivery-status-test \
-H "Content-Type: application/json" \
-d '{
"message": {
"id": "test123",
"status": "delivered",
"recipients": [{"recipient": "31612345678"}]
}
}'Option 2: Send real SMS and monitor webhooks (recommended):
- Configure ngrok tunnel to your local server
- Add ngrok URL to MessageBird Dashboard webhooks
- Send test SMS via
/api/send-smsendpoint - Monitor webhook deliveries in real-time with proper signatures
# Terminal 1: Start server
node server.js
# Terminal 2: Start ngrok
ngrok http 3000
# Terminal 3: Send test SMS
curl -X POST http://localhost:3000/api/send-sms \
-H "Content-Type: application/json" \
-d '{
"recipient": "+1234567890",
"message": "Test message",
"originator": "TestApp"
}'
# Monitor webhooks in Terminal 1 server logsTesting checklist:
- Test all status codes:
sent,delivered,delivery_failed,expired - Verify signature validation rejects invalid JWTs
- Test duplicate webhook handling with same JTI
- Simulate network failures with delayed responses
- Monitor database updates for each webhook
- Verify retry logic for temporary failures
Frequently Asked Questions
How do I verify webhook signatures?
MessageBird signs webhooks with HMAC-SHA256 and sends the signature in the MessageBird-Signature-JWT header. Use the official MessageBird SDK's webhook-signature-jwt module to verify signatures automatically. The SDK validates the JWT structure, expiration, and payload hash. Implement JTI (JWT ID) verification to prevent replay attacks by tracking processed JWT IDs in memory or Redis.
What are MessageBird's webhook retry policies?
MessageBird retries failed webhook deliveries using exponential backoff. If your endpoint returns a non-2xx status code or times out, MessageBird retries up to 10 times over 24 hours. The retry schedule: immediate, 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours, and 24 hours. Return HTTP 200 OK for successful processing, even if business logic fails internally.
What SMS delivery status codes does MessageBird provide?
MessageBird provides three status levels: status (primary), statusReason (secondary), and statusErrorCode (tertiary). Primary statuses: sent, buffered, delivered, delivery_failed, and expired. Status reasons: absent_subscriber, invalid_destination, unknown_subscriber, rejected_by_carrier, and blacklisted. Error codes: 0 (no error), 5 (permanent failure), 6 (temporary failure), 9 (unknown error).
How do I handle duplicate webhooks?
Implement idempotent webhook processing using the message ID as a unique identifier. Use database operations like findOneAndUpdate with upsert to handle duplicates safely. Track JWT IDs (jti claim) in memory or Redis to detect and reject duplicate deliveries. MessageBird may send the same webhook multiple times during network issues or retries.
Why is my webhook endpoint timing out?
MessageBird requires webhook endpoints to respond within 5 seconds. Return HTTP 200 OK immediately after receiving the webhook, then process heavy operations asynchronously. Use job queues like Bull or BullMQ for background processing:
// Install Bull: npm install bull
const Queue = require('bull');
const webhookQueue = new Queue('webhooks', process.env.REDIS_URL);
// Webhook handler - respond immediately
app.post('/webhooks/delivery-status', webhookVerifier, async (req, res) => {
// Queue webhook for async processing
await webhookQueue.add(req.body);
// Respond immediately (< 1 second)
res.status(200).json({ received: true });
});
// Process webhooks in background
webhookQueue.process(async (job) => {
const payload = job.data;
// Heavy processing here: database updates, external APIs, etc.
await processWebhook(payload);
});Optimize database queries with proper indexing. Avoid synchronous external API calls in webhook handlers. Monitor endpoint response times with APM tools.
What security measures should I implement for webhooks?
Implement these security measures: (1) Always verify webhook signatures using the signing key, (2) Enforce HTTPS in production, (3) Apply rate limiting to prevent abuse, (4) Validate timestamp freshness (reject requests older than 5 minutes), (5) Track JWT IDs to prevent replay attacks, (6) Note that IP allowlisting is not recommended by MessageBird due to dynamic IPs, (7) Implement request logging for audit trails.
How do I test webhooks locally?
Use ngrok to create a secure HTTPS tunnel to your local server: ngrok http 3000. Copy the ngrok HTTPS URL and configure it in MessageBird Dashboard under Developers → Webhooks. Start your Express server and send test SMS messages. Monitor webhook deliveries in the ngrok inspector (http://localhost:4040) and your server logs. Update the webhook URL in MessageBird Dashboard whenever ngrok restarts (free tier generates new URLs).
What should I do when webhook delivery fails?
Check the webhook activity logs in MessageBird Dashboard under Developers → Webhooks → Activity. Common failures: (1) 401 Unauthorized – verify signing key matches dashboard, (2) Timeout – optimize endpoint to respond within 5 seconds, (3) Connection refused – verify endpoint URL and server status, (4) 500 errors – check application logs for exceptions. MessageBird automatically retries failed deliveries up to 10 times.
Can I use webhooks without signature verification?
No. Always implement signature verification to prevent unauthorized access and spoofed webhook requests. MessageBird provides the signing key and official SDK for verification. Skipping signature verification exposes your system to malicious actors who could send fake delivery status updates, corrupt your database, or trigger unauthorized actions. Signature verification is a security requirement, not optional.
How do I migrate from API polling to webhooks?
Replace polling logic with webhook handlers: (1) Create webhook endpoint in Express, (2) Implement signature verification middleware, (3) Configure webhook URL in MessageBird Dashboard, (4) Remove polling code that calls messages.read() repeatedly, (5) Update database schema to track webhook deliveries, (6) Test with ngrok for local development, (7) Deploy with HTTPS enabled. Webhooks eliminate API rate limit concerns and provide real-time updates.
Summary: Building Production-Ready SMS Webhooks
You've built a complete MessageBird webhook system with Node.js and Express that tracks SMS delivery status in real time. The system includes cryptographic signature verification using HMAC-SHA256, comprehensive status code handling across three levels (status, statusReason, statusErrorCode), MongoDB persistence for audit trails, and production security with rate limiting and replay attack prevention.
Key implementation requirements:
- Always verify webhook signatures using the
MessageBird-Signature-JWTheader - Return HTTP 200 OK within 5 seconds to prevent MessageBird retries
- Implement idempotent processing with database upserts on message ID
- Track JWT IDs to detect and reject duplicate webhook deliveries
- Handle all status codes appropriately: delivered (success), delivery_failed (retry or mark invalid), expired (investigate)
Production deployment checklist:
- Configure HTTPS webhook endpoint with valid SSL certificate
- Update MessageBird Dashboard with production webhook URL
- Implement rate limiting (1,000 requests per 15 minutes recommended)
- Deploy Redis for distributed JTI tracking across multiple servers
- Enable monitoring with APM tools (New Relic, Datadog, Application Insights)
- Set up alerting for webhook delivery failures
- Document signing key rotation procedures
- Test failover scenarios and backup webhook endpoints
- Review MessageBird activity logs weekly for anomalies
Next steps to implement this system:
- Clone the code examples into your project
- Install dependencies:
npm install messagebird express body-parser mongoose dotenv ngrok redis - Create
.envfile with MessageBird API keys from dashboard - Start MongoDB and Redis services locally
- Run the Express server:
node server.js - Set up ngrok tunnel for testing:
ngrok http 3000 - Configure webhook URL in MessageBird Dashboard
- Send test SMS and monitor webhook deliveries
- Deploy to production with HTTPS and monitoring
Performance expectations:
- Webhook latency: <500ms per request (database update + response)
- Throughput: 1,000+ webhooks per second on single Express instance
- Memory usage: ~50 MB base + ~50 bytes per cached JTI
- Database load: One upsert query per webhook (use connection pooling)
- Scaling: Horizontally scalable with Redis for JTI tracking
Next steps for enhancement:
- Implement retry logic for temporary delivery failures (statusErrorCode 6)
- Add webhook event logging for compliance and debugging
- Build dashboard for real-time delivery analytics
- Integrate with notification systems for critical failures
- Implement dead letter queue for unprocessable webhooks
- Add webhook payload validation with JSON schema
- Create automated tests for webhook endpoint
The webhook system provides real-time visibility into SMS delivery, eliminates polling overhead, and scales efficiently for high-volume messaging. Monitor delivery rates, optimize for carrier-specific issues, and continuously review security practices to maintain reliable SMS communications.
Frequently Asked Questions
How to send SMS with MessageBird and Node.js?
Use the MessageBird Node.js SDK and Express.js to create a POST route that handles sending SMS messages. This route should extract recipient and message details from the request body, construct the necessary parameters for the MessageBird API, and use the SDK's `messages.create()` method to send the SMS. Remember to store the returned message ID for tracking delivery status.
What is a webhook in MessageBird?
A webhook is a mechanism for receiving real-time updates from MessageBird about the status of your sent messages (like 'delivered', 'sent', or 'failed'). MessageBird sends an HTTP POST request to a URL you specify, containing the status details. In your application, create an Express route to handle these incoming requests.
Why use dotenv with MessageBird API key?
Dotenv loads environment variables from a `.env` file. This is essential for securely storing sensitive information, like your MessageBird API key, and preventing it from being exposed in your source code or version control systems like Git. Add your API key to the `.env` file with `MESSAGEBIRD_API_KEY=YOUR_API_KEY`.
When to use ngrok with MessageBird webhooks?
ngrok is useful during development to expose your local server to the internet, allowing you to receive MessageBird webhooks. Since webhooks require a publicly accessible URL, ngrok creates a secure tunnel. It's a development tool; in production, you would use your deployed server's public URL.
Can I send SMS to multiple recipients?
Yes, the MessageBird API supports sending SMS messages to multiple recipients. In the `recipients` parameter of your `messagebird.messages.create` request, provide an array of phone numbers in E.164 format. Ensure you handle responses and status updates for each recipient individually if needed.
How to track SMS delivery status with MessageBird?
Implement a webhook handler in your Express app as a POST route (e.g., `/messagebird-status`). This route will receive status updates from MessageBird. Extract the message ID from the request body to correlate it with the original message you sent. Store status updates in a database for persistent tracking.
What is the role of message ID in MessageBird?
The message ID is a unique identifier assigned by MessageBird to each SMS message you send. It's crucial for correlating delivery status updates received via webhooks with the original message. You'll use this ID to look up and update the message status in your database.
How to handle MessageBird webhook errors?
Acknowledge the webhook by responding with a 2xx HTTP status (e.g., 200 OK) even if errors occur in your processing logic. This prevents MessageBird from retrying the webhook unnecessarily. Log internal errors thoroughly for investigation and implement retry mechanisms or queueing where applicable.
What database can I use for storing MessageBird statuses?
You should use a persistent database for production applications. The tutorial demonstrates setting up MongoDB (install `mongodb` driver, connect, insert, update). Other suitable databases include PostgreSQL, MySQL, or cloud-based solutions like MongoDB Atlas or Google Cloud Firestore.
How to set up a MessageBird webhook endpoint?
In the MessageBird dashboard, go to Developers > API access or Flow Builder, then configure the URL for your webhook handler. This URL should be publicly accessible, so you may use ngrok for local development. In production, use your server's domain/IP + route path. The webhook receives POST requests from MessageBird.
Why does MessageBird use E.164 phone format?
E.164 is an international standard phone number format (+[country code][number]). It's essential for ensuring compatibility and proper delivery of SMS messages globally. Always format numbers in E.164 before sending them to the MessageBird API.
When should I use a test API key in MessageBird?
Use a test API key during development to integrate with MessageBird's platform without incurring real SMS costs. Test keys help verify that API requests are structured correctly and that webhook setups are working as expected. Switch to your live API key once you're ready to send actual messages.
How to install the necessary Node.js packages?
Use npm or yarn to install the required packages. The article outlines these: `npm install express messagebird dotenv` for the main dependencies and `npm install --save-dev nodemon` for development tools (nodemon auto-restarts your server during coding).
What does the nodemon package do?
Nodemon automatically restarts your Node.js server whenever you make changes to your code. It's a development convenience tool that saves you from manually restarting the server every time you edit a file, speeding up development considerably. Install with npm as dev dependency.