code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / messagebird

MessageBird SMS Delivery Status Tracking: RedwoodJS Webhook Integration Guide

Complete guide to tracking SMS delivery status with MessageBird webhooks in RedwoodJS. Learn secure webhook verification, real-time status callbacks, and production-ready Node.js implementation.

This comprehensive guide shows you how to build a production-ready SMS delivery tracking system using MessageBird webhooks in RedwoodJS. You'll learn how to send SMS messages through the MessageBird API, implement secure webhook signature verification, and track real-time delivery status updates in your Node.js application. We'll cover everything from project setup and database schema design to webhook security, error handling, and GraphQL API integration.

By the end of this MessageBird webhook integration tutorial, you will have a RedwoodJS application capable of:

  • Sending SMS messages using the MessageBird API.
  • Persisting message details and status in a database (PostgreSQL via Prisma).
  • Receiving and securely verifying webhook callbacks from MessageBird to update message delivery status in real-time.
  • Exposing functionality via a GraphQL API.

This solution enables applications to reliably inform users or internal systems about the delivery success or failure of critical SMS communications.

MessageBird SMS Delivery Tracking: Project Overview and Goals

Problem: Many applications send SMS notifications for order confirmations, two-factor authentication (2FA), and alerts, but lack visibility into actual delivery status. Relying on the initial MessageBird API sent confirmation is insufficient—network issues, invalid phone numbers, or carrier blocks can prevent delivery without your knowledge.

Solution: This guide shows you how to implement MessageBird's webhook delivery status callbacks in RedwoodJS. MessageBird sends HTTP POST requests to your webhook endpoint whenever message status changes—from sent to delivered, delivery_failed, or expired. Your application will securely verify webhook signatures using JWT, then update delivery status in real-time in your PostgreSQL database via Prisma ORM.

<!-- EXPAND: Could benefit from a real-world use case comparison table showing when status tracking is critical vs optional (Type: Enhancement) -->

Technologies Used:

  • RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. It provides structure, conventions, and tools (like Prisma, GraphQL, Jest) out-of-the-box, accelerating development.
  • Node.js: The underlying runtime environment for RedwoodJS.
  • MessageBird: The communications platform as a service (CPaaS) provider used for sending SMS and providing delivery status webhooks.
  • Prisma: The database toolkit used by RedwoodJS for ORM, migrations, and type-safe database access. We'll use it with PostgreSQL.
  • TypeScript: For type safety and improved developer experience.
  • GraphQL: RedwoodJS's default API layer for querying and mutating data.
<!-- GAP: Missing version compatibility matrix for technology stack (Type: Substantive) -->

System Architecture:

+-------------+ +-----------------+ +---------------------+ +---------------+ +-----------------+ | End User/UI | --> | Redwood Web Side| --> | Redwood API (GraphQL)| --> | Message Service | --> | MessageBird API | --> SMS +-------------+ +-----------------+ +---------------------+ +---------------+ +-----------------+ ^ | | | | (Status Query) | (Store Initial Msg | | (Webhook POST) | | & MessageBird ID) | V | V V +-----------------+ +----------------------------------------- (DB Update) <-------------+ Prisma Client <-----+ Webhook Handler | <--+ (Verify Signature) +---------------+ +-----------------+ <!-- DEPTH: Architecture diagram lacks explanation of error handling flows and retry mechanisms (Priority: High) -->

Prerequisites:

  • Node.js: v18.x or later.
  • Yarn: v1.x (Classic) - RedwoodJS's preferred package manager.
  • RedwoodJS CLI: Install globally: npm install -g redwoodjs
  • PostgreSQL Database: A running instance (local or cloud-based). You'll need the connection string.
  • MessageBird Account: A free or paid account. You'll need:
    • A Live API Key.
    • An SMS-capable Originator (phone number or approved Alphanumeric Sender ID).
    • A Webhook Signing Key.
  • (Optional but Recommended for Local Dev): ngrok or a similar tool to expose your local webhook endpoint to the internet for MessageBird callbacks.
<!-- GAP: Missing step-by-step instructions for obtaining MessageBird credentials from dashboard (Type: Critical) --> <!-- GAP: Missing PostgreSQL setup instructions and recommended configuration (Type: Substantive) -->

1. Setting up Your RedwoodJS Project for MessageBird Integration

Let's create a new RedwoodJS application and install the MessageBird Node.js SDK for SMS API integration.

  1. Create RedwoodJS App: Open your terminal and run:

    bash
    yarn create redwood-app ./messagebird-redwood-status --typescript

    This command scaffolds a new RedwoodJS project named messagebird-redwood-status using TypeScript.

  2. Navigate to Project Directory:

    bash
    cd messagebird-redwood-status
  3. Install Dependencies: We need the MessageBird Node.js SDK and dotenv for managing environment variables on the API side.

    bash
    yarn workspace api add messagebird dotenv
<!-- DEPTH: Missing explanation of why dotenv is needed when RedwoodJS has built-in env support (Priority: Medium) -->
  1. Configure Environment Variables: RedwoodJS uses a .env file at the project root for environment variables. Create this file (touch .env) and add the following, replacing placeholders with your actual credentials:

    dotenv
    # .env
    
    # --- Database ---
    # Replace with your actual PostgreSQL connection string
    # Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
    DATABASE_URL=""postgresql://postgres:password@localhost:5432/messagebird_status""
    
    # --- MessageBird ---
    # Get from MessageBird Dashboard: Developers -> API access (REST) -> Show key
    MESSAGEBIRD_API_KEY=""YOUR_LIVE_API_KEY""
    
    # Get from MessageBird Dashboard: Developers -> API access (REST) -> Signing Key
    MESSAGEBIRD_SIGNING_KEY=""YOUR_WEBHOOK_SIGNING_KEY""
    
    # Your purchased MessageBird number or approved Alphanumeric Sender ID
    # Important: Check country restrictions for Alphanumeric Sender IDs
    MESSAGEBIRD_ORIGINATOR=""YourMessageBirdNumberOrSenderID""
    
    # The base URL where your Redwood app is deployed (for webhook URL construction)
    # For local dev with ngrok, this would be your ngrok https URL (e.g., https://xxxx.ngrok.io)
    # For production, this is your application's public URL (e.g., https://myapp.com)
    APP_BASE_URL=""http://localhost:8910"" # Default Redwood dev server - CHANGE FOR NGROK/PROD
    • DATABASE_URL: Points to your PostgreSQL instance. Ensure the database exists.
    • Important Security Note: The example DATABASE_URL uses a weak password (password). Always use strong, unique passwords for your databases, even during development. Consider using a password manager.
    • MESSAGEBIRD_API_KEY: Your live API key from the MessageBird dashboard (Developers -> API access). Treat this like a password.
    • MESSAGEBIRD_SIGNING_KEY: Found below your API keys in the MessageBird dashboard. Used to verify webhook authenticity. Treat this securely.
    • MESSAGEBIRD_ORIGINATOR: The 'From' number or name for outgoing SMS. Must be a number purchased/verified in MessageBird or an approved Alphanumeric Sender ID (check MessageBird documentation for country support).
    • APP_BASE_URL: Crucial for constructing the statusReportUrl sent to MessageBird and the URL you configure in the MessageBird dashboard. Remember to update this when using ngrok or deploying.
<!-- GAP: Missing instructions for setting up ngrok and obtaining the ngrok URL for local development (Type: Critical) --> <!-- EXPAND: Could benefit from a table showing different APP_BASE_URL values for different environments (Type: Enhancement) -->
  1. Initialize MessageBird Client: Create a reusable MessageBird client instance. Create the file api/src/lib/messagebird.ts:

    typescript
    // api/src/lib/messagebird.ts
    import { initClient } from 'messagebird';
    import type { MessageBird } from 'messagebird/types';
    
    // Load environment variables explicitly with dotenv.
    // While RedwoodJS automatically loads `.env` variables into `process.env`
    // in most of its standard execution contexts (like services, functions, server startup),
    // including dotenv ensures the environment variables are loaded if this specific file
    // were ever imported or executed in a context *outside* the standard Redwood process.
    // It's generally safe but might be considered redundant in typical Redwood usage.
    import dotenv from 'dotenv';
    dotenv.config();
    
    const accessKey = process.env.MESSAGEBIRD_API_KEY;
    
    if (!accessKey) {
      throw new Error(
        'MessageBird API Key not found. Set MESSAGEBIRD_API_KEY in .env'
      );
    }
    
    // Initialize the client
    // Explicitly cast to MessageBird type for better autocompletion if needed elsewhere
    const messagebird: MessageBird = initClient(accessKey);
    
    export default messagebird;
    • This file safely initializes the MessageBird client using the API key from .env.
    • It throws an error during startup if the key is missing, preventing runtime failures later.
<!-- DEPTH: Missing guidance on testing the MessageBird connection and verifying API key validity (Priority: High) -->

2. Implementing SMS Delivery Status Tracking (Database Schema & Services)

To track SMS delivery status, we need a database schema to store message details and real-time status updates from MessageBird webhooks.

  1. Define Database Schema: Open api/db/schema.prisma and define a Message model:

    prisma
    // api/db/schema.prisma
    
    datasource db {
      provider = ""postgresql""
      url      = env(""DATABASE_URL"")
    }
    
    generator client {
      provider = ""prisma-client-js""
    }
    
    model Message {
      id              String    @id @default(cuid()) // Unique DB identifier
      messageBirdId   String?   @unique // MessageBird's unique ID for the message
      recipient       String    // E.164 format phone number
      body            String    // Message content
      status          String    @default(""pending"") // e.g., pending, sent, delivered, failed, expired
      statusDetails   String?   // Optional extra details from webhook
      statusUpdatedAt DateTime? // Timestamp of the last status update from webhook
      createdAt       DateTime  @default(now())
      updatedAt       DateTime  @updatedAt
    
      // Optional: Link to another record in your application
      // relatedRecordId String?
      // relatedRecord   RelatedModel? @relation(fields: [relatedRecordId], references: [id])
    
      @@index([status]) // Add index for faster status queries
    }
    
    // Example of relating to another model (uncomment and define RelatedModel if needed)
    // model RelatedModel {
    //   id       String @id @default(cuid())
    //   messages Message[]
    // }
    • id: Standard CUID primary key.
    • messageBirdId: Stores the ID returned by MessageBird when a message is created. This is crucial for correlating webhook updates. It's nullable initially and marked unique.
    • recipient, body: Basic message details.
    • status: Tracks the delivery status. Defaults to pending.
      • Note on status Type: Using String provides flexibility if MessageBird introduces new status values not defined in your code. However, using a Prisma enum (e.g., enum MessageStatus { PENDING, QUEUED, SENT, DELIVERED, FAILED, EXPIRED }) offers better type safety and clearly defines allowed states within your application code. Choose based on whether you prioritize flexibility or strict type checking.
    • statusDetails: Can store additional error information from failed delivery webhooks.
    • statusUpdatedAt: Timestamp from the webhook event.
    • createdAt, updatedAt: Standard Prisma timestamps.
    • (Optional) relatedRecordId/relatedRecord: Shows how you might link this message back to another entity in your app (like an Order or User).
    • @@index([status]): Added an index to the status field for potentially faster queries filtering by status.
<!-- EXPAND: Could benefit from a table comparing String vs Enum for status field with pros/cons (Type: Enhancement) --> <!-- GAP: Missing guidance on additional indexes for common query patterns (e.g., by recipient, by date) (Type: Substantive) -->
  1. Apply Database Migrations: Create and apply the migration to your database:

    bash
    yarn rw prisma migrate dev --name add_message_model

    Follow the prompts. This creates the Message table in your PostgreSQL database and applies the index.

<!-- DEPTH: Missing explanation of what to do if migration fails or database doesn't exist (Priority: High) -->
  1. Generate GraphQL SDL and Services: Use Redwood's generators to create the boilerplate for GraphQL types, queries, mutations, and the service file for the Message model:

    bash
    yarn rw g sdl Message --crud

    This command creates:

    • api/src/graphql/messages.sdl.ts: Defines GraphQL types (Message), queries (messages, message), and mutations (createMessage, updateMessage, deleteMessage).
    • api/src/services/messages/messages.ts: Contains Prisma logic for the generated queries/mutations.
    • api/src/services/messages/messages.scenarios.ts: For defining seed data for tests.
    • api/src/services/messages/messages.test.ts: Basic test structure.
  2. Implement Message Sending Service: Modify the generated api/src/services/messages/messages.ts to handle sending SMS via MessageBird and integrate the webhook logic. We'll replace the default createMessage with a custom sendMessage mutation and add a function to handle webhook updates.

    typescript
    // api/src/services/messages/messages.ts
    import type { QueryResolvers, MutationResolvers, MessageRelationResolvers } from 'types/graphql';
    import { Prisma } from '@prisma/client';
    
    import { db } from 'src/lib/db';
    import messagebird from 'src/lib/messagebird'; // Import our initialized client
    import { logger } from 'src/lib/logger'; // Redwood's logger
    
    // Define the expected webhook payload structure (subset)
    // Based on MessageBird documentation for message status webhooks
    export interface MessageBirdWebhookPayload {
      id: string; // MessageBird message ID
      status: string; // e.g., 'delivered', 'sent', 'delivery_failed', 'expired'
      statusDatetime: string; // ISO 8601 timestamp string
      statusReason?: string; // Optional reason for failure, e.g., ""invalid_recipient""
      // Add other fields as needed, e.g., recipient, originator, type, mnc, mcc
    }
    
    
    // --- Custom Service Functions ---
    
    /**
     * Sends an SMS message via MessageBird and creates a record in the database.
     * @param input - Contains recipient and body for the message.
     */
    export const sendMessage: MutationResolvers['sendMessage'] = async ({ input }) => {
      const { recipient, body } = input;
    
      // 1. Validate Input (Basic example)
      if (!recipient || !body) {
        throw new Error('Recipient and body are required.');
      }
      // TODO: Add more robust validation (e.g., E.164 format check for recipient, body length)
    
      // 2. Construct Webhook URL
      const appBaseUrl = process.env.APP_BASE_URL;
      if (!appBaseUrl) {
        logger.error('APP_BASE_URL environment variable is not set.');
        throw new Error('Application base URL is not configured.');
      }
      // IMPORTANT: This endpoint '/.redwood/functions/messagebirdWebhook' corresponds
      // to the Redwood function we will create later. Ensure it matches exactly.
      const statusReportUrl = `${appBaseUrl}/.redwood/functions/messagebirdWebhook`;
    
      // 3. Create initial Database Record
      let dbMessage;
      try {
        dbMessage = await db.message.create({
          data: {
            recipient,
            body,
            status: 'queued', // Initial status before sending attempt
          },
        });
        logger.info({ messageId: dbMessage.id }, 'Created initial message record.');
      } catch (error) {
        logger.error({ error }, 'Failed to create initial message record in DB.');
        // Depending on requirements, you might want to stop here or attempt send anyway
        throw new Error('Database error while preparing message.');
      }
    
      // 4. Send SMS via MessageBird
      const originator = process.env.MESSAGEBIRD_ORIGINATOR;
      if (!originator) {
        logger.error('MESSAGEBIRD_ORIGINATOR environment variable is not set.');
        // Update DB record to 'config_error' status before throwing
        await db.message.update({ where: { id: dbMessage.id }, data: { status: 'config_error', statusDetails: 'Originator not set' } });
        throw new Error('MessageBird Originator is not configured.');
      }
    
      try {
        logger.info({ recipient, originator, statusReportUrl }, 'Attempting to send SMS via MessageBird');
    
        const params = {
          originator: originator,
          recipients: [recipient],
          body: body,
          statusReportUrl: statusReportUrl, // Tell MessageBird where to send status updates
          // reference: `redwood_msg_${dbMessage.id}`, // Optional: Add your own reference ID
        };
    
        // Use Promise wrapper for callback-based SDK method
        const response = await new Promise<any>((resolve, reject) => {
           messagebird.messages.create(params, (err, resp) => {
            if (err) {
               // MessageBird specific error handling can be added here based on err object structure
               logger.error({ err, dbMessageId: dbMessage.id }, 'MessageBird API error during send.');
               return reject(err);
             }
             logger.info({ response, dbMessageId: dbMessage.id }, 'MessageBird API send successful.');
             resolve(resp);
           });
         });
    
        // 5. Update DB record with MessageBird ID and 'sent' status
        if (response?.id) {
          const updatedMessage = await db.message.update({
            where: { id: dbMessage.id },
            data: {
              messageBirdId: response.id,
              status: 'sent', // Successfully handed off to MessageBird queue
            },
          });
          logger.info({ messageBirdId: response.id, dbMessageId: dbMessage.id }, 'Updated DB record with MessageBird ID.');
          return updatedMessage; // Return the updated message record to GraphQL caller
        } else {
          // This case indicates an issue even if the API didn't throw an error (unexpected response)
          logger.warn({ response, dbMessageId: dbMessage.id }, 'MessageBird API response missing expected ID.');
          // Update status to indicate potential issue
           await db.message.update({ where: { id: dbMessage.id }, data: { status: 'send_issue', statusDetails: 'API response missing ID' } });
           throw new Error('Message submission issue: No MessageBird ID received.');
        }
    
      } catch (error) {
        logger.error({ error, dbMessageId: dbMessage.id }, 'Failed to send message via MessageBird API.');
        // Update DB record to 'failed_to_send' status with error details
        // Use error.message or potentially more specific details if available from MessageBird error object
        const errorMessage = error.message || (error.errors ? JSON.stringify(error.errors) : 'Unknown API error');
        await db.message.update({
          where: { id: dbMessage.id },
          data: { status: 'failed_to_send', statusDetails: errorMessage },
        });
        // Re-throw the error to be caught by the GraphQL layer and returned to the client
        throw new Error(`Failed to send message: ${errorMessage}`);
      }
    };
    
    /**
     * Updates the status of a message based on a webhook payload from MessageBird.
     * Intended to be called *only* by the verified webhook handler function.
     * @param payload - The parsed and verified webhook data matching MessageBirdWebhookPayload interface.
     */
    export const updateMessageStatusFromWebhook = async (payload: MessageBirdWebhookPayload): Promise<boolean> => {
      logger.info({ payload }, 'Received request to update message status from webhook');
    
      const { id: messageBirdId, status, statusDatetime, statusReason } = payload;
    
      if (!messageBirdId) {
        logger.warn({ payload }, 'Webhook payload missing messageBirdId. Cannot process.');
        // Cannot process without the ID to correlate. Return failure.
        return false;
      }
    
      try {
        // Attempt to find and update the message using the unique MessageBird ID
        const updatedMessage = await db.message.update({
          where: { messageBirdId: messageBirdId }, // Find message by MessageBird's unique ID
          data: {
            status: status, // Update status from webhook payload
            statusDetails: statusReason, // Store failure reason if provided, otherwise null/undefined
            statusUpdatedAt: new Date(statusDatetime), // Convert ISO 8601 string to Date object
          },
        });
        logger.info(
          { messageBirdId, newStatus: status, dbMessageId: updatedMessage.id },
          'Successfully updated message status from webhook.'
        );
        return true; // Indicate successful update
    
      } catch (error) {
        // Handle specific Prisma errors, like record not found
        if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
           // P2025: Record to update not found.
           // This can happen if MessageBird sends a webhook for a message not in our DB
           // (e.g., deleted, never stored correctly, or from a different system).
           // This is often not a critical error needing retry from MessageBird.
           logger.warn({ messageBirdId }, 'MessageBird ID from webhook not found in database (Prisma code P2025). Update skipped.');
           // Consider returning true here if you don't want MessageBird to retry for not-found errors.
           // Return false if you want to investigate why it wasn't found. For now, let's return true
           // to acknowledge the webhook for a non-existent record was ""handled"" by logging it.
           return true;
         } else {
           // Log other potential database errors during the update
           logger.error({ error, messageBirdId }, 'Database error updating message status from webhook.');
           return false; // Indicate failure for other DB errors, allowing potential retry from MessageBird
         }
      }
    };
    
    
    // --- Standard Redwood CRUD (Keep or Modify as needed) ---
    
    export const messages: QueryResolvers['messages'] = () => {
      // TODO: Add authorization checks if necessary
      return db.message.findMany();
    };
    
    export const message: QueryResolvers['message'] = ({ id }) => {
      // TODO: Add authorization checks if necessary
      return db.message.findUnique({
        where: { id },
      });
    };
    
    // We replaced createMessage with sendMessage, so remove or comment out the original CRUD operation
    /*
    export const createMessage: MutationResolvers['createMessage'] = ({ input, }) => {
      // This bypasses the MessageBird sending logic. Generally should not be exposed
      // unless there's a specific use case for creating message records directly.
      logger.warn({ input }, 'Direct message creation via GraphQL called (bypassing send logic).')
      // TODO: Add authorization checks
      return db.message.create({
        data: input,
      })
    }
    */
    
    // Keep update/delete if you need direct DB manipulation via GraphQL, otherwise remove.
    // Ensure proper authorization is in place if these are kept.
    export const updateMessage: MutationResolvers['updateMessage'] = ({ id, input }) => {
      // WARNING: Allows direct modification of message state. Use with caution.
      // TODO: Add strict authorization checks here.
      logger.warn({ id, input }, 'Direct message update via GraphQL called.');
      return db.message.update({
        data: input,
        where: { id },
      });
    };
    
    export const deleteMessage: MutationResolvers['deleteMessage'] = ({ id }) => {
      // WARNING: Allows direct deletion of message records. Use with caution.
      // TODO: Add strict authorization checks here.
      logger.warn({ id }, 'Direct message delete via GraphQL called.');
      return db.message.delete({
        where: { id },
      });
    };
    
    // Keep Relation Resolvers if you defined relations in schema.prisma
    export const Message: MessageRelationResolvers = {
      // Example if you added a relation to RelatedModel
      // relatedRecord: (_obj, { root }) => {
      //   return db.message.findUnique({ where: { id: root?.id } }).relatedRecord()
      // },
    };
    • sendMessage:
      • Takes recipient and body as input.
      • Constructs the statusReportUrl using APP_BASE_URL and the exact path to the Redwood function we'll create (/.redwood/functions/messagebirdWebhook).
      • Creates a Message record in the DB with status queued.
      • Calls messagebird.messages.create, passing the statusReportUrl.
      • Uses a Promise wrapper around the callback-based SDK method for better async/await flow.
      • If successful, updates the DB record with the messageBirdId from the response and sets status to sent.
      • Includes enhanced error handling and logging for DB and API call failures, updating the DB status accordingly (e.g., config_error, failed_to_send, send_issue).
    • updateMessageStatusFromWebhook:
      • Takes the verified webhook payload, strongly typed with MessageBirdWebhookPayload (which is now exported).
      • Finds the corresponding Message record using the unique messageBirdId.
      • Updates the status, statusDetails, and statusUpdatedAt fields.
      • Logs success or failure, specifically handling the P2025 (Record Not Found) Prisma error as a non-critical warning. Returns a boolean indicating success/failure.
    • Standard CRUD: The original createMessage is removed/commented out with warnings. updateMessage and deleteMessage are kept but include warnings and TODOs for adding authorization, as direct manipulation might bypass business logic or security checks.
<!-- GAP: Missing E.164 phone number validation implementation details and library recommendations (Type: Critical) --> <!-- DEPTH: Code lacks comprehensive error code mapping from MessageBird API errors (Priority: Medium) --> <!-- EXPAND: Could benefit from a flowchart showing the complete message lifecycle with all status transitions (Type: Enhancement) -->

3. Building the GraphQL API for SMS Operations

RedwoodJS generates GraphQL schema boilerplate. We'll customize it to expose a sendMessage mutation for sending SMS with delivery tracking enabled.

  1. Update GraphQL Schema Definition: Modify api/src/graphql/messages.sdl.ts:

    typescript
    // api/src/graphql/messages.sdl.ts
    
    export const schema = gql`
      scalar DateTime
    
      type Message {
        id: String!
        messageBirdId: String
        recipient: String!
        body: String!
        status: String! # Consider using a GraphQL enum if you used a Prisma enum
        statusDetails: String
        statusUpdatedAt: DateTime
        createdAt: DateTime!
        updatedAt: DateTime!
        # relatedRecord: RelatedModel # Uncomment if relation exists
      }
    
      type Query {
        messages: [Message!]! @requireAuth
        message(id: String!): Message @requireAuth
      }
    
      # Input type used by the standard Redwood CRUD generator (optional to keep)
      input CreateMessageInput {
        messageBirdId: String
        recipient: String!
        body: String!
        status: String
        statusDetails: String
        statusUpdatedAt: DateTime
        # relatedRecordId: String # Uncomment if relation exists
      }
    
      # Input type used by the standard Redwood CRUD generator (optional to keep)
      input UpdateMessageInput {
        messageBirdId: String
        recipient: String
        body: String
        status: String
        statusDetails: String
        statusUpdatedAt: DateTime
        # relatedRecordId: String # Uncomment if relation exists
      }
    
      # --- Input type specifically for our custom sendMessage mutation ---
      input SendMessageInput {
        recipient: String! # Should be E.164 format ideally
        body: String!
        # Add other relevant fields if needed, e.g., relatedRecordId to link the message
        # relatedRecordId: String
      }
    
      type Mutation {
        # --- Custom Mutation to send SMS via MessageBird ---
        sendMessage(input: SendMessageInput!): Message! @requireAuth
    
        # --- Standard CRUD Mutations (optional, add fine-grained authorization if kept) ---
        # createMessage(input: CreateMessageInput!): Message! @requireAuth # Typically removed/disabled
        updateMessage(id: String!, input: UpdateMessageInput!): Message! @requireAuth # Add auth checks
        deleteMessage(id: String!): Message! @requireAuth # Add auth checks
      }
    `;
    • Added the SendMessageInput type for our custom mutation.
    • Replaced the default createMessage mutation with sendMessage(input: SendMessageInput!): Message!.
    • Kept the standard Message type, queries (messages, message), and other mutations (updateMessage, deleteMessage).
    • Added comments reminding to implement proper authorization (@requireAuth is a basic check, might need role-based access) if the standard update/delete mutations are exposed.
    • Ensured the scalar DateTime definition is present (often included by default).
<!-- GAP: Missing explanation of @requireAuth directive and how to implement custom authorization rules (Type: Substantive) --> <!-- EXPAND: Could benefit from pagination examples for the messages query (Type: Enhancement) -->
  1. Testing the Mutation (Conceptual): You can use the Redwood GraphQL Playground (usually available at http://localhost:8911/graphql when running yarn rw dev) to test the sendMessage mutation.

    Example GraphQL Mutation:

    graphql
    mutation SendTestMessage {
      sendMessage(input: {
        recipient: ""+1xxxxxxxxxx"" # Replace with a valid E.164 test number
        body: ""Hello from RedwoodJS + MessageBird! Testing status updates.""
      }) {
        id
        messageBirdId
        recipient
        status
        createdAt
      }
    }
    • Executing this should trigger the sendMessage service function, create a DB record, call the MessageBird API, update the record with the messageBirdId and sent status, and return the result.
<!-- DEPTH: Missing detailed instructions on accessing and using the GraphQL Playground (Priority: High) --> <!-- GAP: Missing example queries for checking message status and filtering by status (Type: Substantive) -->

4. Implementing MessageBird Webhook Handler for Delivery Status Callbacks

The webhook handler is essential for receiving real-time SMS delivery status updates from MessageBird. We'll create a dedicated RedwoodJS serverless function to handle incoming POST requests, verify webhook signatures, and update message status in the database.

  1. Create Redwood Function: Use the Redwood generator:

    bash
    yarn rw g function messagebirdWebhook --typescript

    This creates api/src/functions/messagebirdWebhook.ts.

  2. Implement Webhook Handler and Signature Verification: Edit api/src/functions/messagebirdWebhook.ts to verify the signature and process the payload:

    typescript
    // api/src/functions/messagebirdWebhook.ts
    import type { APIGatewayEvent, Context, APIGatewayProxyResult } from 'aws-lambda';
    import { WebhookSignatureJwt } from 'messagebird/lib/webhook-signature-jwt'; // Import verification helper
    import { logger } from 'src/lib/logger';
    // Import the service function AND the payload type definition
    import { updateMessageStatusFromWebhook, MessageBirdWebhookPayload } from 'src/services/messages/messages';
    
    // Load signing key from environment variables
    const signingKey = process.env.MESSAGEBIRD_SIGNING_KEY;
    
    if (!signingKey) {
      logger.fatal('MESSAGEBIRD_SIGNING_KEY is not set. Cannot verify webhooks.');
      // In a real application, you might throw an error here during server startup
      // to prevent the function from running in an insecure state.
    }
    
    // Instantiate the verifier once (outside the handler) for efficiency
    const verifier = signingKey ? new WebhookSignatureJwt(signingKey) : null;
    
    /**
     * CRITICAL CONFIGURATION FOR RAW BODY:
     * MessageBird signature verification requires the raw, unparsed request body.
     * RedwoodJS API functions might automatically parse JSON bodies based on Content-Type.
     * To prevent this for this specific function, you MUST disable the body parser.
     * Add the following export directly within this file (`messagebirdWebhook.ts`):
     */
    export const config = {
      api: {
        bodyParser: false,
      },
    };
    
    export const handler = async (event: APIGatewayEvent, _context: Context): Promise<APIGatewayProxyResult> => {
      logger.info('Received request on messagebirdWebhook function.');
    
      if (!verifier) {
        logger.error('Webhook signature verifier not initialized (missing signing key). Denying request.');
        return { statusCode: 500, body: 'Internal Server Error: Webhook verification not configured.' };
      }
    
      if (event.httpMethod !== 'POST') {
        logger.warn(`Received non-POST request: ${event.httpMethod}. Method Not Allowed.`);
        return { statusCode: 405, body: 'Method Not Allowed' };
      }
    
      // --- 1. Signature Verification ---
      const signatureHeader = event.headers['messagebird-signature-jwt'];
      const queryParams = event.queryStringParameters || {}; // Get query params as object
      const rawBody = event.body; // Raw body string thanks to `bodyParser: false`
    
      if (!signatureHeader) {
        logger.warn('Missing MessageBird-Signature-JWT header. Denying request.');
        return { statusCode: 401, body: 'Unauthorized: Missing signature.' };
      }
    
      if (!rawBody) {
        logger.warn('Request body is empty. Cannot verify signature or process payload.');
        // MessageBird usually sends a body, so this might indicate an issue or unexpected request.
        return { statusCode: 400, body: 'Bad Request: Empty body.' };
      }
    
      // Construct the full URL as MessageBird used it to sign the request.
      // This is critical and can be tricky depending on the deployment environment.
      // Headers like 'host' and 'x-forwarded-proto' might be unreliable or absent.
      // Using the configured `APP_BASE_URL` is generally more robust if set correctly for the environment.
      // It's VITAL to test this URL reconstruction in your actual deployment environment.
      const appBaseUrl = process.env.APP_BASE_URL;
      if (!appBaseUrl) {
        logger.error('APP_BASE_URL is not set. Cannot reliably reconstruct request URL for verification.');
        return { statusCode: 500, body: 'Internal Server Error: Application URL not configured.' };
      }
      const path = event.path; // Should be '/.redwood/functions/messagebirdWebhook'
      const requestUrl = `${appBaseUrl}${path}`; // Combine base URL and path
    
      try {
        // Verify the signature using the reconstructed URL, query params, signature header, and raw body
        verifier.verify(signatureHeader, requestUrl, queryParams, rawBody);
        logger.info('MessageBird webhook signature verified successfully.');
    
      } catch (error) {
        logger.error({ error }, 'Invalid MessageBird webhook signature. Denying request.');
        return { statusCode: 401, body: 'Unauthorized: Invalid signature.' };
      }
    
      // --- 2. Process Payload ---
      let payload: MessageBirdWebhookPayload;
      try {
        // Parse the raw body *after* successful signature verification
        payload = JSON.parse(rawBody);
        logger.info({ messageBirdId: payload.id, status: payload.status }, 'Parsed webhook payload.');
    
        // Basic validation of expected fields (add more checks as needed)
        if (!payload.id || !payload.status || !payload.statusDatetime) {
          logger.warn({ payload }, 'Webhook payload missing required fields (id, status, statusDatetime).');
          // Respond with 400 Bad Request as the payload structure is invalid
          return { statusCode: 400, body: 'Bad Request: Invalid payload structure.' };
        }
    
      } catch (error) {
        logger.error({ error, rawBody }, 'Failed to parse webhook JSON payload.');
        // Respond with 400 Bad Request if JSON parsing fails
        return { statusCode: 400, body: 'Bad Request: Invalid JSON payload.' };
      }
    
      // --- 3. Update Database ---
      try {
        const updateSuccessful = await updateMessageStatusFromWebhook(payload);
    
        if (updateSuccessful) {
          logger.info({ messageBirdId: payload.id }, 'Webhook processed and database updated successfully.');
          // MessageBird expects a 200 OK to acknowledge receipt and stop retries
          return { statusCode: 200, body: 'Webhook processed successfully.' };
        } else {
          // updateMessageStatusFromWebhook returned false (e.g., DB error other than not found)
          logger.error({ messageBirdId: payload.id }, 'Failed to update database from webhook (service function returned false).');
          // Return 500 to indicate a server-side issue processing the valid webhook.
          // MessageBird might retry sending the webhook in this case.
          return { statusCode: 500, body: 'Internal Server Error: Failed to update message status.' };
        }
      } catch (error) {
        // Catch any unexpected errors during the call to the service function
        logger.error({ error, messageBirdId: payload.id }, 'Unexpected error calling updateMessageStatusFromWebhook.');
        return { statusCode: 500, body: 'Internal Server Error: Unexpected error processing webhook.' };
      }
    };
    • Signature Verification:
      • Imports WebhookSignatureJwt from the MessageBird SDK.
      • Loads the MESSAGEBIRD_SIGNING_KEY from .env.
      • Crucially, includes export const config = { api: { bodyParser: false } }; to ensure event.body contains the raw request string needed for verification.
      • Instantiates the verifier outside the handler.
      • Checks for POST method and the presence of the MessageBird-Signature-JWT header.
      • Reconstructs the requestUrl using APP_BASE_URL and event.path. This is a critical step and needs careful testing in deployment.
      • Calls verifier.verify with the signature, reconstructed URL, query parameters (event.queryStringParameters), and the raw body (event.body).
      • Returns 401 Unauthorized if verification fails.
    • Payload Processing:
      • Parses the rawBody into a JSON object only after successful signature verification.
      • Performs basic validation on the parsed payload to ensure required fields exist.
      • Returns 400 Bad Request if parsing or validation fails.
    • Database Update:
      • Calls the updateMessageStatusFromWebhook service function with the validated payload.
      • Returns 200 OK if the service function indicates success (returns true).
      • Returns 500 Internal Server Error if the service function indicates failure (returns false) or if an unexpected error occurs, potentially prompting MessageBird to retry.
<!-- GAP: Missing instructions for configuring the webhook URL in MessageBird dashboard (Type: Critical) --> <!-- DEPTH: Webhook handler lacks discussion of retry behavior and exponential backoff from MessageBird (Priority: High) --> <!-- EXPAND: Could benefit from a table showing all possible HTTP status codes and their meanings in webhook context (Type: Enhancement) --> <!-- GAP: Missing guidance on testing webhook handler locally with sample payloads (Type: Substantive) -->

Looking to expand your MessageBird SMS integration? Check out these related guides:

MessageBird Webhook Integration FAQ

How do I verify MessageBird webhook signatures in RedwoodJS?

Use the WebhookSignatureJwt class from the MessageBird SDK. Import it with import { WebhookSignatureJwt } from 'messagebird/lib/webhook-signature-jwt', then instantiate it with your signing key and call verifier.verify() with the signature header, request URL, query parameters, and raw body. You must disable RedwoodJS's automatic body parser by exporting config = { api: { bodyParser: false } } in your function file to access the raw request body required for verification.

What is the correct way to initialize the MessageBird SDK in Node.js?

Use initClient() from the MessageBird SDK: import { initClient } from 'messagebird'; const messagebird = initClient('YOUR_API_KEY');. The SDK uses a callback-based API, so wrap methods in Promises for async/await support: await new Promise((resolve, reject) => { messagebird.messages.create(params, (err, resp) => err ? reject(err) : resolve(resp)); }).

How does MessageBird's statusReportUrl parameter work?

The statusReportUrl parameter tells MessageBird where to send HTTP POST requests when your message's delivery status changes. Include it when creating a message: messagebird.messages.create({ originator, recipients, body, statusReportUrl }). MessageBird will send webhooks to this URL with status updates like delivered, delivery_failed, or expired.

Why do I need to set bodyParser: false in RedwoodJS functions?

MessageBird's JWT signature verification requires the raw, unparsed request body to validate the signature. RedwoodJS normally parses JSON bodies automatically, which modifies the body string and breaks signature verification. Setting export const config = { api: { bodyParser: false } } in your function file prevents automatic parsing, letting you access event.body as the original raw string needed for verification.

What MessageBird message statuses should I handle in my webhook?

Handle these key statuses: sent (message left MessageBird's platform), buffered (queued for delivery), delivered (successfully received by handset), delivery_failed (permanent failure), and expired (validity period exceeded). The webhook payload includes status, statusReason, and optionally statusErrorCode for detailed failure information.

<!-- GAP: Missing detailed explanation of each status code and when it occurs in the message lifecycle (Type: Substantive) -->

How do I test MessageBird webhooks locally with RedwoodJS?

Use RedwoodJS's mockSignedWebhook testing utility to simulate webhooks without external tools: const event = mockSignedWebhook({ payload, signatureType: 'sha256Verifier', signatureHeader: 'MessageBird-Signature-JWT', secret: 'YOUR_SIGNING_KEY' }). This creates a properly signed test event you can pass to your handler in unit tests. For live testing, use ngrok to expose your local server and configure the ngrok URL in MessageBird's dashboard.

<!-- DEPTH: Testing section lacks complete test examples and unit test structure (Priority: High) -->

What happens if MessageBird sends duplicate webhook deliveries?

MessageBird may retry webhooks up to 10 times if it doesn't receive a 200 OK response. Implement idempotency by using MessageBird's unique message ID (messageBirdId) as your database lookup key. Prisma's update with a where clause on the unique messageBirdId field ensures duplicate webhooks safely update the same record without creating duplicates.

How do I configure the webhook URL in MessageBird's dashboard?

MessageBird doesn't provide a programmatic API for webhook URL configuration. You must configure it manually through the Flow Builder dashboard. For status reports, pass the statusReportUrl parameter when creating each message. For inbound messages, configure the webhook URL in Flow Builder using the "Forward to URL" step.

<!-- GAP: Missing step-by-step screenshots or detailed navigation instructions for MessageBird dashboard configuration (Type: Critical) -->

What security measures should I implement for MessageBird webhooks?

Always verify the MessageBird-Signature-JWT header using your signing key before processing payloads. Return 401 Unauthorized for invalid signatures. Validate the webhook payload structure before database operations. Consider implementing rate limiting to prevent abuse. Store your signing key securely in environment variables, never in code. Use HTTPS in production to prevent man-in-the-middle attacks.

<!-- EXPAND: Could benefit from a security checklist and rate limiting implementation example (Type: Enhancement) -->

How do I handle webhook failures and retries in RedwoodJS?

Return appropriate HTTP status codes: 200 OK when successfully processed (stops retries), 401 Unauthorized for signature failures (stops retries), and 500 Internal Server Error for temporary issues (triggers MessageBird retries). Log all webhook attempts with logger.info() for debugging. MessageBird will retry failed webhooks up to 10 times with exponential backoff, so design your handler to be idempotent.

<!-- GAP: Missing details on MessageBird's retry schedule and exponential backoff timings (Type: Substantive) --> <!-- GAP: Missing production deployment considerations (environment-specific configurations, monitoring, logging best practices) (Type: Critical) --> <!-- EXPAND: Could benefit from a troubleshooting section with common issues and solutions (Type: Enhancement) -->

Frequently Asked Questions

How to track MessageBird SMS delivery status in RedwoodJS?

Use MessageBird's webhooks and a RedwoodJS function to capture real-time delivery updates. Set up a webhook endpoint in your Redwood app and configure it in your MessageBird dashboard. The webhook will send an HTTP POST request to your app whenever a message status changes, allowing you to update its status in your database.

What is the role of Prisma in MessageBird SMS integration?

Prisma acts as an ORM (Object-Relational Mapper), simplifying database interactions. It allows you to define your database schema (like the Message model), manage migrations, and access data in a type-safe way using Prisma Client. This streamlines database operations within your RedwoodJS application.

Why does RedwoodJS use a serverless function for webhooks?

Serverless functions are ideal for handling asynchronous events like webhooks. They are cost-effective because you only pay for the compute time used when a webhook is received. RedwoodJS integrates seamlessly with serverless providers, making webhook implementation straightforward.

When should I update the APP_BASE_URL environment variable?

The `APP_BASE_URL` is critical for webhook functionality and must be updated whenever your application's public URL changes. This includes using tools like ngrok for local development or deploying to a production environment. Ensure this variable accurately reflects the reachable address of your application.

Can I use Alphanumeric Sender IDs with MessageBird in RedwoodJS?

Yes, you can use Alphanumeric Sender IDs, but be mindful of country-specific restrictions. Instead of a phone number, you can use an approved alphanumeric string (e.g., your brand name) as the sender. Consult MessageBird's documentation for supported countries and regulations.

How to set up environment variables in RedwoodJS for MessageBird?

RedwoodJS utilizes a `.env` file at the root of your project. In this file, you'll store sensitive information like your `MESSAGEBIRD_API_KEY`, `MESSAGEBIRD_SIGNING_KEY`, `DATABASE_URL`, and `APP_BASE_URL`. Remember to replace placeholders with your actual credentials and keep this file secure.

What is the correct path for the MessageBird webhook endpoint?

The correct path is `/.redwood/functions/messagebirdWebhook`. This corresponds to the Redwood function you create to handle incoming webhook requests from MessageBird. Make sure this path matches exactly in your service function and webhook handler.

How to verify MessageBird webhook signatures in RedwoodJS?

The MessageBird Node.js SDK provides the `WebhookSignatureJwt` class for signature verification. You'll initialize it with your `MESSAGEBIRD_SIGNING_KEY` and use it to verify the signature in the `messagebirdWebhook` function. This ensures that incoming webhook requests genuinely originate from MessageBird.

What is the purpose of setting bodyParser to false in the webhook function?

Setting `bodyParser: false` in the `messagebirdWebhook` function's `config` object prevents RedwoodJS from automatically parsing the incoming request body as JSON. This is crucial because MessageBird's signature verification requires the raw, unparsed request body.

How to handle different MessageBird webhook statuses in RedwoodJS?

Your `updateMessageStatusFromWebhook` service function will receive the `status` from the MessageBird webhook payload. Use this value to update the corresponding message record in your database. Common statuses include 'sent', 'delivered', 'delivery_failed', and 'expired'.

What does the 'queued' status mean in the message sending process?

The 'queued' status indicates that the message has been created in your database and is awaiting delivery via MessageBird. It's the initial state before the message is actually sent to the MessageBird API. The status will change to 'sent' once MessageBird accepts the message for delivery.

How to handle MessageBird API errors during SMS sending?

Implement error handling within the `sendMessage` service function to catch potential issues during the MessageBird API call. Update the message status in the database accordingly (e.g., 'failed_to_send') and log the error for debugging. You might also want to notify users or retry the send operation based on the error type.

What if the MessageBird webhook payload is missing the messageBirdId?

The `messageBirdId` is essential for correlating webhook updates with existing messages in your database. If the payload is missing this ID, you cannot process the update. Log a warning and return an appropriate response to MessageBird (e.g., a 400 Bad Request).

Why is a Prisma index added to the status field?

The index on the `status` field in your Prisma schema improves the performance of queries that filter by message status. This optimization is beneficial when you need to quickly retrieve messages with specific statuses, such as 'delivered' or 'failed'.