This guide provides a step-by-step walkthrough for building a production-ready system within a RedwoodJS application to send SMS messages via MessageBird and track their delivery status using webhooks. We'll cover everything from project setup and core implementation to security, error handling, and deployment.
By the end of this 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.
Project Overview and Goals
Problem: Many applications need to send SMS notifications (e.g., order confirmations, alerts, OTPs) but lack visibility into whether the message was actually delivered to the recipient's handset. Relying solely on the initial API sent
confirmation is insufficient; network issues, invalid numbers, or carrier blocks can prevent delivery.
Solution: We will leverage MessageBird's SMS API to send messages and configure its webhook feature. MessageBird will send HTTP POST requests (webhooks) to a specific endpoint in our RedwoodJS application whenever the delivery status of a message changes (e.g., sent
, delivered
, delivery_failed
). Our application will securely verify these incoming webhooks and update the corresponding message record in our database.
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)
+---------------+ +-----------------+
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.
1. Setting up the RedwoodJS Project
Let's create a new RedwoodJS application and install the necessary dependencies.
-
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app ./messagebird-redwood-status --typescript
This command scaffolds a new RedwoodJS project named
messagebird-redwood-status
using TypeScript. -
Navigate to Project Directory:
cd messagebird-redwood-status
-
Install Dependencies: We need the MessageBird Node.js SDK and
dotenv
for managing environment variables on the API side.yarn workspace api add messagebird dotenv
-
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:# .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 thestatusReportUrl
sent to MessageBird and the URL you configure in the MessageBird dashboard. Remember to update this when usingngrok
or deploying.
-
Initialize MessageBird Client: Create a reusable MessageBird client instance. Create the file
api/src/lib/messagebird.ts
:// 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 Core Functionality (Database & Services)
We need a way to store message details and their status updates.
-
Define Database Schema: Open
api/db/schema.prisma
and define aMessage
model:// 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
status
Type: UsingString
provides 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 anOrder
orUser
). @@index([status])
: Added an index to thestatus
field for potentially faster queries filtering by status.
-
Apply Database Migrations: Create and apply the migration to your database:
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. -
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: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.
-
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 defaultcreateMessage
with a customsendMessage
mutation and add a function to handle webhook updates.// 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
andbody
as input. - Constructs the
statusReportUrl
usingAPP_BASE_URL
and the exact path to the Redwood function we'll create (/.redwood/functions/messagebirdWebhook
). - Creates a
Message
record in the DB with statusqueued
. - Calls
messagebird.messages.create
, passing thestatusReportUrl
. - 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 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
Message
record using the uniquemessageBirdId
. - Updates the
status
,statusDetails
, andstatusUpdatedAt
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.
- Takes the verified webhook payload, strongly typed with
- Standard CRUD: The original
createMessage
is removed/commented out with warnings.updateMessage
anddeleteMessage
are kept but include warnings and TODOs for adding authorization, as direct manipulation might bypass business logic or security checks.
3. Building the API Layer (GraphQL)
Redwood generated most of the GraphQL setup. We just need to adjust the SDL to match our new sendMessage
mutation.
-
Update GraphQL Schema Definition: Modify
api/src/graphql/messages.sdl.ts
:// 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 withsendMessage(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 standardupdate/delete
mutations are exposed. - Ensured the
scalar DateTime
definition 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/graphql
when runningyarn rw dev
) to test thesendMessage
mutation.Example GraphQL Mutation:
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 themessageBirdId
andsent
status, and return the result.
- Executing this should trigger the
4. Integrating Third-Party Services (MessageBird Webhook)
This is the core of receiving status updates. We need a dedicated Redwood Function to handle incoming POST requests from MessageBird.
-
Create Redwood Function: Use the Redwood generator:
yarn rw g function messagebirdWebhook --typescript
This creates
api/src/functions/messagebirdWebhook.ts
. -
Implement Webhook Handler and Signature Verification: Edit
api/src/functions/messagebirdWebhook.ts
to verify the signature and process the payload:// 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 ensureevent.body
contains the raw request string needed for verification. - Instantiates the
verifier
outside the handler. - Checks for
POST
method and the presence of theMessageBird-Signature-JWT
header. - Reconstructs the
requestUrl
usingAPP_BASE_URL
andevent.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.
- Imports
- 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.
- Parses the
- Database Update:
- Calls the
updateMessageStatusFromWebhook
service function with the validatedpayload
. - Returns
200 OK
if the service function indicates success (returnstrue
). - Returns
500 Internal Server Error
if the service function indicates failure (returnsfalse
) or if an unexpected error occurs, potentially prompting MessageBird to retry.
- Calls the
- Signature Verification: