code examples
code examples
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.
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.
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):
ngrokor a similar tool to expose your local webhook endpoint to the internet for MessageBird callbacks.
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.
-
Create RedwoodJS App: Open your terminal and run:
bashyarn create redwood-app ./messagebird-redwood-status --typescriptThis command scaffolds a new RedwoodJS project named
messagebird-redwood-statususing TypeScript. -
Navigate to Project Directory:
bashcd messagebird-redwood-status -
Install Dependencies: We need the MessageBird Node.js SDK and
dotenvfor managing environment variables on the API side.bashyarn workspace api add messagebird dotenv
-
Configure Environment Variables: RedwoodJS uses a
.envfile 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/PRODDATABASE_URL: Points to your PostgreSQL instance. Ensure the database exists.- Important Security Note: The example
DATABASE_URLuses 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 thestatusReportUrlsent to MessageBird and the URL you configure in the MessageBird dashboard. Remember to update this when usingngrokor deploying.
-
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.
- This file safely initializes the MessageBird client using the API key from
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.
-
Define Database Schema: Open
api/db/schema.prismaand define aMessagemodel: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 topending.- Note on
statusType: UsingStringprovides flexibility if MessageBird introduces new status values not defined in your code. However, using a Prismaenum(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.
- Note on
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 anOrderorUser). @@index([status]): Added an index to thestatusfield for potentially faster queries filtering by status.
-
Apply Database Migrations: Create and apply the migration to your database:
bashyarn rw prisma migrate dev --name add_message_modelFollow the prompts. This creates the
Messagetable in your PostgreSQL database and applies the index.
-
Generate GraphQL SDL and Services: Use Redwood's generators to create the boilerplate for GraphQL types, queries, mutations, and the service file for the
Messagemodel:bashyarn rw g sdl Message --crudThis 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.
-
Implement Message Sending Service: Modify the generated
api/src/services/messages/messages.tsto handle sending SMS via MessageBird and integrate the webhook logic. We'll replace the defaultcreateMessagewith a customsendMessagemutation 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
recipientandbodyas input. - Constructs the
statusReportUrlusingAPP_BASE_URLand the exact path to the Redwood function we'll create (/.redwood/functions/messagebirdWebhook). - Creates a
Messagerecord in the DB with statusqueued. - Calls
messagebird.messages.create, passing thestatusReportUrl. - Uses a
Promisewrapper around the callback-based SDK method for better async/await flow. - If successful, updates the DB record with the
messageBirdIdfrom the response and sets status tosent. - 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).
- Takes
updateMessageStatusFromWebhook:- Takes the verified webhook payload, strongly typed with
MessageBirdWebhookPayload(which is now exported). - Finds the corresponding
Messagerecord using the uniquemessageBirdId. - Updates the
status,statusDetails, andstatusUpdatedAtfields. - Logs success or failure, specifically handling the
P2025(Record Not Found) Prisma error as a non-critical warning. Returns a boolean indicating success/failure.
- Takes the verified webhook payload, strongly typed with
- Standard CRUD: The original
createMessageis removed/commented out with warnings.updateMessageanddeleteMessageare kept but include warnings and TODOs for adding authorization, as direct manipulation might bypass business logic or security checks.
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.
-
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
SendMessageInputtype for our custom mutation. - Replaced the default
createMessagemutation withsendMessage(input: SendMessageInput!): Message!. - Kept the standard
Messagetype, queries (messages,message), and other mutations (updateMessage,deleteMessage). - Added comments reminding to implement proper authorization (
@requireAuthis a basic check, might need role-based access) if the standardupdate/deletemutations are exposed. - Ensured the
scalar DateTimedefinition is present (often included by default).
- Added the
-
Testing the Mutation (Conceptual): You can use the Redwood GraphQL Playground (usually available at
http://localhost:8911/graphqlwhen runningyarn rw dev) to test thesendMessagemutation.Example GraphQL Mutation:
graphqlmutation 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
sendMessageservice function, create a DB record, call the MessageBird API, update the record with themessageBirdIdandsentstatus, and return the result.
- Executing this should trigger the
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.
-
Create Redwood Function: Use the Redwood generator:
bashyarn rw g function messagebirdWebhook --typescriptThis creates
api/src/functions/messagebirdWebhook.ts. -
Implement Webhook Handler and Signature Verification: Edit
api/src/functions/messagebirdWebhook.tsto 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
WebhookSignatureJwtfrom the MessageBird SDK. - Loads the
MESSAGEBIRD_SIGNING_KEYfrom.env. - Crucially, includes
export const config = { api: { bodyParser: false } };to ensureevent.bodycontains the raw request string needed for verification. - Instantiates the
verifieroutside the handler. - Checks for
POSTmethod and the presence of theMessageBird-Signature-JWTheader. - Reconstructs the
requestUrlusingAPP_BASE_URLandevent.path. This is a critical step and needs careful testing in deployment. - Calls
verifier.verifywith the signature, reconstructed URL, query parameters (event.queryStringParameters), and the raw body (event.body). - Returns
401 Unauthorizedif verification fails.
- Imports
- Payload Processing:
- Parses the
rawBodyinto a JSON object only after successful signature verification. - Performs basic validation on the parsed
payloadto ensure required fields exist. - Returns
400 Bad Requestif parsing or validation fails.
- Parses the
- Database Update:
- Calls the
updateMessageStatusFromWebhookservice function with the validatedpayload. - Returns
200 OKif the service function indicates success (returnstrue). - Returns
500 Internal Server Errorif the service function indicates failure (returnsfalse) or if an unexpected error occurs, potentially prompting MessageBird to retry.
- Calls the
- Signature Verification:
Related Resources
Looking to expand your MessageBird SMS integration? Check out these related guides:
- Sending SMS with MessageBird in RedwoodJS - Learn the basics of MessageBird SMS API integration
- MessageBird MMS Integration with RedwoodJS - Add multimedia messaging capabilities
- Two-Way SMS Messaging with MessageBird - Handle incoming SMS replies
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.
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.
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.
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.
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.
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'.