Express.js Integration Guide
Learn how to build production-ready WhatsApp applications with Express.js and meta-cloud-api. This guide covers everything from initial setup to deployment.
Official Documentation: Express.js
Overview
Express.js is a minimal and flexible Node.js web framework that provides robust features for building web and mobile applications. This guide demonstrates how to integrate the WhatsApp Cloud API into an Express.js application with best practices for production environments.
Project Setup
1. Initialize Your Project
# Create project directorymkdir whatsapp-express-appcd whatsapp-express-app
# Initialize package.jsonpnpm init
# Install dependenciespnpm add meta-cloud-api express dotenvpnpm add -D @types/express @types/node typescript tsx nodemon
# Initialize TypeScriptnpx tsc --init2. Project Structure
Organize your project for maintainability:
whatsapp-express-app/├── src/│ ├── config/│ │ └── whatsapp.ts # WhatsApp client configuration│ ├── handlers/│ │ ├── messages/│ │ │ ├── text.ts # Text message handler│ │ │ ├── image.ts # Image message handler│ │ │ ├── interactive.ts # Button/list handler│ │ │ └── index.ts # Export all handlers│ │ └── webhooks/│ │ ├── flows.ts # Flow webhook handler│ │ └── index.ts # Export all webhook handlers│ ├── middleware/│ │ ├── errorHandler.ts # Error handling middleware│ │ ├── logger.ts # Request logging│ │ └── auth.ts # Authentication middleware│ ├── routes/│ │ ├── webhook.ts # Webhook routes│ │ └── api.ts # API routes for sending messages│ ├── services/│ │ └── database.ts # Database service (Prisma/MongoDB)│ ├── types/│ │ └── index.ts # Custom type definitions│ └── server.ts # Main server file├── .env # Environment variables├── .env.example # Example environment file├── package.json└── tsconfig.json3. Environment Configuration
Create .env file:
# WhatsApp Cloud API ConfigurationCLOUD_API_ACCESS_TOKEN=your_access_token_hereWA_PHONE_NUMBER_ID=your_phone_number_idWA_BUSINESS_ACCOUNT_ID=your_business_account_idWHATSAPP_WEBHOOK_VERIFICATION_TOKEN=your_verification_token
# Server ConfigurationPORT=3000NODE_ENV=development
# Database (optional)DATABASE_URL=mongodb://localhost:27017/whatsapp# or PostgreSQL# DATABASE_URL=postgresql://user:password@localhost:5432/whatsapp
# LoggingLOG_LEVEL=debugCreate .env.example:
CLOUD_API_ACCESS_TOKEN=WA_PHONE_NUMBER_ID=WA_BUSINESS_ACCOUNT_ID=WHATSAPP_WEBHOOK_VERIFICATION_TOKEN=PORT=3000NODE_ENV=developmentDATABASE_URL=LOG_LEVEL=infoWhatsApp Client Configuration
Create WhatsApp Client (src/config/whatsapp.ts)
import WhatsApp from 'meta-cloud-api';
if (!process.env.CLOUD_API_ACCESS_TOKEN) { throw new Error('CLOUD_API_ACCESS_TOKEN is required');}
if (!process.env.WA_PHONE_NUMBER_ID) { throw new Error('WA_PHONE_NUMBER_ID is required');}
export const whatsappClient = new WhatsApp({ accessToken: process.env.CLOUD_API_ACCESS_TOKEN, phoneNumberId: Number(process.env.WA_PHONE_NUMBER_ID), businessAcctId: process.env.WA_BUSINESS_ACCOUNT_ID,});
// Validate configuration on startupexport async function validateWhatsAppConfig() { try { // Test API connection by fetching phone number info const phoneInfo = await whatsappClient.phone.get(); console.log('✅ WhatsApp API connected successfully'); console.log(`📱 Phone Number: ${phoneInfo.display_phone_number}`); return true; } catch (error) { console.error('❌ Failed to connect to WhatsApp API:', error); throw error; }}Sending Messages
Basic Message Sending API (src/routes/api.ts)
import { Router } from 'express';import { whatsappClient } from '../config/whatsapp';import { MessageTypesEnum } from 'meta-cloud-api/enums';
const router = Router();
// Send text messagerouter.post('/messages/text', async (req, res) => { try { const { to, message } = req.body;
if (!to || !message) { return res.status(400).json({ error: 'Missing required fields: to, message' }); }
const response = await whatsappClient.messages.text({ to, body: message, });
res.json({ success: true, messageId: response.messages[0].id, }); } catch (error: any) { res.status(500).json({ error: 'Failed to send message', details: error.response?.data || error.message, }); }});
// Send image with captionrouter.post('/messages/image', async (req, res) => { try { const { to, imageUrl, caption } = req.body;
if (!to || !imageUrl) { return res.status(400).json({ error: 'Missing required fields: to, imageUrl' }); }
const response = await whatsappClient.messages.image({ to, image: { link: imageUrl, caption: caption || undefined, }, });
res.json({ success: true, messageId: response.messages[0].id, }); } catch (error: any) { res.status(500).json({ error: 'Failed to send image', details: error.response?.data || error.message, }); }});
// Send interactive button messagerouter.post('/messages/buttons', async (req, res) => { try { const { to, text, buttons } = req.body;
if (!to || !text || !buttons || !Array.isArray(buttons)) { return res.status(400).json({ error: 'Missing required fields: to, text, buttons (array)' }); }
const response = await whatsappClient.messages.interactive({ to, type: 'button', body: { text }, action: { buttons: buttons.map((btn: any, idx: number) => ({ type: 'reply', reply: { id: btn.id || `btn_${idx}`, title: btn.title, }, })), }, });
res.json({ success: true, messageId: response.messages[0].id, }); } catch (error: any) { res.status(500).json({ error: 'Failed to send button message', details: error.response?.data || error.message, }); }});
// Send template messagerouter.post('/messages/template', async (req, res) => { try { const { to, templateName, languageCode, parameters } = req.body;
if (!to || !templateName) { return res.status(400).json({ error: 'Missing required fields: to, templateName' }); }
const response = await whatsappClient.messages.template({ to, template: { name: templateName, language: { code: languageCode || 'en_US', }, components: parameters ? [ { type: 'body', parameters: parameters.map((text: string) => ({ type: 'text', text, })), }, ] : undefined, }, });
res.json({ success: true, messageId: response.messages[0].id, }); } catch (error: any) { res.status(500).json({ error: 'Failed to send template', details: error.response?.data || error.message, }); }});
export default router;Webhook Handling
Setup Webhook Handler (src/routes/webhook.ts)
import { Router } from 'express';import { webhookHandler } from 'meta-cloud-api/webhook/express';
// Import message handlersimport { handleTextMessage, handleImageMessage, handleInteractiveMessage, handleDocumentMessage,} from '../handlers/messages';
// Import webhook field handlersimport { handleFlowsWebhook } from '../handlers/webhooks';
const router = Router();
// WhatsApp configurationconst whatsappConfig = { accessToken: process.env.CLOUD_API_ACCESS_TOKEN!, phoneNumberId: Number(process.env.WA_PHONE_NUMBER_ID!), businessAcctId: process.env.WA_BUSINESS_ACCOUNT_ID, webhookVerificationToken: process.env.WHATSAPP_WEBHOOK_VERIFICATION_TOKEN!,};
// Create webhook handlerconst Whatsapp = webhookHandler(whatsappConfig);
// Register message handlersWhatsapp.processor.onText(handleTextMessage);Whatsapp.processor.onImage(handleImageMessage);Whatsapp.processor.onInteractive(handleInteractiveMessage);Whatsapp.processor.onDocument(handleDocumentMessage);
// Register webhook field handlersWhatsapp.processor.onFlows(handleFlowsWebhook);
// Webhook routesconst { GET, POST } = Whatsapp;
router.get('/', GET);router.post('/', POST);
export default router;Message Handlers
Text Message Handler (src/handlers/messages/text.ts)
import type { WebhookMessage, WhatsApp } from 'meta-cloud-api';import { saveMessageToDatabase } from '../../services/database';
export async function handleTextMessage( whatsapp: WhatsApp, message: WebhookMessage) { console.log(`📨 Text from ${message.from}: ${message.text?.body}`);
try { // Save message to database await saveMessageToDatabase({ messageId: message.id, from: message.from, type: 'text', content: message.text?.body || '', timestamp: new Date(parseInt(message.timestamp) * 1000), });
// Mark message as read await whatsapp.messages.markAsRead({ messageId: message.id });
// Show typing indicator await whatsapp.messages.showTypingIndicator({ messageId: message.id });
// Simulate processing delay await new Promise(resolve => setTimeout(resolve, 1000));
// Process message and send response const response = await processTextMessage(message.text?.body || '');
await whatsapp.messages.text({ to: message.from, body: response, context: { message_id: message.id }, // Reply to the message });
console.log(`✅ Response sent to ${message.from}`); } catch (error) { console.error('Error handling text message:', error);
// Send error message to user await whatsapp.messages.text({ to: message.from, body: 'Sorry, something went wrong. Please try again later.', }); }}
// Simple message processing logicfunction processTextMessage(text: string): string { const lowerText = text.toLowerCase();
if (lowerText.includes('hello') || lowerText.includes('hi')) { return 'Hello! How can I help you today?'; }
if (lowerText.includes('help')) { return 'Here are some commands:\n• "hello" - Greet the bot\n• "help" - Show this menu\n• "status" - Check bot status'; }
if (lowerText.includes('status')) { return 'Bot is running perfectly! ✅'; }
return `You said: "${text}"\n\nType "help" for available commands.`;}Interactive Message Handler (src/handlers/messages/interactive.ts)
import type { WebhookMessage, WhatsApp } from 'meta-cloud-api';
export async function handleInteractiveMessage( whatsapp: WhatsApp, message: WebhookMessage) { const interactive = message.interactive;
if (!interactive) return;
console.log(`🎯 Interactive response from ${message.from}`);
try { // Handle button replies if (interactive.type === 'button_reply') { const buttonId = interactive.button_reply?.id; console.log(`Button clicked: ${buttonId}`);
await handleButtonClick(whatsapp, message.from, buttonId || ''); }
// Handle list replies if (interactive.type === 'list_reply') { const listId = interactive.list_reply?.id; console.log(`List item selected: ${listId}`);
await handleListSelection(whatsapp, message.from, listId || ''); }
// Mark as read await whatsapp.messages.markAsRead({ messageId: message.id });
} catch (error) { console.error('Error handling interactive message:', error); }}
async function handleButtonClick( whatsapp: WhatsApp, to: string, buttonId: string) { const responses: Record<string, string> = { 'help': 'Here is the help information...', 'status': 'Current status: All systems operational ✅', 'contact': 'Contact us at: support@example.com', };
const response = responses[buttonId] || 'Unknown button clicked';
await whatsapp.messages.text({ to, body: response, });}
async function handleListSelection( whatsapp: WhatsApp, to: string, listId: string) { await whatsapp.messages.text({ to, body: `You selected: ${listId}`, });}Image Message Handler (src/handlers/messages/image.ts)
import type { WebhookMessage, WhatsApp } from 'meta-cloud-api';
export async function handleImageMessage( whatsapp: WhatsApp, message: WebhookMessage) { console.log(`📷 Image received from ${message.from}`);
try { const image = message.image;
if (!image) return;
// Download image metadata console.log(`Image ID: ${image.id}`); console.log(`MIME type: ${image.mime_type}`); console.log(`Caption: ${image.caption || 'No caption'}`);
// Optional: Download the actual image file const mediaUrl = await whatsapp.media.getUrl({ mediaId: image.id }); console.log(`Media URL: ${mediaUrl.url}`);
// Send confirmation await whatsapp.messages.text({ to: message.from, body: `✅ Image received! ${image.caption ? `Caption: "${image.caption}"` : ''}`, context: { message_id: message.id }, });
await whatsapp.messages.markAsRead({ messageId: message.id });
} catch (error) { console.error('Error handling image:', error); }}Export All Handlers (src/handlers/messages/index.ts)
export { handleTextMessage } from './text';export { handleImageMessage } from './image';export { handleInteractiveMessage } from './interactive';export { handleDocumentMessage } from './document';Middleware Patterns
Error Handling Middleware (src/middleware/errorHandler.ts)
import { Request, Response, NextFunction } from 'express';
export interface AppError extends Error { statusCode?: number; isOperational?: boolean;}
export function errorHandler( err: AppError, req: Request, res: Response, next: NextFunction) { console.error('Error:', { message: err.message, stack: err.stack, url: req.url, method: req.method, });
const statusCode = err.statusCode || 500; const message = err.isOperational ? err.message : 'Internal server error';
res.status(statusCode).json({ success: false, error: message, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), });}
export function asyncHandler( fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); };}Request Logger (src/middleware/logger.ts)
import { Request, Response, NextFunction } from 'express';
export function requestLogger(req: Request, res: Response, next: NextFunction) { const start = Date.now();
res.on('finish', () => { const duration = Date.now() - start; console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`); });
next();}Authentication Middleware (src/middleware/auth.ts)
import { Request, Response, NextFunction } from 'express';
export function authenticateApiKey( req: Request, res: Response, next: NextFunction) { const apiKey = req.headers['x-api-key'];
if (!apiKey || apiKey !== process.env.API_KEY) { return res.status(401).json({ error: 'Unauthorized' }); }
next();}
export function rateLimiter() { const requests = new Map<string, number[]>();
return (req: Request, res: Response, next: NextFunction) => { const ip = req.ip || 'unknown'; const now = Date.now(); const windowMs = 60000; // 1 minute const maxRequests = 10;
if (!requests.has(ip)) { requests.set(ip, []); }
const timestamps = requests.get(ip)!.filter(time => now - time < windowMs); timestamps.push(now); requests.set(ip, timestamps);
if (timestamps.length > maxRequests) { return res.status(429).json({ error: 'Too many requests' }); }
next(); };}Database Integration
Prisma Example (src/services/database.ts)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export interface SaveMessageParams { messageId: string; from: string; type: string; content: string; timestamp: Date;}
export async function saveMessageToDatabase(params: SaveMessageParams) { try { await prisma.message.create({ data: { messageId: params.messageId, from: params.from, type: params.type, content: params.content, timestamp: params.timestamp, }, }); console.log(`✅ Message saved to database: ${params.messageId}`); } catch (error) { console.error('Failed to save message:', error); throw error; }}
export async function getMessageHistory(phoneNumber: string, limit = 50) { return prisma.message.findMany({ where: { from: phoneNumber }, orderBy: { timestamp: 'desc' }, take: limit, });}
export async function disconnectDatabase() { await prisma.$disconnect();}Prisma Schema Example
datasource db { provider = "postgresql" // or "mongodb" url = env("DATABASE_URL")}
generator client { provider = "prisma-client-js"}
model Message { id String @id @default(cuid()) messageId String @unique from String type String content String timestamp DateTime createdAt DateTime @default(now())
@@index([from]) @@index([timestamp])}Main Server File
Complete Server Setup (src/server.ts)
import 'dotenv/config';import express from 'express';import { validateWhatsAppConfig } from './config/whatsapp';import webhookRoutes from './routes/webhook';import apiRoutes from './routes/api';import { errorHandler } from './middleware/errorHandler';import { requestLogger } from './middleware/logger';import { authenticateApiKey, rateLimiter } from './middleware/auth';
const app = express();
// Middlewareapp.use(express.json());app.use(requestLogger);
// Health check endpointapp.get('/', (req, res) => { res.json({ status: 'ok', service: 'WhatsApp API Server', version: '1.0.0', });});
// Webhook routes (no authentication for Meta verification)app.use('/webhook', webhookRoutes);
// API routes (protected with API key and rate limiting)app.use('/api', authenticateApiKey, rateLimiter(), apiRoutes);
// Error handling middleware (must be last)app.use(errorHandler);
// Start serverconst PORT = process.env.PORT || 3000;
async function startServer() { try { // Validate WhatsApp configuration await validateWhatsAppConfig();
app.listen(PORT, () => { console.log(`🚀 Server running on port ${PORT}`); console.log(`📱 Webhook endpoint: http://localhost:${PORT}/webhook`); console.log(`🔧 API endpoint: http://localhost:${PORT}/api`); console.log(`🌐 Environment: ${process.env.NODE_ENV}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); }}
// Handle graceful shutdownprocess.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully...'); process.exit(0);});
process.on('SIGINT', () => { console.log('SIGINT received, shutting down gracefully...'); process.exit(0);});
startServer();Testing
Unit Tests with Vitest (src/__tests__/api.test.ts)
import { describe, it, expect, vi, beforeEach } from 'vitest';import request from 'supertest';import express from 'express';import apiRoutes from '../routes/api';
// Mock the WhatsApp clientvi.mock('../config/whatsapp', () => ({ whatsappClient: { messages: { text: vi.fn().mockResolvedValue({ messages: [{ id: 'wamid.test123' }], }), image: vi.fn().mockResolvedValue({ messages: [{ id: 'wamid.test456' }], }), }, },}));
describe('API Routes', () => { let app: express.Application;
beforeEach(() => { app = express(); app.use(express.json()); app.use('/api', apiRoutes); });
describe('POST /api/messages/text', () => { it('should send text message successfully', async () => { const response = await request(app) .post('/api/messages/text') .send({ to: '15551234567', message: 'Test message', });
expect(response.status).toBe(200); expect(response.body).toHaveProperty('success', true); expect(response.body).toHaveProperty('messageId'); });
it('should return 400 if required fields are missing', async () => { const response = await request(app) .post('/api/messages/text') .send({ to: '15551234567' });
expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); }); });
describe('POST /api/messages/image', () => { it('should send image message successfully', async () => { const response = await request(app) .post('/api/messages/image') .send({ to: '15551234567', imageUrl: 'https://example.com/image.jpg', caption: 'Test image', });
expect(response.status).toBe(200); expect(response.body).toHaveProperty('success', true); }); });});Package.json Scripts
{ "scripts": { "dev": "nodemon --exec tsx src/server.ts", "build": "tsc", "start": "node dist/server.js", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src --ext .ts", "format": "prettier --write \"src/**/*.ts\"" }}Production Deployment
Using PM2
# Install PM2pnpm add -g pm2
# Build the projectpnpm build
# Start with PM2pm2 start dist/server.js --name whatsapp-api
# View logspm2 logs whatsapp-api
# Restartpm2 restart whatsapp-api
# Stoppm2 stop whatsapp-apiPM2 Ecosystem File (ecosystem.config.js)
module.exports = { apps: [{ name: 'whatsapp-api', script: './dist/server.js', instances: 2, exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 }, env_production: { NODE_ENV: 'production', PORT: 3000 }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', max_memory_restart: '500M' }]};Docker Deployment
Create Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpmRUN npm install -g pnpm
# Copy package filesCOPY package.json pnpm-lock.yaml ./
# Install dependenciesRUN pnpm install --frozen-lockfile
# Copy source codeCOPY . .
# BuildRUN pnpm build
# Production stageFROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Create docker-compose.yml:
version: '3.8'
services: app: build: . ports: - "3000:3000" env_file: - .env restart: unless-stopped depends_on: - postgres
postgres: image: postgres:16-alpine environment: POSTGRES_DB: whatsapp POSTGRES_USER: user POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data restart: unless-stopped
volumes: postgres_data:Deploy with Docker:
# Build and startdocker-compose up -d
# View logsdocker-compose logs -f
# Stopdocker-compose downTroubleshooting
Common Issues
Webhook Verification Fails
// Ensure verification token matchesapp.get('/webhook', (req, res) => { const mode = req.query['hub.mode']; const token = req.query['hub.verify_token']; const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.WHATSAPP_WEBHOOK_VERIFICATION_TOKEN) { console.log('✅ Webhook verified'); res.status(200).send(challenge); } else { res.sendStatus(403); }});Messages Not Sending
// Add detailed error loggingtry { const response = await whatsappClient.messages.text({ to, body }); console.log('Message sent:', response);} catch (error: any) { console.error('Send error:', { status: error.response?.status, data: error.response?.data, message: error.message, });}Rate Limiting Issues
// Implement exponential backoffasync function sendWithRetry(fn: () => Promise<any>, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error: any) { if (error.response?.status === 429 && i < maxRetries - 1) { const delay = Math.pow(2, i) * 1000; console.log(`Rate limited, retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } else { throw error; } } }}Best Practices
- Always validate environment variables on startup
- Use TypeScript for type safety
- Implement proper error handling and logging
- Store messages in a database for audit trails
- Use middleware for authentication and rate limiting
- Test webhook handlers with mock data
- Monitor API usage and quotas
- Implement graceful shutdown
Example Repository
View the complete Express.js example on GitHub: express-example