WhatsApp Business API Integration with RedwoodJS & MessageBird: Complete Guide (2025)
Build WhatsApp messaging into your RedwoodJS application using MessageBird API. Complete tutorial covering template messages, webhooks, database logging, and production deployment. Updated for Node.js 20 and RedwoodJS v8.
Build a production-ready WhatsApp Business API integration into your RedwoodJS application using the MessageBird API (now Bird). This comprehensive guide covers sending outbound WhatsApp messages (including pre-approved template messages), receiving inbound messages via webhooks, database logging with Prisma, and secure deployment patterns.
Platform Update (February 2024): MessageBird rebranded as "Bird" with aggressive pricing cuts (up to 90% savings on SMS). The legacy MessageBird API and Node.js SDK remain fully functional and supported. For WhatsApp Business API integrations, you can use either the MessageBird platform (dashboard.messagebird.com) or the new Bird platform (app.bird.com). Both platforms support the same API endpoints documented in this guide. See Bird's platform documentation for migration details.
By the end of this guide, you'll have a functional RedwoodJS serverless application that leverages WhatsApp as a customer communication channel – perfect for notifications, support tickets, appointment reminders, and two-factor authentication.
Quick Reference: RedwoodJS WhatsApp Integration
What You'll Build: Full-stack RedwoodJS application with WhatsApp messaging via MessageBird/Bird API
RedwoodJS service for sending WhatsApp messages (text + template messages)
Serverless function for webhook handling (incoming messages + status updates)
Prisma schema for message logging and conversation tracking
Secure webhook signature verification with HMAC SHA-256
E.164 phone number validation with Zod
Prerequisites: Node.js 20+, Yarn 1.22.21+, MessageBird account, approved WhatsApp Business number
Time to Complete: 45–60 minutes
What is the WhatsApp Business API?
The WhatsApp Business API (officially the WhatsApp Business Platform) enables businesses to send and receive messages programmatically at scale. Unlike the WhatsApp Business app (designed for small businesses), the API supports:
Customer service conversations with unlimited agents
Template messages for initiating conversations outside the 24-hour window
Rich media (images, documents, location, contacts)
Webhook integration for real-time message delivery
MessageBird/Bird as CPaaS Provider: MessageBird (now Bird) acts as a Communication Platform as a Service (CPaaS) provider, simplifying WhatsApp Business API access. They handle Meta's complex onboarding, provide unified APIs across channels (SMS, WhatsApp, Voice), and offer embedded sign-up for faster deployment.
24-Hour Messaging Window: WhatsApp enforces a customer service window that opens when a user messages your business. During this 24-hour period, you can send any message type freely. Outside this window, you must use pre-approved template messages (called HSM – Highly Structured Messages) to initiate conversations. This policy prevents spam while enabling legitimate business communication.
What Problem Does This Integration Solve?
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.
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. Current version: v8.8.1 (as of January 2025).
Node.js: The underlying runtime environment for the RedwoodJS API side. Recommended version: Node.js 20 (current LTS, supported until April 2026).
MessageBird/Bird: A communication Platform as a Service (CPaaS) provider offering APIs for various channels, including WhatsApp. We'll use their Node.js SDK (v4.0.1, last updated 2021–2022 but remains functional) 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.
Node.js v20 or later (RedwoodJS requires Node.js 20+; avoid Node.js 21+ for AWS Lambda compatibility) and Yarn 1.22.21+ 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.
How Do You Set Up Your RedwoodJS 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:
Bash
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.
Bash
node -v
yarn -v
Environment Variables:
RedwoodJS uses .env files for environment variables. Create one in the project root:
Bash
touch .env
Add the following placeholders. You will get the actual values from your MessageBird dashboard later.
.env
# .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 into process.env.
Install MessageBird SDK:
Add the official MessageBird Node.js SDK to your project's API side dependencies.
Bash
yarn workspace api add messagebird
This installs the SDK and adds it to api/package.json.
Project Structure:
RedwoodJS provides a clear structure:
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).
How Do You Design the Database Schema for Message Logging?
We need a way to store message history.
Define Prisma Schema:
Open api/db/schema.prisma and add a MessageLog model:
Prisma
// 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 on messageBirdId helps prevent duplicate entries if webhooks are delivered multiple times.
Added @@index directives for potentially common query fields.
Apply Migrations:
Generate and apply the database migration.
Bash
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 the MessageLog table.
How Do You Send WhatsApp Messages from RedwoodJS?
We'll create a RedwoodJS service to encapsulate the logic for sending messages via MessageBird.
Generate Service:
Bash
yarn rw g service whatsapp
This creates api/src/services/whatsapp/whatsapp.js and whatsapp.test.js.
Implement Sending Logic:
Open api/src/services/whatsapp/whatsapp.js and add the following:
JavaScript
// api/src/services/whatsapp/whatsapp.jsimport { 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)
constWHATSAPP_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.
*/exportconstsendWhatsappMessage = async ({ to, type, content }) => {
if (!WHATSAPP_CHANNEL_ID) {
logger.error('MESSAGEBIRD_WHATSAPP_CHANNEL_ID is not configured.')
thrownewError('WhatsApp channel ID is missing.')
}
if (!process.env.MESSAGEBIRD_API_KEY) {
logger.error('MESSAGEBIRD_API_KEY is not configured.')
thrownewError('MessageBird API Key is missing.')
}
const payload = {
to: to,
from: WHATSAPP_CHANNEL_ID, // Use channel ID as sendertype: type,
content: content,
}
logger.info({ payload: { ...payload, content: '<<omitted>>' } }, `Attempting to send WhatsApp message to ${to}`) // Avoid logging full content potentiallylet logEntryId = nulltry {
// Pre-log the attempt (optional, but useful for tracking)const initialLog = await db.messageLog.create({
data: {
channelId: WHATSAPP_CHANNEL_ID,
status: 'pending', // Initial statusdirection: '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 flexibilityconst result = awaitnewPromise((resolve, reject) => {
messagebird.conversations.send(payload, (err, response) => {
if (err) {
// Log detailed error information from MessageBird if availableconst errorDetails = err.errors ? JSON.stringify(err.errors) : err.message
logger.error({ statusCode: err.statusCode, details: errorDetails, payload }, 'MessageBird API error')
returnreject(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 IDawait 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 createdif (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 codeerrorDescription: errorDescription.substring(0, 500), // Limit error description length if needed
},
})
logger.info(`Updated log entry ${logEntryId} to failed status.`)
}
// Rethrow or return structured errorthrownewError(`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
*/exportconstgetMessageLogsByRecipient = 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), message type ('text' or 'hsm'), and the content payload.
It constructs the payload required by the MessageBird conversations.send API endpoint. Crucially, from is set to your MESSAGEBIRD_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 from error.errors.
API Call: We use messagebird.conversations.send. The SDK uses callbacks, so we wrap it in a Promise for async/await compatibility.
HSM Templates: For type: 'hsm', the content object must match the structure shown in the MessageBird API documentation. You need the namespace and templateName from your approved template in the MessageBird dashboard. Parameter substitution happens via the params array (or components 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.
How Do You Build the API Endpoint for Message Sending?
We need an HTTP endpoint to trigger our sendWhatsappMessage service. A RedwoodJS serverless function is perfect for this.
Generate Function:
Bash
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:
JavaScript
// api/src/functions/sendWhatsapp.jsimport { 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 validationtype: z.enum(['text', 'hsm']),
content: z.object({}).passthrough(), // Allow any object structure for content initially
}).refine(data => {
if (data.type === 'text') {
returntypeof 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.)returntypeof data.content.hsm === 'object' && data.content.hsm !== null;
}
returnfalse; // 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.
*/exportconsthandler = 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 authorizationif (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 Zodconst 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 Zodconst result = awaitsendWhatsappMessage({
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 callheaders: { '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 necessaryconst 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 desiredif (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 availableheaders: { '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 a 400 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:
Bash
yarn rw dev
Install Zod if you haven't already:
Bash
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):
Bash
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)
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.
How Do You Handle Incoming WhatsApp Messages with Webhooks?
MessageBird uses webhooks to notify your application about incoming messages and status updates for outgoing messages.
Generate Webhook Function:
Bash
yarn rw g function messagebirdWebhook
This creates api/src/functions/messagebirdWebhook.js.
Implement Webhook Handler:
Open api/src/functions/messagebirdWebhook.js:
JavaScript
// api/src/functions/messagebirdWebhook.jsimport { logger } from'src/lib/logger'import { db } from'src/lib/db'import crypto from'crypto'constMESSAGEBIRD_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.
*/constverifyMessageBirdSignature = (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.returnfalse
}
// Headers might be lowercase depending on the API gatewayconst 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).')
returnfalse
}
// Construct the payload string: timestamp + '.' + bodyconst payloadString = `${requestTimestamp}.${body}`// Calculate the expected signatureconst 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.');
returnfalse;
}
return crypto.timingSafeEqual(receivedSigBuffer, expectedSigBuffer);
} catch (error) {
// Handle potential errors during comparison (e.g., invalid hex strings)
logger.error({ error }, 'Error during timingSafeEqual comparison.');
returnfalse;
}
}
/**
* 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.
*/exportconsthandler = 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 accessconst 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 Payloadlet 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.messageif (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 messageawait 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 IDsender: msg.from, // User's phone numbermessageType: msg.type, // 'text', 'image', etc.content: msg.content, // Full content payloadcreatedAt: newDate(msg.createdDatetime), // Use MessageBird's timestampupdatedAt: newDate(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.
}
} elseif (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 handlerawait db.messageLog.updateMany({
where: { messageBirdId: msg.id }, // Update based on MessageBird IDdata: {
status: msg.status, // Update status if neededupdatedAt: newDate(msg.updatedDatetime || msg.createdDatetime),
},
});
}
} elseif (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 IDconst updateData = {
status: msg.status, // e.g., 'sent', 'delivered', 'read', 'failed'updatedAt: newDate(msg.updatedDatetime || Date.now()), // Use update timestamperrorCode: null, // Clear previous errors if anyerrorDescription: 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 IDdata: 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 using crypto.timingSafeEqual against the signature provided in the messagebird-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) and message.updated (for status changes like 'delivered', 'failed').
Idempotency: It checks if a MessageLog with the same messageBirdId already exists. If so, it skips creation to prevent duplicates from potential webhook retries.
If new, it creates a MessageLog record with direction: '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 via messageBirdId) with the new status (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 a 500 Internal Server Error.
Local Testing with ngrok:
Keep your Redwood dev server running (yarn rw dev).
Open another terminal and run ngrok:
Bash
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 and message.updated.
Signing Key: Enter the same strong random string you put in your .env file for MESSAGEBIRD_WEBHOOK_SIGNING_KEY.
Save the webhook.
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 see message.updated events arriving at the webhook as the message status changes (e.g., accepted -> sent -> delivered). Check the MessageLog table for status updates.
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.
Frequently Asked Questions
What is the difference between MessageBird and Bird?
MessageBird rebranded as "Bird" in February 2024, slashing prices by up to 90% on SMS to compete with Twilio. The legacy MessageBird API, Node.js SDK, and documentation remain fully functional. New customers can use either platform – both support the same WhatsApp Business API endpoints. Bird's platform (app.bird.com) offers the next-generation interface with unified pricing across channels.
How do WhatsApp template messages work in 2025?
WhatsApp template messages (also called HSM – Highly Structured Messages) must be pre-approved by Meta before use. As of July 2024, templates are categorized as Marketing, Utility, or Authentication. Marketing templates require explicit opt-out instructions (e.g., "Reply STOP to unsubscribe"). Utility templates are for transactional messages (order confirmations, shipping updates). All templates must be approved via the MessageBird or Bird dashboard before you can send them. Effective April 2025, Meta will automatically recategorize improperly labeled templates.
Can you send freeform WhatsApp messages outside the 24-hour window?
No. WhatsApp enforces a strict customer service window – you can only send freeform text messages within 24 hours of the user's last message. Outside this window, you must use pre-approved template messages. This policy combats spam while enabling legitimate business communication. Your RedwoodJS integration should detect 24-hour window violations (MessageBird error code 470) and fallback to template messages automatically.
What Node.js version does RedwoodJS require in 2025?
RedwoodJS requires Node.js 20 or higher as of version 8. However, if you're deploying to AWS Lambda or similar serverless platforms, avoid Node.js 21+ due to compatibility issues. Recommended: Node.js 20 LTS (supported until April 2026). The MessageBird SDK (v4.0.1) works with all current Node.js LTS versions (18, 20, 22).
How do you secure MessageBird webhooks in production?
Implement HMAC SHA-256 signature verification using the MESSAGEBIRD_WEBHOOK_SIGNING_KEY environment variable. MessageBird includes a messagebird-signature header in each webhook request, computed from the request timestamp and body. Your RedwoodJS function must reconstruct this signature and compare it using crypto.timingSafeEqual() (constant-time comparison) to prevent timing attacks. Never accept unsigned webhook requests in production – this prevents unauthorized parties from spoofing incoming messages.
What is E.164 phone number format and why does it matter?
E.164 is the international phone number standard (ITU-T E.164): a plus sign (+) followed by country code and subscriber number (1–15 digits total, no spaces or special characters). Example: +14155552671 for a US number. MessageBird requires all phone numbers in E.164 format. Use Zod validation (z.string().regex(/^\+[1-9]\d{1,14}$/)) to enforce this format in your RedwoodJS API endpoints. Invalid formats cause message delivery failures and wasted API credits.
How do you track WhatsApp message delivery status?
MessageBird sends webhook events (message.updated) as message status changes: pending → accepted → sent → delivered → read. Your RedwoodJS webhook function updates the Prisma MessageLog table with each status change. The messageBirdId field (unique constraint) ensures idempotent updates. For failed messages, MessageBird provides error codes and descriptions via msg.error.code and msg.error.description. Store these in the errorCode and errorDescription fields for debugging.
Can you use RedwoodJS GraphQL API instead of serverless functions?
Yes. While this guide uses RedwoodJS serverless functions (simpler for webhook endpoints), you can expose the WhatsApp sending logic via GraphQL mutations using the @requireAuth directive for authentication. GraphQL is ideal for integrating WhatsApp messaging into your existing RedwoodJS UI. However, webhooks should remain as serverless functions since MessageBird expects standard HTTP endpoints, not GraphQL queries.
Conclusion and Next Steps
Congratulations! You've successfully built a production-ready WhatsApp Business API integration for RedwoodJS using MessageBird/Bird. You learned how to send WhatsApp messages (text and template formats), handle incoming messages via webhooks, log conversations with Prisma, validate phone numbers, and secure webhook endpoints with signature verification.
Next Steps & Potential Improvements:
Template Message Management: Build a RedwoodJS GraphQL API to manage WhatsApp templates, track approval status, and select templates dynamically based on use case.
User Interface: Create a React UI in the web/ directory for customer service agents to view conversations and send responses.
Conversation Management: Extend the Prisma schema with a Conversation model to group messages by conversationId, track conversation state (open/closed), and assign conversations to agents.
Rich Media Support: Implement image, document, and location message types. MessageBird supports these via the components structure in template messages or direct media URLs in freeform messages.
Auto-Replies and Chatbots: Add business logic to the webhook handler to automatically respond to keywords (e.g., "HELP" → send support menu, "STATUS" → query order status).
Rate Limiting and Queuing: Implement message queues (BullMQ with Redis) for high-volume sending to prevent MessageBird rate limit errors and retry failed messages with exponential backoff.
Meta Business Verification: Complete Meta Business Verification to unlock higher messaging limits (from 1,000 to 100,000+ conversations per day).
Monitoring and Alerts: Integrate Sentry or Datadog for error tracking, and set up alerts for webhook failures, delivery rate drops, or API errors.
Migration to Bird Platform: Evaluate migrating to the Bird platform (app.bird.com) for access to 90% SMS discounts and unified cross-channel analytics.
Code Repository
[Actual repository link should be inserted here if available]
This foundation enables you to build powerful, compliant WhatsApp Business integrations in your RedwoodJS applications. Happy coding!
You can send WhatsApp messages within your RedwoodJS application by integrating with the MessageBird API. This involves setting up a MessageBird account, configuring a WhatsApp channel, and then using the MessageBird Node.js SDK within your RedwoodJS services to send messages programmatically. This guide provides step-by-step instructions for setting up this integration and leveraging RedwoodJS functions and services.
MessageBird acts as a Communication Platform as a Service (CPaaS) provider, simplifying the complexities of the WhatsApp Business API. It provides the necessary infrastructure and API for sending and receiving WhatsApp messages, including template messages (HSM), through a unified interface that can be accessed through its Node.js SDK and REST API from your RedwoodJS application.
MessageBird simplifies WhatsApp integration by handling the complexities of the WhatsApp Business API. Combined with RedwoodJS, it offers a structured, serverless approach for building robust WhatsApp integrations with features like database message logging and secure handling of credentials, all within a full-stack JavaScript environment.
Incoming WhatsApp messages are handled using MessageBird webhooks. Set up a dedicated RedwoodJS function as your webhook endpoint and configure this URL within your MessageBird dashboard. Ensure proper signature verification for security. This function will receive message data and status updates and should be used to update your database or trigger other logic within your application.
Integrate the WhatsApp Business API by using MessageBird as an intermediary. You'll need a MessageBird account, an approved WhatsApp Business number, and the MessageBird Node.js SDK installed in your RedwoodJS API side. Configure your RedwoodJS services to make API calls to MessageBird, enabling your RedwoodJS application to send and receive WhatsApp messages.
The guide recommends PostgreSQL or a similar relational database to store message logs. Prisma, an ORM used by RedwoodJS, handles database interactions and migrations. This enables efficient storage and retrieval of message history, metadata, and delivery status updates.
ngrok is useful during local development for testing your MessageBird webhook endpoint. It creates a public HTTPS tunnel to your local server, allowing MessageBird to deliver webhook events to your development environment. You will need to replace the ngrok URL with your application URL after deployment.
The webhook signing key is critical for security. This secret key, generated by you and configured within MessageBird, allows your webhook handler to verify the authenticity of incoming webhook requests. This prevents unauthorized actors from sending fake events to your application. It’s crucial to keep this key secure.
Prisma serves as the Object-Relational Mapper (ORM) in the RedwoodJS application. It simplifies database interactions, allowing you to define your data models (like MessageLog) and perform database operations (create, read, update, delete) using JavaScript instead of raw SQL. Prisma migrations also manage database schema changes.
RedwoodJS uses .env files for environment variables. Store your MESSAGEBIRD_API_KEY, MESSAGEBIRD_WHATSAPP_CHANNEL_ID, and MESSAGEBIRD_WEBHOOK_SIGNING_KEY in a .env file in your project root. Ensure this file is added to your .gitignore to avoid exposing sensitive information in version control.
The provided sendWhatsappMessage service handles both 'text' messages and 'hsm' (Highly Structured Messages, also known as Template Messages). Ensure your 'content' payload matches the MessageBird API specification for each type. 'text' messages require a 'text' field, while 'hsm' messages require template details like namespace, templateName, and parameters.
The RedwoodJS service encapsulates the logic for interacting with the MessageBird API. This includes sending messages, handling different message types (text, HSM), logging messages to the database, and managing API errors. It helps keep your API routes clean and your business logic centralized.
Yes, you can send template messages (HSM) with this integration. Ensure you have pre-approved templates configured in your MessageBird account. When calling the sendWhatsappMessage service, set the type to 'hsm' and provide the required template details and parameters in the content.hsm object.
A typical structure uses Redwood's functions to handle API requests and webhooks, services to interact with MessageBird's API and the database, and Prisma models to represent message logs. The 'api' directory houses backend code, including the MessageBird SDK. The 'web' directory handles the frontend. Your database, accessed via Prisma, stores message history and metadata.
Build a production-ready WhatsApp Business API integration into your RedwoodJS application using the MessageBird API (now Bird). This comprehensive guide covers sending outbound WhatsApp messages (including pre-approved template messages), receiving inbound messages via webhooks, database logging with Prisma, and secure deployment patterns.
By the end of this guide, you'll have a functional RedwoodJS serverless application that leverages WhatsApp as a customer communication channel – perfect for notifications, support tickets, appointment reminders, and two-factor authentication.
Quick Reference: RedwoodJS WhatsApp Integration
What You'll Build: Full-stack RedwoodJS application with WhatsApp messaging via MessageBird/Bird API
Technologies: RedwoodJS v8, Node.js 20, MessageBird SDK v4.0.1, Prisma ORM, PostgreSQL, GraphQL
Key Components:
Prerequisites: Node.js 20+, Yarn 1.22.21+, MessageBird account, approved WhatsApp Business number
Time to Complete: 45–60 minutes
What is the WhatsApp Business API?
The WhatsApp Business API (officially the WhatsApp Business Platform) enables businesses to send and receive messages programmatically at scale. Unlike the WhatsApp Business app (designed for small businesses), the API supports:
MessageBird/Bird as CPaaS Provider: MessageBird (now Bird) acts as a Communication Platform as a Service (CPaaS) provider, simplifying WhatsApp Business API access. They handle Meta's complex onboarding, provide unified APIs across channels (SMS, WhatsApp, Voice), and offer embedded sign-up for faster deployment.
24-Hour Messaging Window: WhatsApp enforces a customer service window that opens when a user messages your business. During this 24-hour period, you can send any message type freely. Outside this window, you must use pre-approved template messages (called HSM – Highly Structured Messages) to initiate conversations. This policy prevents spam while enabling legitimate business communication.
What Problem Does This Integration Solve?
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.
Project Goals:
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:
ngrok: For exposing local development webhook endpoints to the internet for testing.System Architecture:
Prerequisites:
ngrokinstalled.Final Outcome:
By the end of this guide, you will have:
How Do You Set Up Your RedwoodJS 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-whatsappFollow 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.
Environment Variables: RedwoodJS uses
.envfiles for environment variables. Create one in the project root:touch .envAdd the following placeholders. You will get the actual values from your MessageBird dashboard later.
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
.envto your.gitignorefile to avoid committing secrets. RedwoodJS automatically loads.envvariables intoprocess.env.Install MessageBird SDK: Add the official MessageBird Node.js SDK to your project's API side dependencies.
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).How Do You Design the Database Schema for Message Logging?
We need a way to store message history.
Define Prisma Schema: Open
api/db/schema.prismaand add aMessageLogmodel:?) where data might not always be present (e.g.,messageBirdIdbefore sending,errorCodeon success).contentis stored as JSON to handle various message structures flexibly.@uniqueconstraint onmessageBirdIdhelps prevent duplicate entries if webhooks are delivered multiple times.@@indexdirectives for potentially common query fields.Apply Migrations: Generate and apply the database migration.
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 theMessageLogtable.How Do You Send WhatsApp Messages from RedwoodJS?
We'll create a RedwoodJS service to encapsulate the logic for sending messages via MessageBird.
Generate Service:
This creates
api/src/services/whatsapp/whatsapp.jsandwhatsapp.test.js.Implement Sending Logic: Open
api/src/services/whatsapp/whatsapp.jsand 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:
messagebirdclient using the API key from environment variables. Added checks for missing keys.sendWhatsappMessagefunction takes the recipient (to), messagetype('text' or 'hsm'), and thecontentpayload.conversations.sendAPI endpoint. Crucially,fromis set to yourMESSAGEBIRD_WHATSAPP_CHANNEL_ID.messageBirdId). This ensures traceability even if the API call fails network-wise. Error logging includes details fromerror.errors.messagebird.conversations.send. The SDK uses callbacks, so we wrap it in aPromiseforasync/awaitcompatibility.type: 'hsm', thecontentobject must match the structure shown in the MessageBird API documentation. You need thenamespaceandtemplateNamefrom your approved template in the MessageBird dashboard. Parameter substitution happens via theparamsarray (orcomponentsfor media templates).try...catchblock handles API errors, logs them (including MessageBird-specific error details), updates the database record to 'failed', and includes error details.getMessageLogsByRecipientis an example of how to query the logs using Prisma.How Do You Build the API Endpoint for Message Sending?
We need an HTTP endpoint to trigger our
sendWhatsappMessageservice. A RedwoodJS serverless function is perfect for this.Generate Function:
yarn rw g function sendWhatsappThis creates
api/src/functions/sendWhatsapp.js.Implement API Endpoint: Open
api/src/functions/sendWhatsapp.jsand 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:
POSTrequest with a JSON body.zodfor robust input validation (phone number format, type enum, basic content structure). Invalid requests are rejected with a400 Bad Requestand details.sendWhatsappMessageservice using the validated data.200 OKresponse 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:
Install Zod if you haven't already:
Use
curlor 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.How Do You Handle Incoming WhatsApp Messages with Webhooks?
MessageBird uses webhooks to notify your application about incoming messages and status updates for outgoing messages.
Generate Webhook Function:
yarn rw g function messagebirdWebhookThis 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:
verifyMessageBirdSignaturefunction 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.timingSafeEqualagainst the signature provided in themessagebird-signatureheader. This prevents unauthorized requests. Headers are normalized to lowercase.message.created(for incoming messages and potentially initial outgoing status) andmessage.updated(for status changes like 'delivered', 'failed').message.created,direction: 'received'):MessageLogwith the samemessageBirdIdalready exists. If so, it skips creation to prevent duplicates from potential webhook retries.MessageLogrecord withdirection: 'incoming', storing the sender's number, content, type, and MessageBird timestamps.message.updated):MessageLogrecord (found viamessageBirdId) with the newstatus(e.g., 'sent', 'delivered', 'failed').200 OKquickly to acknowledge receipt to MessageBird. Errors during processing return a500 Internal Server Error.ngrok:yarn rw dev).ngrok:ngrokwill provide a public HTTPS URL (e.g.,https://<random-string>.ngrok.io).ngrokHTTPS URL followed by your function path:https://<random-string>.ngrok.io/messagebirdWebhookmessage.createdandmessage.updated..envfile forMESSAGEBIRD_WEBHOOK_SIGNING_KEY.ngrokterminal and logs in your Redwood API terminal showing the incoming message being processed./sendWhatsappendpoint. You should seemessage.updatedevents arriving at the webhook as the message status changes (e.g., accepted -> sent -> delivered). Check theMessageLogtable for status updates.MESSAGEBIRD_API_KEY,MESSAGEBIRD_WHATSAPP_CHANNEL_ID,MESSAGEBIRD_WEBHOOK_SIGNING_KEY,DATABASE_URL) are correctly configured in your deployment environment.https://your-app.com/api/messagebirdWebhook)./sendWhatsappendpoint.Frequently Asked Questions
What is the difference between MessageBird and Bird?
MessageBird rebranded as "Bird" in February 2024, slashing prices by up to 90% on SMS to compete with Twilio. The legacy MessageBird API, Node.js SDK, and documentation remain fully functional. New customers can use either platform – both support the same WhatsApp Business API endpoints. Bird's platform (app.bird.com) offers the next-generation interface with unified pricing across channels.
How do WhatsApp template messages work in 2025?
WhatsApp template messages (also called HSM – Highly Structured Messages) must be pre-approved by Meta before use. As of July 2024, templates are categorized as Marketing, Utility, or Authentication. Marketing templates require explicit opt-out instructions (e.g., "Reply STOP to unsubscribe"). Utility templates are for transactional messages (order confirmations, shipping updates). All templates must be approved via the MessageBird or Bird dashboard before you can send them. Effective April 2025, Meta will automatically recategorize improperly labeled templates.
Can you send freeform WhatsApp messages outside the 24-hour window?
No. WhatsApp enforces a strict customer service window – you can only send freeform text messages within 24 hours of the user's last message. Outside this window, you must use pre-approved template messages. This policy combats spam while enabling legitimate business communication. Your RedwoodJS integration should detect 24-hour window violations (MessageBird error code 470) and fallback to template messages automatically.
What Node.js version does RedwoodJS require in 2025?
RedwoodJS requires Node.js 20 or higher as of version 8. However, if you're deploying to AWS Lambda or similar serverless platforms, avoid Node.js 21+ due to compatibility issues. Recommended: Node.js 20 LTS (supported until April 2026). The MessageBird SDK (v4.0.1) works with all current Node.js LTS versions (18, 20, 22).
How do you secure MessageBird webhooks in production?
Implement HMAC SHA-256 signature verification using the
MESSAGEBIRD_WEBHOOK_SIGNING_KEYenvironment variable. MessageBird includes amessagebird-signatureheader in each webhook request, computed from the request timestamp and body. Your RedwoodJS function must reconstruct this signature and compare it usingcrypto.timingSafeEqual()(constant-time comparison) to prevent timing attacks. Never accept unsigned webhook requests in production – this prevents unauthorized parties from spoofing incoming messages.What is E.164 phone number format and why does it matter?
E.164 is the international phone number standard (ITU-T E.164): a plus sign (+) followed by country code and subscriber number (1–15 digits total, no spaces or special characters). Example:
+14155552671for a US number. MessageBird requires all phone numbers in E.164 format. Use Zod validation (z.string().regex(/^\+[1-9]\d{1,14}$/)) to enforce this format in your RedwoodJS API endpoints. Invalid formats cause message delivery failures and wasted API credits.How do you track WhatsApp message delivery status?
MessageBird sends webhook events (
message.updated) as message status changes:pending→accepted→sent→delivered→read. Your RedwoodJS webhook function updates the PrismaMessageLogtable with each status change. ThemessageBirdIdfield (unique constraint) ensures idempotent updates. For failed messages, MessageBird provides error codes and descriptions viamsg.error.codeandmsg.error.description. Store these in theerrorCodeanderrorDescriptionfields for debugging.Can you use RedwoodJS GraphQL API instead of serverless functions?
Yes. While this guide uses RedwoodJS serverless functions (simpler for webhook endpoints), you can expose the WhatsApp sending logic via GraphQL mutations using the
@requireAuthdirective for authentication. GraphQL is ideal for integrating WhatsApp messaging into your existing RedwoodJS UI. However, webhooks should remain as serverless functions since MessageBird expects standard HTTP endpoints, not GraphQL queries.Conclusion and Next Steps
Congratulations! You've successfully built a production-ready WhatsApp Business API integration for RedwoodJS using MessageBird/Bird. You learned how to send WhatsApp messages (text and template formats), handle incoming messages via webhooks, log conversations with Prisma, validate phone numbers, and secure webhook endpoints with signature verification.
Next Steps & Potential Improvements:
web/directory for customer service agents to view conversations and send responses.Conversationmodel to group messages byconversationId, track conversation state (open/closed), and assign conversations to agents.componentsstructure in template messages or direct media URLs in freeform messages.Code Repository
[Actual repository link should be inserted here if available]
This foundation enables you to build powerful, compliant WhatsApp Business integrations in your RedwoodJS applications. Happy coding!
Sources: