Express.js Integration
The SDK provides a built-in Express.js webhook adapter that handles both webhook verification and message processing. This guide shows you how to build a complete WhatsApp bot with Express.
Quick Start
Installation
npm install meta-cloud-api express# orpnpm add meta-cloud-api expressBasic Setup
import express from 'express';import { webhookHandler } from 'meta-cloud-api/webhook/express';
const app = express();
// Create webhook handlerconst whatsapp = webhookHandler({ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!, phoneNumberId: parseInt(process.env.WHATSAPP_PHONE_NUMBER_ID!), businessAcctId: process.env.WHATSAPP_BUSINESS_ACCOUNT_ID, webhookVerificationToken: process.env.WEBHOOK_VERIFICATION_TOKEN!,});
// Register a text message handlerwhatsapp.processor.onText(async (client, message) => { console.log('Received:', message.text.body);
await client.messages.text({ to: message.from, body: `Echo: ${message.text.body}`, });});
// Set up routesapp.get('/webhook', whatsapp.GET);app.post('/webhook', express.json(), whatsapp.POST);
// Start serverconst PORT = process.env.PORT || 3000;app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);});Handler Registration Patterns
The Express adapter provides multiple routing patterns:
Pattern 1: Separate GET/POST (Recommended)
const { GET, POST, processor } = whatsapp;
// Clean destructuringapp.get('/webhook', GET);app.post('/webhook', express.json(), POST);
// Register handlers on processorprocessor.onText(async (client, message) => { // Handle text messages});Pattern 2: Auto-routing
const { webhook, processor } = whatsapp;
// Single handler for both GET and POSTapp.use('/webhook', express.json());app.all('/webhook', webhook);
processor.onText(async (client, message) => { // Handle text messages});Pattern 3: Direct Method Access
// Access handlers directlyapp.get('/webhook', whatsapp.GET);app.post('/webhook', express.json(), whatsapp.POST);
whatsapp.processor.onText(async (client, message) => { // Handle text messages});Message Handlers
The SDK provides type-safe handlers for all message types:
Text Messages
processor.onText(async (whatsapp, message) => { console.log(`From: ${message.from}`); console.log(`Text: ${message.text.body}`);
await whatsapp.messages.text({ to: message.from, body: `You said: ${message.text.body}`, });});Media Messages
processor.onImage(async (whatsapp, message) => { const { id, mime_type, caption } = message.image;
console.log(`Received image: ${id} (${mime_type})`); if (caption) { console.log(`Caption: ${caption}`); }
// Download the image const mediaUrl = await whatsapp.media.retrieveMediaUrl({ mediaId: id });
await whatsapp.messages.text({ to: message.from, body: `Thanks for the image! URL: ${mediaUrl.url}`, });});processor.onVideo(async (whatsapp, message) => { const { id, mime_type } = message.video;
await whatsapp.messages.text({ to: message.from, body: `Received your video (${mime_type})`, });});processor.onAudio(async (whatsapp, message) => { const { id, voice } = message.audio;
const messageType = voice ? 'voice message' : 'audio file';
await whatsapp.messages.text({ to: message.from, body: `Received your ${messageType}`, });});processor.onDocument(async (whatsapp, message) => { const { id, filename, mime_type } = message.document;
console.log(`Received document: ${filename}`);
await whatsapp.messages.text({ to: message.from, body: `Thanks for ${filename}`, });});Interactive Messages
// Handle button responsesprocessor.onInteractive(async (whatsapp, message) => { const { interactive } = message;
if (interactive.type === 'button_reply') { const buttonId = interactive.button_reply.id; console.log(`Button clicked: ${buttonId}`);
await whatsapp.messages.text({ to: message.from, body: `You clicked: ${interactive.button_reply.title}`, }); }
if (interactive.type === 'list_reply') { const listId = interactive.list_reply.id; console.log(`List item selected: ${listId}`);
await whatsapp.messages.text({ to: message.from, body: `You selected: ${interactive.list_reply.title}`, }); }
if (interactive.type === 'nfm_reply') { // Flow response const flowData = JSON.parse(interactive.nfm_reply.response_json); console.log('Flow response:', flowData); }});Location Messages
processor.onLocation(async (whatsapp, message) => { const { latitude, longitude, name, address } = message.location;
console.log(`Location: ${latitude}, ${longitude}`); if (name) console.log(`Name: ${name}`);
await whatsapp.messages.text({ to: message.from, body: `Received location: ${name || 'Unknown'}`, });});Contact Messages
processor.onContacts(async (whatsapp, message) => { const contacts = message.contacts;
for (const contact of contacts) { console.log(`Contact: ${contact.name.formatted_name}`);
if (contact.phones) { contact.phones.forEach(phone => { console.log(`Phone: ${phone.phone}`); }); } }
await whatsapp.messages.text({ to: message.from, body: `Received ${contacts.length} contact(s)`, });});Reaction Messages
processor.onReaction(async (whatsapp, message) => { const { message_id, emoji } = message.reaction;
if (emoji) { console.log(`Reacted with ${emoji} to message ${message_id}`); } else { console.log(`Removed reaction from message ${message_id}`); }});Status Updates
Track message delivery status:
processor.onStatus(async (whatsapp, status) => { console.log(`Message ${status.id}: ${status.status}`);
switch (status.status) { case 'sent': console.log('Message sent to WhatsApp'); break; case 'delivered': console.log('Message delivered to recipient'); break; case 'read': console.log('Message read by recipient'); break; case 'failed': console.log('Message failed:', status.errors); break; }});Webhook Field Handlers
Handle non-message webhook events:
Account Updates
processor.onAccountUpdate(async (whatsapp, update) => { console.log('Account updated:', update);
// Handle account changes if (update.account) { console.log('Account ID:', update.account.id); }});Template Updates
processor.onMessageTemplateStatusUpdate(async (whatsapp, update) => { console.log('Template status:', update);
const { event, message_template_id, message_template_name, reason } = update;
if (event === 'APPROVED') { console.log(`Template ${message_template_name} was approved!`); } else if (event === 'REJECTED') { console.log(`Template ${message_template_name} was rejected: ${reason}`); }});Phone Number Quality
processor.onPhoneNumberQualityUpdate(async (whatsapp, update) => { console.log('Quality rating:', update);
const { current_limit, event } = update;
if (event === 'FLAGGED') { console.warn('Phone number flagged! Current limit:', current_limit); }});Complete Example
Here’s a full-featured Express webhook server:
import 'dotenv/config';import express from 'express';import { webhookHandler } from 'meta-cloud-api/webhook/express';import { MessageTypesEnum } from 'meta-cloud-api/enums';
const app = express();
// Configurationconst whatsapp = webhookHandler({ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!, phoneNumberId: parseInt(process.env.WHATSAPP_PHONE_NUMBER_ID!), businessAcctId: process.env.WHATSAPP_BUSINESS_ACCOUNT_ID, webhookVerificationToken: process.env.WEBHOOK_VERIFICATION_TOKEN!,});
// Text message handlerwhatsapp.processor.onText(async (client, message) => { const text = message.text.body.toLowerCase();
// Mark as read await client.messages.markAsRead({ messageId: message.id });
// Show typing indicator await client.messages.showTypingIndicator({ messageId: message.id });
// Command handling if (text === 'menu') { await client.messages.interactive({ to: message.from, type: 'button', body: { text: 'Choose an option:' }, action: { buttons: [ { type: 'reply', reply: { id: 'help', title: 'Help' } }, { type: 'reply', reply: { id: 'about', title: 'About' } }, ], }, }); return; }
// Echo await client.messages.text({ to: message.from, body: `You said: ${message.text.body}`, });});
// Interactive message handlerwhatsapp.processor.onInteractive(async (client, message) => { if (message.interactive.type === 'button_reply') { const buttonId = message.interactive.button_reply.id;
let response = ''; if (buttonId === 'help') { response = 'Send "menu" to see options.'; } else if (buttonId === 'about') { response = 'WhatsApp Bot built with meta-cloud-api'; }
await client.messages.text({ to: message.from, body: response, }); }});
// Image handlerwhatsapp.processor.onImage(async (client, message) => { await client.messages.text({ to: message.from, body: 'Thanks for the image!', });});
// Status handlerwhatsapp.processor.onStatus(async (client, status) => { console.log(`[${status.timestamp}] ${status.id}: ${status.status}`);});
// Routesapp.get('/webhook', whatsapp.GET);app.post('/webhook', express.json(), whatsapp.POST);
// Health checkapp.get('/', (req, res) => { res.json({ status: 'ok', message: 'WhatsApp webhook server' });});
// Start serverconst PORT = process.env.PORT || 3000;app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); console.log(`Webhook: http://localhost:${PORT}/webhook`);});Error Handling
Handle errors gracefully:
// Global error handlerprocessor.onText(async (whatsapp, message) => { try { await processMessage(message); } catch (error) { console.error('Error processing message:', error);
// Notify user await whatsapp.messages.text({ to: message.from, body: 'Sorry, something went wrong. Please try again.', }); }});
// Express error handlerapp.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error('Express error:', err); res.status(500).json({ error: 'Internal server error' });});Testing Locally
Use ngrok to test webhooks locally:
# Terminal 1: Start servernpm run dev
# Terminal 2: Start ngrokngrok http 3000
# Use ngrok URL in Meta Developer Portal# https://abc123.ngrok.io/webhookTest your webhook:
# Test verificationcurl "http://localhost:3000/webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=test"
# Test webhook (mock payload)curl -X POST http://localhost:3000/webhook \ -H "Content-Type: application/json" \ -d '{ "object": "whatsapp_business_account", "entry": [{ "id": "123", "changes": [{ "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "1234567890", "phone_number_id": "123456789" }, "contacts": [{"profile": {"name": "Test"}, "wa_id": "1234567890"}], "messages": [{ "from": "1234567890", "id": "wamid.test", "timestamp": "1234567890", "type": "text", "text": {"body": "Hello"} }] }, "field": "messages" }] }] }'Best Practices
Use Environment Variables
WHATSAPP_ACCESS_TOKEN=your_tokenWHATSAPP_PHONE_NUMBER_ID=123456789WHATSAPP_BUSINESS_ACCOUNT_ID=987654321WEBHOOK_VERIFICATION_TOKEN=your_secretPORT=3000Async Processing
For long-running operations, process in the background:
processor.onText(async (whatsapp, message) => { // Acknowledge immediately setImmediate(async () => { await performLongOperation(message); });});Rate Limiting
Implement rate limiting to avoid overwhelming the API:
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per window});
app.use('/webhook', limiter);Monitoring
Add logging and monitoring:
import morgan from 'morgan';
app.use(morgan('combined'));
processor.onText(async (whatsapp, message) => { console.log('[TEXT]', { from: message.from, text: message.text.body, timestamp: message.timestamp, });});Singleton Caching
The webhook handler factory uses singleton caching per phoneNumberId. Calling webhookHandler() (or expressWebhookHandler()) multiple times with the same config returns the same instance. This prevents duplicate handler registration during HMR re-evaluation in development frameworks like Next.js or Vite.
// Both calls return the same instanceconst handler1 = webhookHandler({ phoneNumberId: 123, /* ... */ });const handler2 = webhookHandler({ phoneNumberId: 123, /* ... */ });// handler1 === handler2 ✓Destroying Instances
Use destroy() to clear the cache and remove all handlers. This is useful in tests or when you need to reinitialize:
const whatsapp = webhookHandler({ /* ... */ });
// Register handlerswhatsapp.processor.onText(async (client, message) => { /* ... */ });
// Later: clean upwhatsapp.destroy();// Next call to webhookHandler() creates a fresh instanceDeployment
Environment Setup
# Production .envNODE_ENV=productionWHATSAPP_ACCESS_TOKEN=your_production_tokenWHATSAPP_PHONE_NUMBER_ID=123456789WHATSAPP_BUSINESS_ACCOUNT_ID=987654321WEBHOOK_VERIFICATION_TOKEN=your_production_secretPORT=3000Using Process Managers
# Install PM2npm install -g pm2
# Start with PM2pm2 start dist/server.js --name whatsapp-bot
# Monitorpm2 logs whatsapp-botpm2 monit