Skip to content
Playground

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

Terminal window
npm install meta-cloud-api express
# or
pnpm add meta-cloud-api express

Basic Setup

server.ts
import express from 'express';
import { webhookHandler } from 'meta-cloud-api/webhook/express';
const app = express();
// Create webhook handler
const 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 handler
whatsapp.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 routes
app.get('/webhook', whatsapp.GET);
app.post('/webhook', express.json(), whatsapp.POST);
// Start server
const 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:

const { GET, POST, processor } = whatsapp;
// Clean destructuring
app.get('/webhook', GET);
app.post('/webhook', express.json(), POST);
// Register handlers on processor
processor.onText(async (client, message) => {
// Handle text messages
});

Pattern 2: Auto-routing

const { webhook, processor } = whatsapp;
// Single handler for both GET and POST
app.use('/webhook', express.json());
app.all('/webhook', webhook);
processor.onText(async (client, message) => {
// Handle text messages
});

Pattern 3: Direct Method Access

// Access handlers directly
app.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}`,
});
});

Interactive Messages

// Handle button responses
processor.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:

server.ts
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();
// Configuration
const 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 handler
whatsapp.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 handler
whatsapp.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 handler
whatsapp.processor.onImage(async (client, message) => {
await client.messages.text({
to: message.from,
body: 'Thanks for the image!',
});
});
// Status handler
whatsapp.processor.onStatus(async (client, status) => {
console.log(`[${status.timestamp}] ${status.id}: ${status.status}`);
});
// Routes
app.get('/webhook', whatsapp.GET);
app.post('/webhook', express.json(), whatsapp.POST);
// Health check
app.get('/', (req, res) => {
res.json({ status: 'ok', message: 'WhatsApp webhook server' });
});
// Start server
const 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 handler
processor.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 handler
app.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 window
# Terminal 1: Start server
npm run dev
# Terminal 2: Start ngrok
ngrok http 3000
# Use ngrok URL in Meta Developer Portal
# https://abc123.ngrok.io/webhook

Test your webhook:

Terminal window
# Test verification
curl "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

.env
WHATSAPP_ACCESS_TOKEN=your_token
WHATSAPP_PHONE_NUMBER_ID=123456789
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321
WEBHOOK_VERIFICATION_TOKEN=your_secret
PORT=3000

Async 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 instance
const 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 handlers
whatsapp.processor.onText(async (client, message) => { /* ... */ });
// Later: clean up
whatsapp.destroy();
// Next call to webhookHandler() creates a fresh instance

Deployment

Environment Setup

Terminal window
# Production .env
NODE_ENV=production
WHATSAPP_ACCESS_TOKEN=your_production_token
WHATSAPP_PHONE_NUMBER_ID=123456789
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321
WEBHOOK_VERIFICATION_TOKEN=your_production_secret
PORT=3000

Using Process Managers

Terminal window
# Install PM2
npm install -g pm2
# Start with PM2
pm2 start dist/server.js --name whatsapp-bot
# Monitor
pm2 logs whatsapp-bot
pm2 monit