messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / MessageBird

MessageBird Fastify Webhook Tutorial: Handle Delivery Status Callbacks

Build a secure Fastify webhook endpoint to receive real-time SMS delivery status updates from MessageBird/Bird API with signature verification and error handling.

Build a robust webhook endpoint using Fastify to receive and process real-time message delivery status updates from MessageBird (now Bird). This guide walks you through project setup, deployment, and verification to reliably track sent message status.

Focus on handling incoming Delivery Status Reports (DLRs) via webhooks from MessageBird. Build a dedicated Fastify endpoint that securely receives these updates, logs them, and acknowledges receipt to MessageBird – a crucial component of any application requiring message delivery confirmation.

Note: MessageBird rebranded to Bird in 2023. API endpoints, SDKs, and webhook functionality remain compatible. This guide uses "MessageBird" for consistency with existing implementations, but Bird documentation references apply equally.

Project Overview and Goals

Why Webhooks Matter:

Without webhooks, you're blind to delivery failures, delays, and carrier rejections. Webhooks enable:

  • Real-time delivery tracking: Update user dashboards showing message status
  • Failed delivery handling: Retry messages or switch to alternative channels
  • Compliance: Maintain audit trails showing delivery confirmation
  • Cost optimization: Identify and stop sending to invalid numbers
  • User experience: Send app notifications when 2FA codes are delivered

What You'll Build:

A Node.js application using Fastify that exposes a secure webhook endpoint. This endpoint will:

  1. Receive POST requests from MessageBird containing message delivery status updates
  2. Securely verify incoming requests using MessageBird's webhook signature
  3. Parse the status update payload
  4. Log relevant status information
  5. Respond with 200 OK to acknowledge receipt

Problem Solved:

When you send SMS or messages via MessageBird, you need confirmation that messages were delivered, failed, or changed status. The initial API response isn't enough. MessageBird provides webhooks that push status updates to your application in real-time, enabling you to update internal systems, notify users, or trigger actions based on delivery success or failure.

Technologies:

  • Node.js: Runtime environment for the JavaScript application
  • Fastify: High-performance web framework chosen for speed, extensibility, and built-in logging and schema validation (compatible with Fastify 4.x)
  • MessageBird (Bird): Communications platform providing the messaging API and delivery status webhooks
  • dotenv: Loads environment variables from .env into process.env, keeping sensitive keys out of source code
  • Node.js crypto module: Verifies webhook signatures

System Architecture:

mermaid
graph LR
    A[Your Application] -- 1. Send Message API Request --> B(MessageBird API);
    B -- 2. Sends Message --> C(End User Device);
    C -- 3. Sends Delivery Status --> B;
    B -- 4. POST Delivery Status Webhook --> D(Your Fastify Webhook Endpoint);
    D -- 5. Verify Signature & Process --> E{Logs / Database / Queue};
    D -- 6. Send 200 OK Response --> B;

    style D fill:#f9f,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js 14.x or higher (MessageBird SDK minimum v0.10, but Node.js 14+ recommended for modern features and security)
  • npm 6.x or higher, or yarn 1.22+
  • MessageBird account with API credentials:
    • Sign up for free to receive test credits
    • After registration, navigate to Developers → API access (REST) in the MessageBird Dashboard to access your API keys
    • Test API Keys (prefix: test_): Test API connectivity without sending messages or consuming credits. Note: Test keys are not supported for Conversations API. Use for development and integration testing. (Source)
    • Live API Keys (no prefix): Send actual messages and consume account balance. You can use free test credits provided on new accounts.
  • ngrok or similar tunneling tool to expose local development server to the internet for webhook testing (Download ngrok)
  • Basic understanding of JavaScript, Node.js, APIs, and webhooks

1. Set Up the Project

Initialize the Node.js project and install dependencies.

  1. Create Project Directory:

    bash
    mkdir fastify-messagebird-webhook
    cd fastify-messagebird-webhook
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Dependencies:

    bash
    npm install fastify dotenv
  4. Create Project Structure:

    bash
    mkdir src
    touch src/server.js
    touch .env
    touch .gitignore
  5. Configure .gitignore: Prevent sensitive files from being committed to version control:

    text
    # Environment variables
    .env*
    !.env.example
    
    # Node dependencies
    node_modules/
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    lerna-debug.log*
    
    # Build output
    dist
    build
    
    # OS generated files
    .DS_Store
    Thumbs.db
  6. Configure .env: Add placeholders for MessageBird credentials and server configuration. Obtain the MESSAGEBIRD_WEBHOOK_SIGNING_KEY from the MessageBird dashboard when configuring webhooks (see Section 4).

    bash
    # .env
    
    # Server Configuration
    PORT=3000
    HOST=0.0.0.0
    
    # MessageBird Configuration
    # Webhook Signing Key: Obtain from MessageBird Dashboard → Developers → API Settings → Webhooks → Add webhook
    # This key is generated when you create a webhook and is used to verify incoming webhook signatures
    MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_MESSAGEBIRD_WEBHOOK_SIGNING_KEY
    • PORT: Port where your Fastify server listens
    • HOST: Network interface to bind to (0.0.0.0 makes it accessible from outside Docker containers or VMs)
    • MESSAGEBIRD_WEBHOOK_SIGNING_KEY: Secret key from MessageBird for verifying webhook signatures (NOT your API Key). Generated when you add a webhook in the dashboard (covered in Section 4).
  7. Set Up Initial Fastify Server (src/server.js):

    javascript
    // src/server.js
    
    // Load environment variables from .env file
    require('dotenv').config();
    
    // Require the framework and instantiate it
    const fastify = require('fastify')({
        logger: true // Enable built-in logger
    });
    
    // Basic route to check if the server is running
    fastify.get('/', async (request, reply) => {
        return { hello: 'world' };
    });
    
    // Run the server
    const start = async () => {
        try {
            const port = process.env.PORT || 3000;
            const host = process.env.HOST || '0.0.0.0';
            await fastify.listen({ port: parseInt(port, 10), host: host });
            fastify.log.info(`Server listening on ${fastify.server.address().port}`);
        } catch (err) {
            fastify.log.error(err);
            process.exit(1);
        }
    };
    
    start();
  8. Add Start Script to package.json:

    json
    {
      "name": "fastify-messagebird-webhook",
      "version": "1.0.0",
      "description": "Fastify webhook handler for MessageBird delivery status",
      "main": "src/server.js",
      "scripts": {
        "start": "node src/server.js",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [ "fastify", "messagebird", "webhook" ],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "dotenv": "^16.x.x",
        "fastify": "^4.x.x"
      }
    }
  9. Run the Server:

    bash
    npm start

    You should see output indicating the server is running on port 3000. Visit http://localhost:3000 in your browser to see the { "hello": "world" } response. Stop the server with Ctrl+C.

2. Implementing the Core Webhook Handler

Understanding the Webhook Lifecycle:

When you send an SMS via MessageBird, the API returns an initial status (typically sent). As the message progresses through the carrier network, MessageBird receives Delivery Reports (DLRs) from the carrier and forwards these updates to your webhook endpoint. The webhook lifecycle is:

  1. Message sent → Initial status returned in API response
  2. Carrier processes message → Intermediate statuses (e.g., buffered)
  3. Final delivery outcome → MessageBird receives DLR from carrier
  4. MessageBird sends webhook → POST request to your endpoint
  5. Your endpoint processes and responds with 200 OK
  6. MessageBird logs successful delivery (or retries on failure)

Retry Behavior: If MessageBird doesn't receive a 2xx response within several seconds, it will retry webhook delivery up to 10 times with exponential backoff. Always respond quickly with 200 OK and process heavy operations asynchronously. (Source)

Now, create the endpoint that will receive MessageBird's status updates.

  1. Define the Webhook Route (src/server.js): MessageBird sends status updates via POST request. Define a route, /webhooks/messagebird, to handle these. Add the following code inside src/server.js, before the start function definition.

    javascript
    // src/server.js (add inside the file, before the start function)
    
    fastify.post('/webhooks/messagebird', async (request, reply) => {
        fastify.log.info('Received MessageBird webhook');
        fastify.log.info({ headers: request.headers }, 'Incoming Headers');
        fastify.log.info({ body: request.body }, 'Incoming Body');
    
        // Signature verification and payload processing will be added in later steps.
    
        // Acknowledge receipt to MessageBird immediately.
        // Send a 200 OK quickly – MessageBird may retry without a timely 200 response.
        reply.code(200).send({ status: 'received' });
    });
    
    // ... rest of the server code (start function, etc.)
    • Log the incoming request headers and body for debugging.
    • Send a 200 OK response immediately. Important: Handle long-running processes asynchronously (e.g., push to a queue) after sending the 200 OK to avoid MessageBird timeouts and retries.
  2. Understanding the Payload: MessageBird's delivery status webhook payload typically looks like this (structure may vary slightly based on event type and API version):

    json
    {
      "id": "message_id_string",
      "href": "https://rest.messagebird.com/messages/message_id_string",
      "recipient": 31612345678,
      "originator": "YourSenderID",
      "body": "Your message text",
      "reference": "YourOptionalReference",
      "status": "delivered",
      "statusDatetime": "2025-04-20T10:30:00+00:00",
      "type": "sms",
      "mccmnc": "20408",
      "price": {
        "amount": 0.075,
        "currency": "EUR"
      }
    }

    Complete Status Values (verified from MessageBird SMS API documentation):

    StatusDescription
    scheduledMessage scheduled for future delivery (API only, set via scheduledDatetime parameter)
    sentMessage sent and left the MessageBird platform (temporary status, awaiting carrier DLR)
    bufferedMessage queued for delivery, pending DLR from carrier (temporary status)
    deliveredMessage successfully delivered to recipient with positive DLR confirmation
    expiredMessage not delivered within validity period (default validity or custom validity parameter)
    delivery_failedMessage delivery failed with negative DLR from carrier

    Status Reasons (accompanying statusReason field provides additional detail):

    • successfully delivered – Positive DLR received
    • pending DLR – Awaiting delivery report from carrier
    • DLR not received – No DLR received before validity expiration
    • unknown subscriber – Recipient number not associated with active line (error code 1)
    • unavailable subscriber – Recipient temporarily unreachable (error codes 8, 27-29, 31, 33)
    • expired – Validity period expired before delivery attempt
    • opted out – Recipient revoked consent to receive messages (error code 103)
    • received network error – Carrier network issue preventing delivery (error codes 7, 12, 21, 30, 34-36, 39-40, 71)
    • insufficient balance – Account balance too low (error code 100)
    • carrier rejected – Carrier blocked message due to registration requirements (error codes 104-105, 110)
    • capacity limit reached – Campaign volume/throughput limits exceeded (error codes 106-107)
    • generic delivery failure – No detailed information available from carrier

    Focus primarily on id, status, statusDatetime, and potentially recipient or reference to correlate the update with your original message.

  3. Processing the Payload: Add basic processing logic to log the key information. Update the /webhooks/messagebird route handler in src/server.js:

    javascript
    // src/server.js - Update the '/webhooks/messagebird' route handler
    
    fastify.post('/webhooks/messagebird', async (request, reply) => {
        fastify.log.info('Received MessageBird webhook');
        // Implement signature verification before processing in a later step.
    
        // Process Payload (Basic Logging Example)
        try {
            const payload = request.body;
    
            if (payload && payload.id && payload.status) {
                const messageId = payload.id;
                const status = payload.status;
                const statusTimestamp = payload.statusDatetime;
                const recipient = payload.recipient;
    
                // --- Placeholder for Real Application Logic ---
                // In a production application, you would typically:
                // 1. Look up the message in your database using `messageId`.
                // 2. Update its status to the received `status`.
                // 3. Store the `statusTimestamp`.
                // 4. Trigger notifications or workflows based on the status (e.g., alert on failures).
                // 5. Push complex processing to a background queue.
                // For this guide, log the information only.
                // --- End Placeholder ---
                fastify.log.info(`Message ID [${messageId}] for recipient [${recipient}] updated to status [${status}] at [${statusTimestamp}]`);
    
            } else {
                fastify.log.warn('Received webhook with missing id or status');
            }
    
        } catch (error) {
            fastify.log.error(error, 'Error processing webhook payload');
            // Even if processing fails, send 200 OK
            // unless the failure is critical (like signature mismatch, handled later).
        }
    
        // Acknowledge receipt to MessageBird
        reply.code(200).send({ status: 'received' });
    });

    This code attempts to parse the id, status, statusDatetime, and recipient from the request body and logs them.

3. Building a Complete API Layer (Optional Send Endpoint)

While the core goal is handling incoming webhooks, adding an endpoint to send a message via MessageBird helps test the full loop. You can skip this section if you plan to test webhooks only by sending messages from other applications or the MessageBird dashboard.

This requires the official MessageBird Node.js SDK.

  1. Install MessageBird SDK:

    bash
    npm install messagebird
  2. Add API Key to .env: You'll need your Live API Key from the MessageBird Dashboard (Developers → API access (REST)). Add it to your .env file.

    bash
    # .env (add this line)
    # Live API Key: Use to send real messages (consumes balance/test credits)
    # Test API Key (test_ prefix): Use to test API connectivity without sending messages
    # Get from: MessageBird Dashboard → Developers → API access (REST)
    MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY # Replace with your actual API key

    API Key Types:

    • Live API Key (no prefix): Sends actual messages, deducts from account balance. You can use free test credits on new accounts.
    • Test API Key (test_ prefix): Tests API connectivity only; no messages sent, no credits consumed. Not supported for Conversations API.

    (Source)

    Replace the placeholder with your actual key.

  3. Implement Send Endpoint (src/server.js): Add a new route to trigger sending an SMS. Add this code inside src/server.js, after require('dotenv').config(); and before the webhook route.

    javascript
    // src/server.js (add near the top, after require('dotenv').config())
    const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY);
    
    // ... (inside the server setup, before the webhook route and start function)
    
    // Endpoint to send a test SMS
    fastify.post('/send-test-sms', async (request, reply) => {
        const { recipient, message } = request.body;
    
        if (!recipient || !message) {
            reply.code(400).send({ error: 'Missing recipient or message in request body' });
            return;
        }
    
        // Basic validation (adjust as needed)
        if (!/^\+?[1-9]\d{1,14}$/.test(recipient)) {
             reply.code(400).send({ error: 'Invalid recipient phone number format (E.164 expected)' });
             return;
        }
    
        const params = {
            originator: 'TestApp', // Change to your approved originator or number
            recipients: [ recipient ],
            body: message
            // reportUrl: 'YOUR_PUBLIC_WEBHOOK_URL/webhooks/messagebird' // Optional: Set webhook URL per message
        };
    
        fastify.log.info({ params }, 'Attempting to send SMS');
    
        try {
            const result = await new Promise((resolve, reject) => {
                messagebird.messages.create(params, (err, response) => {
                    if (err) {
                        fastify.log.error(err, 'MessageBird API error');
                        return reject(err);
                    }
                    fastify.log.info({ response }, 'MessageBird API success response');
                    resolve(response);
                });
            });
            reply.code(201).send({ success: true, messageId: result.id, status: result.recipients.items[0].status });
    
        } catch (error) {
             // Determine appropriate status code based on MessageBird error if possible
            const statusCode = error.statusCode || 500;
            const errorMessage = error.errors ? error.errors[0].description : 'Failed to send SMS via MessageBird';
            reply.code(statusCode).send({ success: false, error: errorMessage });
        }
    });
    • Requires the messagebird SDK.
    • Reads recipient and message from the POST body.
    • Includes basic validation for the phone number (E.164 format expected).
    • Uses messagebird.messages.create to send the SMS.
    • Handles success and error responses from the MessageBird API using Promises.
    • Important: Set originator to a valid sender ID or Virtual Mobile Number registered in your MessageBird account. Alphanumeric IDs have restrictions.
    • The reportUrl parameter in messages.create can override the default webhook URL set in the dashboard for specific messages, but rely on the dashboard setting primarily.
  4. Testing the Send Endpoint: Once your server is running (restart it after adding the new code and API key), use curl or a tool like Postman:

    bash
    curl -X POST http://localhost:3000/send-test-sms \
         -H "Content-Type: application/json" \
         -d '{ "recipient": "+1XXXXXXXXXX", "message": "Hello from Fastify!" }'
    # Replace +1XXXXXXXXXX with a valid E.164 test phone number

4. Integrating with MessageBird (Webhook Configuration)

Connect MessageBird's status updates to your running application.

  1. Expose Your Local Server: MessageBird needs a publicly accessible URL to send webhooks to. During development, tools like ngrok are essential.

    • Install ngrok (if you haven't already): Visit https://ngrok.com/download
    • Run it to expose your local port (e.g., 3000):
      bash
      ngrok http 3000
    • ngrok will provide a public URL (e.g., https://<random_string>.ngrok.io). Copy this HTTPS URL.
  2. Configure Webhook in MessageBird Dashboard:

    • Log in to your MessageBird Dashboard
    • Navigate to Developers in the left-hand menu
    • Click on API Settings
    • Go to the Webhooks tab
    • Click Add webhook
    • Event: Select message.updated. This event triggers for status changes like sent, delivered, failed, etc.
    • URL: Paste your public HTTPS URL from ngrok, followed by your webhook route: https://<random_string>.ngrok.io/webhooks/messagebird
    • Method: Ensure POST is selected
    • Signing Key: MessageBird will generate a Webhook Signing Key. Copy this value immediately and securely – it's only displayed once.
    • Click Add webhook

    (Source)

  3. Update .env with Signing Key: Paste the Webhook Signing Key (NOT your API key) you copied from the MessageBird dashboard into your .env file:

    bash
    # .env
    # ... other variables
    MESSAGEBIRD_WEBHOOK_SIGNING_KEY=paste_your_webhook_signing_key_here # Replace with the key generated in the MessageBird Dashboard

    Restart your Fastify server for the new environment variable to load (Ctrl+C then npm start).

5. Implementing Error Handling and Logging

Enhance error handling, especially around webhook processing.

  1. Consistent Error Handling: The current try...catch in the webhook handler is a good start. Ensure critical errors (like signature failure, added later) prevent further processing and potentially return a non-200 status (though MessageBird generally prefers 200 OK for acknowledgment, so log the failure).

  2. Logging Levels: Fastify's logger supports levels (info, warn, error, debug, etc.). Use them appropriately:

    • info: Standard operations (webhook received, status processed)
    • warn: Non-critical issues (payload missing optional fields, unexpected status)
    • error: Critical failures (signature mismatch, database errors, unhandled exceptions)
  3. Retry Mechanisms:

    • MessageBird Retries: MessageBird automatically retries webhook delivery up to 10 times if it doesn't receive a 2xx response within several seconds. Your primary goal is to respond quickly with 200 OK. (Source)
    • Internal Retries: If processing the webhook after sending the 200 OK fails (e.g., database connection issue), implement your own retry logic. Use libraries like async-retry or push the job to a queue (like BullMQ, RabbitMQ) that handles retries with exponential backoff. This is beyond the scope of the basic handler but essential for production robustness.
  4. Log Management Best Practices:

    • Structured Logging: Fastify uses Pino, which outputs JSON logs by default – ideal for parsing by log aggregation tools
    • Log Rotation: Use pino-pretty for development and log rotation tools like logrotate (Linux) or Winston transports in production
    • Centralized Storage: Aggregate logs in systems like ELK Stack (Elasticsearch, Logstash, Kibana), Loki, Datadog Logs, or CloudWatch Logs
    • Retention Policies: Retain webhook logs for at least 30-90 days for troubleshooting and compliance

6. Creating a Database Schema and Data Layer (Conceptual)

Store message statuses for historical tracking and analytics.

Choosing a Database:

  • PostgreSQL: Best for complex queries, ACID compliance, full-text search
  • MySQL/MariaDB: Widely supported, good performance, familiar syntax
  • MongoDB: Schema flexibility, scales horizontally, JSON-native
  • SQLite: Lightweight, serverless, ideal for low-volume applications
  1. Conceptual ERD:

    mermaid
    erDiagram
        MESSAGES ||--o{ MESSAGE_STATUS_UPDATES : has
        MESSAGES {
            string messageId PK "MessageBird ID"
            string recipient
            string originator
            string body
            string initialStatus
            datetime createdAt
            datetime updatedAt
        }
        MESSAGE_STATUS_UPDATES {
            int updateId PK
            string messageId FK
            string status
            datetime statusTimestamp
            datetime receivedAt
        }
  2. Data Layer Interaction (Pseudocode within webhook handler): This pseudocode shows where database interaction would fit inside the webhook handler's try block, after signature verification (added in the next step).

    javascript
    // Inside the webhook handler's try block, after signature verification
    
    const { id: messageId, status, statusDatetime, recipient } = request.body;
    
    try {
        // Assuming a db connection and model/function `updateMessageStatus`
        await db.updateMessageStatus({
            messageId: messageId,
            newStatus: status,
            statusTimestamp: new Date(statusDatetime), // Convert to Date object
            receivedAt: new Date()
        });
        fastify.log.info(`Successfully updated status for message [${messageId}] to [${status}] in DB`);
    
        // Optional: Update a main 'messages' table current status as well
        // await db.updateMainMessage({ messageId, currentStatus: status });
    
    } catch (dbError) {
        fastify.log.error(dbError, `Database error updating status for message [${messageId}]`);
        // CRITICAL: Even if DB fails, you already sent 200 OK.
        // Implement alerting or add to a dead-letter queue for manual review/retry.
    }
    • Use the messageId from the webhook payload as the primary key or foreign key to find and update your message record.
    • Store the status and statusTimestamp.
    • Consider storing when your server received the update (receivedAt) to track potential delays.
    • Use a database library/ORM (like Prisma, Sequelize, Knex.js) for actual implementation. Handle migrations with your chosen tool.

7. Adding Security Features

Secure your webhook endpoint.

  1. Webhook Signature Verification (CRITICAL): Confirm the request genuinely came from MessageBird/Bird. Important: The official signature verification method differs from simpler timestamp+body approaches.

    • Get Raw Body: Signature verification needs the raw, unmodified request body. Fastify parses JSON by default. Configure it to give you the raw body as well.

    Option A: Using fastify-raw-body plugin (Recommended for Fastify 4.x)

    bash
    npm install fastify-raw-body

    Then register it:

    javascript
    // src/server.js (after const fastify = require('fastify')({...});)
    await fastify.register(require('fastify-raw-body'), {
        field: 'rawBody',
        global: false, // Set to false, enable per-route for memory efficiency
        encoding: false, // Keep as Buffer for signature verification
        runFirst: true
    });

    Then enable it for the webhook route:

    javascript
    fastify.post('/webhooks/messagebird', {
        config: {
            rawBody: true // Enable raw body for this route
        }
    }, async (request, reply) => {
        // Handler code
    });

    Option B: Custom Content Type Parser

    Add the following near the top of src/server.js, after instantiating fastify:

    javascript
    // src/server.js (near the top, after const fastify = require('fastify')({...});)
    const crypto = require('node:crypto'); // Add crypto require here
    
    // Add a content type parser to store the raw body
    fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
        try {
            // Store raw body on the request object
            req.rawBody = body;
            // Parse JSON as usual for request.body
            const json = JSON.parse(body.toString());
            done(null, json);
        } catch (err) {
            err.statusCode = 400;
            done(err, undefined);
        }
    });
    • Implement Verification Logic (Official Bird/MessageBird Method):

    The official verification process (as documented in Bird API docs, November 2024) is:

    1. Base64 decode the messagebird-signature header
    2. Create SHA256 hash of the request body as binary
    3. Join: timestamp + "\n" + requestURL + "\n" + bodyHash
    4. Calculate HMAC-SHA256 using signing key
    5. Compare signatures using timing-safe comparison

    Create this function in src/server.js:

    javascript
    // src/server.js (add this function somewhere in the file)
    
    function verifyMessageBirdSignature(request, signingKey, requestUrl) {
        const signature = request.headers['messagebird-signature'];
        const timestamp = request.headers['messagebird-request-timestamp'];
        const rawBody = request.rawBody; // Get raw body (Buffer)
    
        if (!signature || !timestamp || !rawBody) {
            request.log.warn('Missing signature, timestamp, or rawBody for verification');
            return false;
        }
    
        try {
            // Step 1: Base64 decode the signature from header
            const receivedSignature = Buffer.from(signature, 'base64');
    
            // Step 2: Create SHA256 hash of request body as binary
            const bodyHash = crypto.createHash('sha256').update(rawBody).digest(); // Binary output
    
            // Step 3: Build payload: timestamp + \n + URL + \n + bodyHash
            // Note: bodyHash is binary, concatenate using Buffer.concat
            const payload = Buffer.concat([
                Buffer.from(timestamp),
                Buffer.from('\n'),
                Buffer.from(requestUrl),
                Buffer.from('\n'),
                bodyHash
            ]);
    
            // Step 4: Calculate HMAC-SHA256 using signing key
            const expectedSignature = crypto
                .createHmac('sha256', signingKey)
                .update(payload)
                .digest(); // Binary output
    
            // Step 5: Compare signatures using timing-safe comparison
            if (receivedSignature.length !== expectedSignature.length) {
                request.log.warn('Signature length mismatch');
                return false;
            }
    
            const signaturesMatch = crypto.timingSafeEqual(receivedSignature, expectedSignature);
            if (!signaturesMatch) {
                request.log.warn('Signature verification failed: Signatures do not match');
            }
            return signaturesMatch;
    
        } catch (error) {
            request.log.error(error, 'Error during signature verification');
            return false;
        }
    }

    Troubleshooting Signature Verification:

    Common failures and solutions:

    • "Missing signature, timestamp, or rawBody": Ensure raw body parser is configured correctly (Option A or B above)
    • "Signature length mismatch": Verify Base64 decoding is working; check signing key is correct
    • "Signatures do not match":
      • Confirm MESSAGEBIRD_WEBHOOK_SIGNING_KEY matches dashboard value exactly (no extra spaces)
      • Verify request URL construction includes correct protocol, host, and path
      • Check no middleware is modifying raw body before verification
      • Ensure server time is synchronized via NTP (timestamp is part of signed payload)
    • 401 errors persisting: Enable detailed logging of timestamp, requestUrl, and rawBody length to debug payload construction
    • Update Webhook Handler: Modify the /webhooks/messagebird route handler to call the verification function with the request URL.
    javascript
    // src/server.js - Update the '/webhooks/messagebird' route handler again
    
    fastify.post('/webhooks/messagebird', async (request, reply) => {
        fastify.log.info('Received MessageBird webhook');
    
        // --- 1. Verify Signature ---
        const signingKey = process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY;
        if (!signingKey || signingKey === 'YOUR_MESSAGEBIRD_WEBHOOK_SIGNING_KEY' || signingKey === 'paste_your_webhook_signing_key_here') {
            // Check if key is missing or still the placeholder value
            fastify.log.error('FATAL: MESSAGEBIRD_WEBHOOK_SIGNING_KEY is not configured or is set to a placeholder value.');
            reply.code(500).send({ error: 'Webhook signing key not configured' }); // Critical config error
            return;
        }
    
        // Construct the full request URL for signature verification
        // In production behind a proxy, use X-Forwarded-Proto and X-Forwarded-Host headers
        const protocol = request.protocol || 'https';
        const host = request.headers['x-forwarded-host'] || request.headers.host;
        const requestUrl = `${protocol}://${host}${request.url}`;
    
        if (!verifyMessageBirdSignature(request, signingKey, requestUrl)) {
            fastify.log.error('Webhook signature verification failed!');
            reply.code(401).send({ error: 'Invalid signature' }); // Use 401 Unauthorized
            return; // Stop processing
        }
        fastify.log.info('Webhook signature verified successfully.');
        // --- End Verification ---
    
    
        // 2. Process Payload (Basic Logging Example)
        try {
            const payload = request.body;
    
            if (payload && payload.id && payload.status) {
                const messageId = payload.id;
                const status = payload.status;
                const statusTimestamp = payload.statusDatetime;
                const recipient = payload.recipient;
    
                // --- Placeholder for Real Application Logic ---
                // (Database updates, queueing, etc. - see Step 6)
                // --- End Placeholder ---
                fastify.log.info(`Message ID [${messageId}] for recipient [${recipient}] updated to status [${status}] at [${statusTimestamp}]`);
    
            } else {
                fastify.log.warn('Received webhook with missing id or status after verification');
            }
    
        } catch (error) {
            fastify.log.error(error, 'Error processing webhook payload after signature verification');
            // Still send 200 OK as the request was authenticated and received
        }
    
        // 3. Acknowledge receipt
        reply.code(200).send({ status: 'received' });
    });

    Critical Implementation Notes:

    • The official Bird/MessageBird signature method requires the full request URL (including protocol, host, and path) in the signature payload
    • The body hash must be binary (not hex-encoded)
    • All components are joined with newline characters (\n)
    • In production behind a reverse proxy or load balancer, use X-Forwarded-Proto and X-Forwarded-Host headers to construct the correct URL
    • This implementation follows the official Bird API documentation (verified November 2024)
  2. Input Validation: While signature verification is key, basic checks on the payload structure (as shown in the processing step: checking for payload.id and payload.status) help prevent errors if MessageBird's payload format changes unexpectedly. For more complex validation, consider using Fastify's built-in schema validation capabilities.

  3. Rate Limiting: Protect your endpoint from abuse or misconfigured retries.

    • Install the plugin: npm install @fastify/rate-limit
    • Register it within the start function in src/server.js, before fastify.listen:
    javascript
    // src/server.js (inside the start function, before fastify.listen)
    await fastify.register(require('@fastify/rate-limit'), {
      max: 100, // Max requests per time window per IP (adjust as needed)
      timeWindow: '1 minute'
    });

    This adds basic IP-based rate limiting. Configure max and timeWindow based on expected legitimate traffic from MessageBird.

  4. HTTPS: Always use HTTPS for your webhook endpoint. ngrok provides this automatically for development. In production, ensure your deployment environment (e.g., behind a load balancer or reverse proxy) terminates TLS/SSL.

  5. Additional Security Measures:

    • IP Whitelisting: While signature verification is the primary security control, you can add an additional layer by restricting incoming connections to MessageBird's IP ranges. Check the MessageBird documentation for current IP ranges. However, IP ranges can change, so always maintain signature verification as the primary security mechanism.
    • Request Size Limits: Fastify has built-in body size limits. Configure bodyLimit in Fastify options to prevent oversized payloads.

8. Handling Special Cases

Handle edge cases in real-world scenarios.

Duplicate Webhooks:

MessageBird might occasionally send the same update twice (e.g., during network issues or retries). Make your processing logic idempotent. Before updating a status, check if the current status in your database matches the incoming one, or if the incoming statusTimestamp is older than the last recorded update for that message.

Example Idempotency Check:

javascript
// Before updating status in database
const existingUpdate = await db.getLatestStatusUpdate(messageId);

if (existingUpdate) {
    const existingTime = new Date(existingUpdate.statusTimestamp);
    const incomingTime = new Date(statusTimestamp);

    if (incomingTime <= existingTime) {
        fastify.log.info(`Ignoring duplicate/old webhook for message ${messageId}`);
        return; // Skip processing
    }
}

Out-of-Order Updates:

Network latency could cause an older status update (e.g., 'sent') to arrive after a newer one ('delivered'). Always use the statusTimestamp from the payload to determine the actual sequence of events, not the time your server received the webhook (receivedAt). When updating your database, ensure you don't overwrite a newer status with an older one based on the statusTimestamp.

Example Timestamp-Based Ordering:

javascript
// Database update with timestamp check
await db.updateStatusIfNewer({
    messageId,
    newStatus: status,
    statusTimestamp: new Date(statusDatetime),
    // SQL example: WHERE message_id = ? AND (status_timestamp < ? OR status_timestamp IS NULL)
});

Different Status Types:

Handle all possible statuses appropriately in your logic. Official status values (as of 2024-2025, source):

StatusDescription
scheduledScheduled for future delivery
sentTemporary status after sending
buffered or pendingAwaiting DLR (temporary)
deliveredSuccessfully delivered
expiredExpired before delivery
delivery_failed or failedDelivery failed

Common failure reasons in DLR (statusReason field):

  • unknown subscriber – Number not associated with active line (error code 1)
  • unavailable subscriber – Temporarily unreachable (error codes 8, 27-29, 31, 33)
  • insufficient balance – Account balance too low (error code 100)
  • carrier rejected – Carrier blocked due to registration requirements (error codes 104-105, 110)
  • capacity limit reached – Campaign limits exceeded (error codes 106-107)
  • generic delivery failure – No detailed info from carrier

Consult the official Bird/MessageBird documentation for a complete list of statuses and error codes at https://docs.bird.com/ and https://developers.messagebird.com/api/sms-messaging

Timestamp Time Zones:

The statusDatetime is typically in ISO 8601 format with a UTC offset (often +00:00). Store timestamps consistently in your database, preferably as UTC (e.g., using JavaScript Date objects or database timestamp types that handle time zones), and handle time zone conversions only when displaying data to users.

9. Implementing Performance Optimizations

Webhook endpoints need to respond to MessageBird promptly.

Respond Quickly:

Send the 200 OK response before doing any potentially slow processing (like complex database updates, external API calls). Signature verification should be fast enough to happen before the response.

Asynchronous Processing:

Offload heavy tasks (database writes, further API calls, notifications) to a background job queue. The webhook handler simply verifies the signature, parses essential data, pushes a job to the queue, and returns 200 OK. A separate worker process consumes jobs from the queue and handles the database updates or other logic.

Popular Queue Libraries:

  • BullMQ (with Redis): Modern, TypeScript-friendly, supports job priorities, retries, and rate limiting
  • Bee-Queue (with Redis): Lightweight, high-performance, simpler API
  • RabbitMQ (via amqplib): Enterprise-grade message broker, supports complex routing
  • AWS SQS (via AWS SDK): Fully managed, serverless, integrates with AWS ecosystem
  • Kafka (via kafkajs): High-throughput, distributed streaming platform for large-scale systems

Database Indexing:

Ensure your messages table (or equivalent) is indexed on messageId (or whatever field you use to look up messages based on the webhook payload) for fast lookups when processing updates.

Caching:

Caching is less relevant for writing status updates but could be used if the webhook needed to read related data frequently during processing (though ideally offload this to the async worker).

10. Adding Monitoring, Observability, and Analytics

Know what your webhook handler is doing in production.

Health Checks:

Add a simple health check endpoint that monitoring systems can poll.

javascript
// src/server.js (add route, e.g., before start function)
fastify.get('/health', async (request, reply) => {
    // Add checks for DB connection, queue status, etc. if needed
    return { status: 'ok', timestamp: new Date().toISOString() };
});

Metrics to Track:

MetricDescriptionRecommended Threshold
Webhooks receivedTotal /webhooks/messagebird requestsN/A (baseline metric)
Successful signature verificationsCount of passed verificationsShould be >95% of total
Failed signature verifications401 responses<5% of total (alert if higher)
Successfully processed updatesBased on logs or custom metricsShould match received webhooks
Processing errorsLogged errors after verification<1% of total (alert if higher)
Webhook processing latencyTime from request start to 200 OK<2 seconds (alert if higher)
Webhook delivery gapsTime between consecutive webhooks<5 minutes for active systems

Use monitoring tools like Prometheus/Grafana, Datadog, New Relic, or your cloud provider's monitoring services. Fastify plugins like fastify-metrics can help expose Prometheus metrics.

Error Tracking:

Integrate an error tracking service (e.g., Sentry, Bugsnag, Datadog APM). Use Fastify's setErrorHandler to capture unhandled errors globally and report them.

Logging:

Ensure logs are structured (JSON format is good, which Pino, Fastify's default logger, provides) and aggregated in a central logging system (e.g., ELK stack, Loki, Datadog Logs, CloudWatch Logs) for analysis, searching, and alerting on specific error patterns.

11. Troubleshooting and Caveats

Webhook Not Received:

  • Firewall: Ensure your server/firewall allows incoming connections on the configured port (e.g., 3000) from MessageBird's IP ranges. While signature verification is the primary security measure, firewalls can still block initial connections. Check MessageBird documentation for their current IP ranges if strict IP whitelisting is necessary, but prefer relying on signature verification.
  • URL Incorrect: Double-check the webhook URL configured in the MessageBird dashboard. Ensure it exactly matches your publicly accessible endpoint, including https:// and the correct path (/webhooks/messagebird). Verify that your ngrok tunnel (or production URL) is active and pointing to the running Fastify application.
  • Server Not Running: Ensure your Fastify server is running and listening on the correct port and host.
  • DNS Issues: In production, ensure DNS records for your webhook URL are correctly configured and propagated.

Signature Verification Fails (401 Errors):

  • Incorrect Signing Key: Verify that the MESSAGEBIRD_WEBHOOK_SIGNING_KEY in your .env file exactly matches the key generated and displayed in the MessageBird dashboard for that specific webhook URL. Ensure there are no extra spaces or characters. Restart your server after updating .env.
  • Raw Body Modification: Ensure no middleware is modifying the raw request body before the verifyMessageBirdSignature function accesses request.rawBody. The addContentTypeParser setup should preserve it correctly.
  • Timestamp Skew: While less common, significant clock differences between MessageBird's servers and yours could theoretically affect timestamp-based elements, though the signature itself doesn't directly rely on comparing timestamps for validity beyond its inclusion in the signed payload. Ensure your server's clock is synchronized (e.g., using NTP).
  • Request URL Construction: Verify the requestUrl construction matches the exact URL MessageBird uses. Behind proxies, ensure X-Forwarded-Proto and X-Forwarded-Host headers are correctly read.

Processing Errors (Logged after 200 OK):

  • Database Issues: Check database connection, permissions, schema mismatches, or constraint violations.
  • Payload Format Changes: MessageBird might update their payload structure. Add robust checks and logging for unexpected or missing fields.
  • Downstream Service Failures: If your processing involves calling other APIs, handle potential failures gracefully.

MessageBird Retries:

If MessageBird repeatedly sends the same webhook, your endpoint isn't consistently returning a 2xx status code within their timeout period (typically a few seconds). Focus on responding quickly (Step 9) and ensure signature verification is correct. Check MessageBird's webhook delivery logs in their dashboard for details on failures.