Developer Guide: Integrating WhatsApp with RedwoodJS using MessageBird
This guide provides a complete walkthrough for integrating WhatsApp messaging capabilities into your RedwoodJS application using the MessageBird API. We will build a system capable of sending outbound WhatsApp messages (including template messages) and receiving inbound messages via webhooks.
Project Goals:
- Enable a RedwoodJS application to send WhatsApp messages programmatically via MessageBird.
- Handle both standard text messages and pre-approved WhatsApp Template Messages (HSM).
- Receive incoming WhatsApp messages using MessageBird webhooks.
- Store message history in a database.
- Establish a robust, scalable, and secure integration suitable for production environments.
Problem Solved:
This integration allows businesses using RedwoodJS applications to leverage WhatsApp – a ubiquitous communication channel – for customer engagement, notifications, support, and potentially two-factor authentication, directly from their application's backend. It abstracts the complexities of the WhatsApp Business API through MessageBird's unified interface.
Technologies Used:
- RedwoodJS: A full-stack, serverless web application framework based on React, GraphQL, and Prisma. Provides structure for API (functions), services, and database interaction.
- Node.js: The underlying runtime environment for the RedwoodJS API side.
- MessageBird: A communication Platform as a Service (CPaaS) provider offering APIs for various channels, including WhatsApp. We'll use their Node.js SDK and REST API.
- Prisma: A next-generation ORM for Node.js and TypeScript, used by RedwoodJS for database access and migrations.
- PostgreSQL (or similar): Relational database for storing message logs and potentially conversation state.
- (Optional)
ngrok
: For exposing local development webhook endpoints to the internet for testing.
System Architecture:
+-----------------+ +-----------------+ +-----------------------+
| RedwoodJS UI |------>| RedwoodJS API |<----->| PostgreSQL DB |
| (React Frontend)| | (GraphQL/Funcs) | | (Prisma Client) |
+-----------------+ +--------+--------+ +-----------------------+
|
| (MessageBird SDK / API Calls)
v
+------------------------------------+-------------------------------------+
| MessageBird Platform |
| +-------------------+ +------------------+ +-----------------+ |
| | WhatsApp API GW |----->| Outbound Sending | | Inbound Webhook | |
| +-------------------+ +------------------+ +--------+--------+ |
+--------------------------------------------------------------------------+
^ | (Webhook POST)
| (Messages to/from User) |
v |
+-----------------+ |
| WhatsApp User |--------------------------------------------+
+-----------------+
Prerequisites:
- Node.js (v16 or later recommended) and Yarn installed.
- A MessageBird account with access to the WhatsApp Business API channel.
- An approved WhatsApp Business number and channel configured within MessageBird.
- Basic understanding of RedwoodJS concepts (services, functions, Prisma).
- Access to a terminal or command prompt.
- (Optional, for local webhook testing)
ngrok
installed.
Final Outcome:
By the end of this guide, you will have:
- A RedwoodJS service capable of sending WhatsApp messages via MessageBird.
- An API endpoint (Redwood function) to trigger sending messages.
- A webhook endpoint (Redwood function) to receive incoming messages and status updates from MessageBird.
- A database schema and logic to log sent and received messages.
- Configuration for secure handling of API keys and webhook secrets.
1. Setting up the Project
We'll start with a fresh RedwoodJS project. If you have an existing project, adapt the steps accordingly.
-
Create RedwoodJS Project: Open your terminal and run:
yarn create redwood-app ./redwood-messagebird-whatsapp cd redwood-messagebird-whatsapp
Follow the prompts (choose JavaScript or TypeScript). This guide will use JavaScript examples, but the concepts are identical for TypeScript.
-
Verify Node/Yarn: Ensure your Node.js and Yarn versions meet RedwoodJS requirements.
node -v yarn -v
-
Environment Variables: RedwoodJS uses
.env
files for environment variables. Create one in the project root:touch .env
Add the following placeholders. You will get the actual values from your MessageBird dashboard later.
# .env MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_LIVE_API_KEY MESSAGEBIRD_WHATSAPP_CHANNEL_ID=YOUR_WHATSAPP_CHANNEL_ID # Optional, but HIGHLY recommended for webhook security MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_MESSAGEBIRD_WEBHOOK_SIGNING_KEY
MESSAGEBIRD_API_KEY
: Your live API access key from the MessageBird Dashboard (Developers -> API access).MESSAGEBIRD_WHATSAPP_CHANNEL_ID
: The unique ID of your configured WhatsApp channel in MessageBird (Channels -> WhatsApp).MESSAGEBIRD_WEBHOOK_SIGNING_KEY
: A secret key you configure in MessageBird webhooks for request signature verification. Generate a strong random string for this.
Important: Add
.env
to your.gitignore
file to avoid committing secrets. RedwoodJS automatically loads.env
variables intoprocess.env
. -
Install MessageBird SDK: Add the official MessageBird Node.js SDK to your project's API side dependencies.
yarn workspace api add messagebird
This installs the SDK and adds it to
api/package.json
. -
Project Structure: RedwoodJS provides a clear structure:
api/
: Backend code (GraphQL API, serverless functions, services, database).api/src/functions/
: Serverless functions triggered via HTTP (our API endpoint and webhook).api/src/services/
: Business logic, interacting with external APIs (like MessageBird) and the database.api/db/
: Database schema (schema.prisma
) and migrations.
web/
: Frontend React code. (We won't focus heavily on the UI in this guide).
2. Database Schema and Data Layer
We need a way to store message history.
-
Define Prisma Schema: Open
api/db/schema.prisma
and add aMessageLog
model:// api/db/schema.prisma datasource db { provider = ""postgresql"" // Or your chosen DB provider url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" binaryTargets = ""native"" } // Add this model model MessageLog { id String @id @default(cuid()) messageBirdId String? @unique // MessageBird's message ID - crucial for idempotency and updates conversationId String? // MessageBird's conversation ID channelId String // Our MessageBird WhatsApp Channel ID status String // e.g., 'pending', 'accepted', 'sent', 'delivered', 'received', 'failed' direction String // 'outgoing' or 'incoming' recipient String // E.164 format phone number sender String // E.164 format phone number or Channel ID for outgoing messageType String // 'text', 'hsm', 'image', 'document', etc. content Json? // Full message content/payload errorCode Int? // MessageBird error code if status is 'failed' errorDescription String? // Error details createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([recipient]) // Index for faster lookups by recipient @@index([conversationId]) // Index for faster lookups by conversation @@index([status]) // Index for potentially querying statuses } // Ensure you have your User model or other relevant models if needed // model User { ... }
- We use optional fields (
?
) where data might not always be present (e.g.,messageBirdId
before sending,errorCode
on success). content
is stored as JSON to handle various message structures flexibly.- The
@unique
constraint onmessageBirdId
helps prevent duplicate entries if webhooks are delivered multiple times. - Added
@@index
directives for potentially common query fields.
- We use optional fields (
-
Apply Migrations: Generate and apply the database migration.
yarn rw prisma migrate dev --name add_message_log
This creates the SQL migration file and updates your database schema.
-
Prisma Client: RedwoodJS automatically generates and provides the Prisma client instance (
db
) available in services and functions. We'll use this later to interact with theMessageLog
table.
3. Implementing Core Functionality (Sending Messages)
We'll create a RedwoodJS service to encapsulate the logic for sending messages via MessageBird.
-
Generate Service:
yarn rw g service whatsapp
This creates
api/src/services/whatsapp/whatsapp.js
andwhatsapp.test.js
. -
Implement Sending Logic: Open
api/src/services/whatsapp/whatsapp.js
and add the following:// api/src/services/whatsapp/whatsapp.js import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' // Initialize MessageBird client (ensure correct import path if needed) // Note: The exact import might differ slightly based on SDK versions. Check SDK docs. const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY) const WHATSAPP_CHANNEL_ID = process.env.MESSAGEBIRD_WHATSAPP_CHANNEL_ID /** * Sends a WhatsApp message via MessageBird. * Handles both freeform text (within 24h window) and HSM templates. * * @param {object} params - Parameters for sending the message. * @param {string} params.to - Recipient phone number in E.164 format (e.g., +14155552671). * @param {string} params.type - Message type: 'text' or 'hsm'. * @param {object} params.content - Message content payload. * - For type 'text': { text: 'Your message content' } * - For type 'hsm': { hsm: { namespace: '...', templateName: '...', language: { code: 'en', policy: 'deterministic' }, params: [{ default: 'value1' }, ...] } } * or using 'components' for media templates as per MessageBird docs. * @returns {Promise<object>} - Result object with success status and message data or error. */ export const sendWhatsappMessage = async ({ to, type, content }) => { if (!WHATSAPP_CHANNEL_ID) { logger.error('MESSAGEBIRD_WHATSAPP_CHANNEL_ID is not configured.') throw new Error('WhatsApp channel ID is missing.') } if (!process.env.MESSAGEBIRD_API_KEY) { logger.error('MESSAGEBIRD_API_KEY is not configured.') throw new Error('MessageBird API Key is missing.') } const payload = { to: to, from: WHATSAPP_CHANNEL_ID, // Use channel ID as sender type: type, content: content, } logger.info({ payload: { ...payload, content: '<<omitted>>' } }, `Attempting to send WhatsApp message to ${to}`) // Avoid logging full content potentially let logEntryId = null try { // Pre-log the attempt (optional, but useful for tracking) const initialLog = await db.messageLog.create({ data: { channelId: WHATSAPP_CHANNEL_ID, status: 'pending', // Initial status direction: 'outgoing', recipient: to, sender: WHATSAPP_CHANNEL_ID, messageType: type, content: content, // Store the intended content }, }) logEntryId = initialLog.id logger.info(`Created initial log entry: ${logEntryId}`) // Use conversations.send API - preferred for flexibility const result = await new Promise((resolve, reject) => { messagebird.conversations.send(payload, (err, response) => { if (err) { // Log detailed error information from MessageBird if available const errorDetails = err.errors ? JSON.stringify(err.errors) : err.message logger.error({ statusCode: err.statusCode, details: errorDetails, payload }, 'MessageBird API error') return reject(err) } logger.info({ response }, 'MessageBird API success response') resolve(response) }) }) // Update log entry with MessageBird ID and status (often 'accepted') if (result && result.message && result.message.id) { await db.messageLog.update({ where: { id: logEntryId }, data: { messageBirdId: result.message.id, // Note: MessageBird's 'send' might return status 'accepted', not 'sent' directly. // Actual delivery status comes via webhook. status: result.message.status || 'accepted', conversationId: result.message.conversationId, // Capture conversation ID if available // Store the full response content for reference if needed, or just keep the original intended content // content: result, }, }) logger.info(`Updated log entry ${logEntryId} with MessageBird ID: ${result.message.id}`) } else { // Handle cases where the response format might differ or lack an ID await db.messageLog.update({ where: { id: logEntryId }, data: { status: 'sent_api_no_id' }, // Indicate successful API call but no ID returned }) logger.warn({ result }, 'MessageBird send API response did not contain expected message ID.') } return { success: true, data: result } } catch (error) { logger.error({ error: error.message, statusCode: error.statusCode, errors: error.errors, payload }, 'Failed to send WhatsApp message') // Update log entry with error details if it was created if (logEntryId) { const errorDescription = error.errors ? JSON.stringify(error.errors) : (error.message || 'Unknown error'); await db.messageLog.update({ where: { id: logEntryId }, data: { status: 'failed', errorCode: error.statusCode || null, // Attempt to get HTTP status code errorDescription: errorDescription.substring(0, 500), // Limit error description length if needed }, }) logger.info(`Updated log entry ${logEntryId} to failed status.`) } // Rethrow or return structured error throw new Error(`Failed to send message: ${error.message}`) // Keep original error context // Or: return { success: false, error: error.message, details: error.errors } } } // --- Helper functions or other service methods can go here --- /** * Example: Get recent message logs for a specific recipient */ export const getMessageLogsByRecipient = async ({ recipient }) => { logger.debug(`Fetching logs for recipient: ${recipient}`) return db.messageLog.findMany({ where: { recipient: recipient }, orderBy: { createdAt: 'desc' }, take: 50, }) }
Explanation:
- We initialize the
messagebird
client using the API key from environment variables. Added checks for missing keys. - The
sendWhatsappMessage
function takes the recipient (to
), messagetype
('text' or 'hsm'), and thecontent
payload. - It constructs the payload required by the MessageBird
conversations.send
API endpoint. Crucially,from
is set to yourMESSAGEBIRD_WHATSAPP_CHANNEL_ID
. - Logging: We log the attempt before calling the API and update the log with the result (success or failure, including the
messageBirdId
). This ensures traceability even if the API call fails network-wise. Error logging includes details fromerror.errors
. - API Call: We use
messagebird.conversations.send
. The SDK uses callbacks, so we wrap it in aPromise
forasync/await
compatibility. - HSM Templates: For
type: 'hsm'
, thecontent
object must match the structure shown in the MessageBird API documentation. You need thenamespace
andtemplateName
from your approved template in the MessageBird dashboard. Parameter substitution happens via theparams
array (orcomponents
for media templates). - Error Handling: A
try...catch
block handles API errors, logs them (including MessageBird-specific error details), updates the database record to 'failed', and includes error details. - The
getMessageLogsByRecipient
is an example of how to query the logs using Prisma.
- We initialize the
4. Building the API Layer (Exposing Sending Functionality)
We need an HTTP endpoint to trigger our sendWhatsappMessage
service. A RedwoodJS serverless function is perfect for this.
-
Generate Function:
yarn rw g function sendWhatsapp
This creates
api/src/functions/sendWhatsapp.js
. -
Implement API Endpoint: Open
api/src/functions/sendWhatsapp.js
and implement the handler:// api/src/functions/sendWhatsapp.js import { logger } from 'src/lib/logger' import { sendWhatsappMessage } from 'src/services/whatsapp/whatsapp' // Input validation library (recommended) import { z } from 'zod' // Zod Schema for input validation: const messageSchema = z.object({ to: z.string().regex(/^\+[1-9]\d{1,14}$/, { message: ""Invalid E.164 phone number format"" }), // Basic E.164 validation type: z.enum(['text', 'hsm']), content: z.object({}).passthrough(), // Allow any object structure for content initially }).refine(data => { if (data.type === 'text') { return typeof data.content.text === 'string' && data.content.text.length > 0; } if (data.type === 'hsm') { // Basic check for HSM structure. More specific validation might be needed // depending on required HSM fields (namespace, templateName etc.) return typeof data.content.hsm === 'object' && data.content.hsm !== null; } return false; // Should not happen due to enum check, but defensively return false }, { message: ""Invalid content structure for the specified type"" }); /** * @param {APIGatewayEvent} event - The AWS API Gateway event object. * @param {Context} context - The AWS Lambda context object. */ export const handler = async (event, context) => { logger.info('Received request to sendWhatsapp function') // >>> SECURITY CRITICAL <<< // In a real application_ implement robust authentication and authorization here. // Example checks (adapt to your auth strategy): // - Verify a JWT Bearer token from event.headers.authorization // - Check RedwoodJS session context if called via GraphQL with @requireAuth // - Validate an API key passed in headers // If auth fails_ return { statusCode: 401 } or { statusCode: 403 } immediately. // const isAuthenticated = await checkAuth(event.headers); // Replace with your actual auth check // if (!isAuthenticated) { // logger.warn('Unauthorized attempt to call sendWhatsapp'); // return { statusCode: 401_ body: JSON.stringify({ error: 'Unauthorized' }) }; // } // logger.info('Request authorized'); // Log successful authorization if (event.httpMethod !== 'POST') { return { statusCode: 405_ headers: { 'Allow': 'POST' }_ body: 'Method Not Allowed' } } let body let validatedData try { body = JSON.parse(event.body) logger.info({ bodyKeys: Object.keys(body) }_ 'Parsed request body') // Avoid logging full body if sensitive // Validate input using Zod const validationResult = messageSchema.safeParse(body); if (!validationResult.success) { logger.warn({ errors: validationResult.error.flatten().fieldErrors }_ 'Invalid request body') return { statusCode: 400_ headers: { 'Content-Type': 'application/json' }_ body: JSON.stringify({ error: 'Invalid input'_ details: validationResult.error.flatten().fieldErrors }) } } validatedData = validationResult.data; // Use validated data } catch (error) { logger.error({ error }_ 'Failed to parse request body') return { statusCode: 400_ headers: { 'Content-Type': 'application/json' }_ body: JSON.stringify({ error: 'Invalid JSON body' }) } } try { // Use validated data from Zod const result = await sendWhatsappMessage({ to: validatedData.to_ type: validatedData.type_ content: validatedData.content_ }) logger.info({ messageBirdId: result.data?.message?.id }_ 'Successfully processed sendWhatsapp request') return { statusCode: 200_ // Or 202 Accepted if processing is truly async beyond the initial API call headers: { 'Content-Type': 'application/json' }_ body: JSON.stringify(result)_ // Contains { success: true_ data: ... } } } catch (error) { logger.error({ error: error.message_ statusCode: error.statusCode_ errors: error.errors }_ 'Error calling sendWhatsappMessage service') // Avoid leaking internal error details unless necessary const responseBody = { success: false_ error: 'Internal server error processing message'_ // Optionally include a correlation ID for logs }; // Map specific MessageBird errors to user-friendly messages if desired if (error.statusCode === 470) { // Example: 24-hour window error responseBody.error = 'Cannot send freeform message outside the 24-hour window. Use a template.'; return { statusCode: 403_ headers: { 'Content-Type': 'application/json' }_ body: JSON.stringify(responseBody) }; } return { statusCode: error.statusCode && error.statusCode >= 400 && error.statusCode < 500 ? error.statusCode : 500_ // Return specific client error codes if available headers: { 'Content-Type': 'application/json' }_ body: JSON.stringify(responseBody)_ } } }
Explanation:
- The handler receives standard API Gateway event/context objects.
- Security: A prominent comment emphasizes the critical need for authentication/authorization. This must be implemented based on your application's specific security model before production use.
- It expects a
POST
request with a JSON body. - Validation: It now uses
zod
for robust input validation (phone number format_ type enum_ basic content structure). Invalid requests are rejected with a400 Bad Request
and details. - It parses the JSON body and calls the
sendWhatsappMessage
service using the validated data. - It returns a
200 OK
response with the service result on success_ or appropriate error codes (400
_401
_403
_405
_500
) on failure. It attempts to map some known errors (like the 24-hour window) to more specific status codes/messages.
-
Testing the Endpoint: Start the development server:
yarn rw dev
Install Zod if you haven't already:
yarn workspace api add zod
Use
curl
or a tool like Postman to send a request (replace placeholders with your actual test number_ approved template details):-
Text Message (Requires user to have messaged you within 24h):
curl -X POST http://localhost:8911/sendWhatsapp \ -H ""Content-Type: application/json"" \ -d '{ ""to"": ""+14155552671""_ ""type"": ""text""_ ""content"": { ""text"": ""Hello from RedwoodJS via MessageBird!"" } }'
-
HSM Template Message (Can initiate conversation): (Replace
namespace
_templateName
_ and params with your actual approved template)curl -X POST http://localhost:8911/sendWhatsapp \ -H ""Content-Type: application/json"" \ -d '{ ""to"": ""+14155552671""_ ""type"": ""hsm""_ ""content"": { ""hsm"": { ""namespace"": ""your_template_namespace_guid""_ ""templateName"": ""your_approved_template_name""_ ""language"": { ""policy"": ""deterministic""_ ""code"": ""en"" }_ ""params"": [ { ""default"": ""Customer Name"" }_ { ""default"": ""Order #12345"" } ] } } }'
-
HSM Media Template Example (Document): (Replace template details and document URL)
curl -X POST http://localhost:8911/sendWhatsapp \ -H ""Content-Type: application/json"" \ -d '{ ""to"": ""+14155552671""_ ""type"": ""hsm""_ ""content"": { ""hsm"": { ""namespace"": ""your_media_template_namespace""_ ""templateName"": ""your_media_template_name""_ ""language"": { ""policy"": ""deterministic""_ ""code"": ""en"" }_ ""components"": [ { ""type"": ""header""_ ""parameters"": [ { ""type"": ""document""_ ""document"": { ""url"": ""https://www.example.com/your_document.pdf""_ ""filename"": ""optional_filename.pdf"" } } ] }_ { ""type"": ""body""_ ""parameters"": [ { ""type"": ""text""_ ""text"": ""Param1 Value"" }_ { ""type"": ""text""_ ""text"": ""Param2 Value"" } ] } ] } } }'
Check your terminal logs (
api |
) and the MessageLog table in your database. You should see the message logged_ and hopefully_ receive it on your test WhatsApp number. Test invalid inputs (bad phone number_ wrong type) to see the Zod validation errors. -
5. Handling Incoming Messages (Webhooks)
MessageBird uses webhooks to notify your application about incoming messages and status updates for outgoing messages.
-
Generate Webhook Function:
yarn rw g function messagebirdWebhook
This creates
api/src/functions/messagebirdWebhook.js
. -
Implement Webhook Handler: Open
api/src/functions/messagebirdWebhook.js
:// api/src/functions/messagebirdWebhook.js import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' import crypto from 'crypto' const MESSAGEBIRD_WEBHOOK_SIGNING_KEY = process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY /** * Verifies the signature of the incoming webhook request from MessageBird. * See: https://developers.messagebird.com/api/webhooks/#verifying-requests * * @param {object} headers - Request headers (lowercase). * @param {string} body - Raw request body. * @returns {boolean} - True if the signature is valid_ false otherwise. */ const verifyMessageBirdSignature = (headers_ body) => { if (!MESSAGEBIRD_WEBHOOK_SIGNING_KEY) { logger.error('Webhook signature verification failed: MESSAGEBIRD_WEBHOOK_SIGNING_KEY not set. Rejecting request.') // In production, strictly return false if the key isn't set to reject unsigned requests. return false } // Headers might be lowercase depending on the API gateway const requestTimestamp = headers['messagebird-request-timestamp'] const signature = headers['messagebird-signature'] if (!requestTimestamp || !signature) { logger.warn('Missing MessageBird signature headers (messagebird-request-timestamp or messagebird-signature).') return false } // Construct the payload string: timestamp + '.' + body const payloadString = `${requestTimestamp}.${body}` // Calculate the expected signature const expectedSignature = crypto .createHmac('sha256', MESSAGEBIRD_WEBHOOK_SIGNING_KEY) .update(payloadString) .digest('hex') // Compare signatures securely (constant time comparison) try { const receivedSigBuffer = Buffer.from(signature); const expectedSigBuffer = Buffer.from(expectedSignature); if (receivedSigBuffer.length !== expectedSigBuffer.length) { logger.warn('Webhook signature length mismatch.'); return false; } return crypto.timingSafeEqual(receivedSigBuffer, expectedSigBuffer); } catch (error) { // Handle potential errors during comparison (e.g., invalid hex strings) logger.error({ error }, 'Error during timingSafeEqual comparison.'); return false; } } /** * Processes incoming MessageBird webhook events (e.g., message.created, message.updated). * * @param {APIGatewayEvent} event - The AWS API Gateway event object. * @param {Context} context - The AWS Lambda context object. */ export const handler = async (event, context) => { // Log raw headers for debugging signature issues if necessary (remove in prod) // logger.debug({ headers: event.headers }, 'Incoming webhook headers'); // Normalize header keys to lowercase for reliable access const normalizedHeaders = Object.keys(event.headers).reduce((acc, key) => { acc[key.toLowerCase()] = event.headers[key]; return acc; }, {}); // 1. Verify Signature (CRITICAL for security) if (!verifyMessageBirdSignature(normalizedHeaders, event.body)) { logger.error('Invalid webhook signature received.') return { statusCode: 401, body: 'Unauthorized' } } logger.info('Webhook signature verified successfully.') // 2. Parse Payload let payload try { payload = JSON.parse(event.body) // Log minimal info to avoid exposing sensitive content in logs logger.info({ type: payload?.type, messageId: payload?.message?.id, conversationId: payload?.conversation?.id }, 'Parsed webhook payload') } catch (error) { logger.error({ error, bodySnippet: event.body?.substring(0, 100) }, 'Failed to parse webhook JSON body') return { statusCode: 400, body: 'Invalid JSON payload' } } // 3. Process Event (Example: message.created and message.updated) try { if (payload.type === 'message.created' && payload.message) { const msg = payload.message if (msg.direction === 'received') { // --- Handle Incoming Message --- logger.info(`Processing incoming message ${msg.id} from ${msg.from} in conversation ${msg.conversationId}`) // Check for existing message to ensure idempotency (using unique constraint on messageBirdId) const existingLog = await db.messageLog.findUnique({ where: { messageBirdId: msg.id }, select: { id: true } // Only select needed field }); if (existingLog) { logger.warn(`Webhook idempotency check: Incoming message ${msg.id} already processed (Log ID: ${existingLog.id}). Skipping creation.`); // Optionally, you could update the existing log if needed, e.g., if status changed rapidly. } else { // Create new log entry for incoming message await db.messageLog.create({ data: { messageBirdId: msg.id, conversationId: msg.conversationId, channelId: msg.channelId, status: msg.status, // Typically 'received' direction: 'incoming', recipient: msg.to, // Your channel ID sender: msg.from, // User's phone number messageType: msg.type, // 'text', 'image', etc. content: msg.content, // Full content payload createdAt: new Date(msg.createdDatetime), // Use MessageBird's timestamp updatedAt: new Date(msg.updatedDatetime || msg.createdDatetime), }, }); logger.info(`Successfully logged incoming message ${msg.id}.`); // --- Add further business logic here --- // e.g., Trigger notifications, parse message content, update CRM, etc. } } else if (msg.direction === 'sent') { // This block might be redundant if message.updated handles final statuses, // but can be useful for logging the initial 'sent' status from MessageBird // if it differs from the initial 'accepted' status from the send API call. logger.info(`Processing 'message.created' event for an outgoing message ${msg.id}. Status: ${msg.status}`); // Upsert logic might be better here or in message.updated handler await db.messageLog.updateMany({ where: { messageBirdId: msg.id }, // Update based on MessageBird ID data: { status: msg.status, // Update status if needed updatedAt: new Date(msg.updatedDatetime || msg.createdDatetime), }, }); } } else if (payload.type === 'message.updated' && payload.message) { // --- Handle Message Status Update (for Outgoing Messages) --- const msg = payload.message; logger.info(`Processing status update for message ${msg.id}. New status: ${msg.status}`); // Update the corresponding log entry based on MessageBird's message ID const updateData = { status: msg.status, // e.g., 'sent', 'delivered', 'read', 'failed' updatedAt: new Date(msg.updatedDatetime || Date.now()), // Use update timestamp errorCode: null, // Clear previous errors if any errorDescription: null, }; if (msg.status === 'failed' && msg.error) { updateData.errorCode = msg.error.code; updateData.errorDescription = msg.error.description; logger.warn(`Message ${msg.id} failed. Code: ${msg.error.code}, Desc: ${msg.error.description}`); } const updateResult = await db.messageLog.updateMany({ where: { messageBirdId: msg.id }, // Find the log entry using the unique MessageBird ID data: updateData, }); if (updateResult.count > 0) { logger.info(`Successfully updated status for message ${msg.id} to ${msg.status}. Matched ${updateResult.count} records.`); } else { // This might happen if the webhook arrives before the initial send API call response is processed // or if the messageBirdId wasn't stored correctly initially. logger.warn(`Webhook update: No existing log entry found for messageBirdId ${msg.id}. Status was ${msg.status}. Might need reconciliation or check initial logging.`); // Consider creating a log entry here if it's missing, although ideally it should exist. } } else { logger.info(`Received unhandled webhook event type: ${payload.type}`); } // 4. Respond to MessageBird // Acknowledge receipt of the webhook quickly to prevent retries. return { statusCode: 200, body: 'Webhook processed successfully' } } catch (error) { logger.error({ error }, 'Error processing webhook payload') // Return 500 to signal an error to MessageBird, potentially triggering retries. return { statusCode: 500, body: 'Internal Server Error processing webhook' } } }
Explanation:
- Signature Verification: The
verifyMessageBirdSignature
function is crucial. It reconstructs the signature based on the timestamp, raw body, and your secret signing key (MESSAGEBIRD_WEBHOOK_SIGNING_KEY
) and compares it securely usingcrypto.timingSafeEqual
against the signature provided in themessagebird-signature
header. This prevents unauthorized requests. Headers are normalized to lowercase. - Payload Parsing: The incoming JSON body is parsed.
- Event Handling: The code specifically handles
message.created
(for incoming messages and potentially initial outgoing status) andmessage.updated
(for status changes like 'delivered', 'failed'). - Incoming Messages (
message.created
,direction: 'received'
):- It logs the incoming message details.
- Idempotency: It checks if a
MessageLog
with the samemessageBirdId
already exists. If so, it skips creation to prevent duplicates from potential webhook retries. - If new, it creates a
MessageLog
record withdirection: 'incoming'
, storing the sender's number, content, type, and MessageBird timestamps. - A placeholder comment indicates where further business logic (e.g., auto-replies, CRM updates) should go.
- Status Updates (
message.updated
):- It logs the status update.
- It updates the existing
MessageLog
record (found viamessageBirdId
) with the newstatus
(e.g., 'sent', 'delivered', 'failed'). - If the status is 'failed', it also logs the error code and description provided by MessageBird.
- It handles cases where the log entry might not be found (e.g., timing issues).
- Response: It returns a
200 OK
quickly to acknowledge receipt to MessageBird. Errors during processing return a500 Internal Server Error
.
- Signature Verification: The
-
Local Testing with
ngrok
:- Keep your Redwood dev server running (
yarn rw dev
). - Open another terminal and run
ngrok
:ngrok http 8911
ngrok
will provide a public HTTPS URL (e.g.,https://<random-string>.ngrok.io
).- Go to your MessageBird Dashboard -> Developers -> Webhooks.
- Create or edit a webhook.
- URL: Enter the
ngrok
HTTPS URL followed by your function path:https://<random-string>.ngrok.io/messagebirdWebhook
- Events: Select
message.created
andmessage.updated
. - Signing Key: Enter the same strong random string you put in your
.env
file forMESSAGEBIRD_WEBHOOK_SIGNING_KEY
. - Save the webhook.
- URL: Enter the
- Send a message to your WhatsApp Business number from a test phone. You should see activity in the
ngrok
terminal and logs in your Redwood API terminal showing the incoming message being processed. - Send a message from your application using the
/sendWhatsapp
endpoint. You should seemessage.updated
events arriving at the webhook as the message status changes (e.g., accepted -> sent -> delivered). Check theMessageLog
table for status updates.
- Keep your Redwood dev server running (
-
Deployment: When deploying your RedwoodJS application (e.g., to Vercel, Netlify, AWS Serverless), ensure:
- All environment variables (
MESSAGEBIRD_API_KEY
,MESSAGEBIRD_WHATSAPP_CHANNEL_ID
,MESSAGEBIRD_WEBHOOK_SIGNING_KEY
,DATABASE_URL
) are correctly configured in your deployment environment. - Update the MessageBird webhook URL to point to your deployed function's public URL (e.g.,
https://your-app.com/api/messagebirdWebhook
). - Ensure your database is accessible from your deployed functions.
- Implement proper authentication/authorization for the
/sendWhatsapp
endpoint.
- All environment variables (