How to Configure Webhooks in WhatsApp Business API: Complete Technical Guide
Practical guide for developers to configure WhatsApp Business API webhooks with Node.js and Python code examples, security validation, and troubleshooting.
If you're starting to integrate the WhatsApp Business API, configuring webhooks is the first real challenge. Without webhooks, your application doesn't receive messages, delivery notifications, or status updates — it's completely blind.
In this guide, I'll walk you through step by step how to configure, validate, and process Meta webhooks, with working code and solutions for the most common issues.
What Are Webhooks and Why You Need Them
Webhooks are callback URLs that Meta automatically calls when something happens on your WhatsApp account. Instead of polling (repeatedly checking for updates), your server receives events in real time.
Events you receive via webhook:
- Incoming messages — text, image, audio, document, location
- Delivery status — sent, delivered, read
- Delivery errors — message undeliverable, invalid number
- Account changes — template updates, profile changes
Without configured webhooks, your application simply doesn't work as a two-way communication channel.
Anatomy of a Meta Webhook Request
Before implementing, you need to understand the structure Meta sends. Every request is a POST with a JSON payload.
Base Payload Structure
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15551234567",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [
{
"profile": { "name": "John Smith" },
"wa_id": "15559876543"
}
],
"messages": [
{
"from": "15559876543",
"id": "wamid.xxxxx",
"timestamp": "1708099200",
"text": { "body": "Hi, I'd like to know about product X" },
"type": "text"
}
]
},
"field": "messages"
}
]
}
]
}
Delivery Status Event
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15551234567",
"phone_number_id": "PHONE_NUMBER_ID"
},
"statuses": [
{
"id": "wamid.xxxxx",
"status": "delivered",
"timestamp": "1708099260",
"recipient_id": "15559876543"
}
]
}
}
Possible statuses are: sent, delivered, read, and failed.
Step 1: Create the Webhook Endpoint
Your server needs to respond to two types of requests:
- GET — Initial verification (Meta confirms your URL is valid)
- POST — Real-time event reception
Node.js with Express
const express = require('express');
const app = express();
app.use(express.json());
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN;
// Webhook verification (GET)
app.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 === VERIFY_TOKEN) {
console.log('Webhook verified successfully');
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// Event reception (POST)
app.post('/webhook', (req, res) => {
// IMPORTANT: respond 200 immediately
res.sendStatus(200);
const body = req.body;
if (body.object !== 'whatsapp_business_account') return;
body.entry?.forEach(entry => {
entry.changes?.forEach(change => {
const value = change.value;
// Process incoming messages
if (value.messages) {
value.messages.forEach(message => {
console.log(`Message from ${message.from}: ${message.text?.body}`);
// Process the message here (save to DB, reply, etc.)
});
}
// Process delivery statuses
if (value.statuses) {
value.statuses.forEach(status => {
console.log(`Status: ${status.status} for ${status.recipient_id}`);
});
}
});
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Python with Flask
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
VERIFY_TOKEN = os.environ.get('WEBHOOK_VERIFY_TOKEN')
@app.route('/webhook', methods=['GET'])
def verify_webhook():
mode = request.args.get('hub.mode')
token = request.args.get('hub.verify_token')
challenge = request.args.get('hub.challenge')
if mode == 'subscribe' and token == VERIFY_TOKEN:
print('Webhook verified successfully')
return challenge, 200
return 'Forbidden', 403
@app.route('/webhook', methods=['POST'])
def receive_webhook():
body = request.get_json()
if body.get('object') != 'whatsapp_business_account':
return 'OK', 200
for entry in body.get('entry', []):
for change in entry.get('changes', []):
value = change.get('value', {})
# Process incoming messages
for message in value.get('messages', []):
sender = message.get('from')
text = message.get('text', {}).get('body', '')
print(f'Message from {sender}: {text}')
# Process delivery statuses
for status in value.get('statuses', []):
print(f"Status: {status['status']} for {status['recipient_id']}")
return 'OK', 200
if __name__ == '__main__':
app.run(port=3000)
Step 2: Configure in Meta Business Manager
- Go to Meta for Developers and open your app
- In the sidebar, navigate to WhatsApp > Configuration
- In the Webhook section, click Edit
- Enter your Callback URL with your server's public URL (e.g.,
https://yourdomain.com/webhook) - Enter the Verify token — must be the same value as
WEBHOOK_VERIFY_TOKENon your server - Click Verify and save
- After verification, subscribe to fields you want to receive: select at least
messages
Meta will send a GET request to your URL with hub.mode, hub.verify_token, and hub.challenge parameters. If your server responds correctly, the webhook is activated.
Step 3: Signature Validation (Security)
Meta signs every request with an HMAC-SHA256 hash using your App Secret. Validating this signature ensures the request actually came from Meta.
Node.js
const crypto = require('crypto');
function validateSignature(req, buf) {
const signature = req.headers['x-hub-signature-256'];
if (!signature) return false;
const appSecret = process.env.META_APP_SECRET;
const expectedSignature = 'sha256=' +
crypto.createHmac('sha256', appSecret).update(buf).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Middleware to validate before processing
app.use('/webhook', express.json({
verify: (req, res, buf) => {
if (!validateSignature(req, buf)) {
throw new Error('Invalid signature');
}
}
}));
Python
import hmac
import hashlib
def validate_signature(request):
signature = request.headers.get('X-Hub-Signature-256', '')
app_secret = os.environ.get('META_APP_SECRET')
expected = 'sha256=' + hmac.new(
app_secret.encode(),
request.data,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.before_request
def check_signature():
if request.path == '/webhook' and request.method == 'POST':
if not validate_signature(request):
return 'Invalid signature', 403
Never skip signature validation in production. Without it, anyone can send fake requests to your endpoint.
Step 4: Testing Locally with ngrok
During development, you need a public URL. ngrok creates a secure tunnel to your localhost.
# Install ngrok
npm install -g ngrok
# Start your local server
node server.js
# In another terminal, create the tunnel
ngrok http 3000
ngrok will generate a URL like https://abc123.ngrok-free.app. Use this URL in Meta Business Manager as your callback URL.
Important tips:
- The URL changes every time you restart ngrok (free tier)
- Use
ngrok http 3000 --log=stdoutto see requests in real time - The ngrok dashboard at
http://localhost:4040shows all received requests — very useful for debugging
Event Structure by Type
Text Message
{
"type": "text",
"text": { "body": "Message content" }
}
Image Message
{
"type": "image",
"image": {
"id": "MEDIA_ID",
"mime_type": "image/jpeg",
"sha256": "HASH",
"caption": "Optional caption"
}
}
Audio Message
{
"type": "audio",
"audio": {
"id": "MEDIA_ID",
"mime_type": "audio/ogg"
}
}
Document Message
{
"type": "document",
"document": {
"id": "MEDIA_ID",
"mime_type": "application/pdf",
"filename": "proposal.pdf"
}
}
Location Message
{
"type": "location",
"location": {
"latitude": 37.7749,
"longitude": -122.4194,
"name": "San Francisco",
"address": "Market Street, 1000"
}
}
To download media (image, audio, document), use the GET /{media_id} endpoint with your access token to get the download URL.
Troubleshooting: Common Issues
Webhook not receiving events
Cause 1: Invalid or self-signed SSL Meta requires a valid SSL certificate. Self-signed certificates don't work. Use Let's Encrypt (free) or a service like Cloudflare.
Cause 2: Server not responding 200 in time
Your endpoint must return 200 OK in less than 20 seconds. If it takes longer, Meta considers it a failure.
Cause 3: Not subscribed to the correct fields
After configuring the webhook, you need to subscribe to fields. Go to WhatsApp > Configuration > Webhook and check the desired fields (e.g., messages).
Meta retries the webhook and duplicates messages
Meta automatically retries when it doesn't receive 200. To avoid duplicate processing:
const processedMessages = new Set();
app.post('/webhook', (req, res) => {
res.sendStatus(200); // Respond IMMEDIATELY
req.body.entry?.forEach(entry => {
entry.changes?.forEach(change => {
change.value.messages?.forEach(message => {
// Idempotency: ignore already-processed messages
if (processedMessages.has(message.id)) return;
processedMessages.add(message.id);
// Process the message asynchronously
processMessageAsync(message).catch(console.error);
});
});
});
});
In production, replace the Set with Redis or a database to persist between restarts.
Error 400 on verification
Check that WEBHOOK_VERIFY_TOKEN on your server is exactly the same as what you configured in Meta Business Manager. Extra spaces or invisible characters are a common cause.
Implementation Best Practices
1. Respond 200 Before Processing
The most important rule: return 200 immediately and process the event asynchronously. If you process before responding and take more than 20 seconds, Meta will consider it a failure and retry.
app.post('/webhook', (req, res) => {
res.sendStatus(200); // First: respond
// Then: process in background
processWebhookEvent(req.body)
.catch(err => console.error('Error processing webhook:', err));
});
2. Implement Idempotency
Meta may send the same event more than once (retries). Use the message.id or status.id as an idempotency key to avoid processing duplicates.
3. Use Queues for Processing
In high-volume applications, put events in a queue (Redis Queue, Bull, SQS) instead of processing inline:
const Queue = require('bull');
const webhookQueue = new Queue('webhook-events', process.env.REDIS_URL);
app.post('/webhook', (req, res) => {
res.sendStatus(200);
webhookQueue.add(req.body);
});
webhookQueue.process(async (job) => {
await processWebhookEvent(job.data);
});
4. Monitor and Log
Log all received events for debugging and auditing. When issues arise, logs are your first source of diagnosis.
5. Handle Every Message Type
Don't assume every message is text. Always check the type field before accessing data:
function handleMessage(message) {
switch (message.type) {
case 'text':
return handleText(message.text.body);
case 'image':
return handleMedia(message.image);
case 'audio':
return handleMedia(message.audio);
case 'document':
return handleMedia(message.document);
case 'location':
return handleLocation(message.location);
default:
console.log(`Unsupported type: ${message.type}`);
}
}
Conclusion
Webhooks are the foundation of any functional WhatsApp Business API integration. With a properly configured endpoint, security validation implemented, and async processing, your application will be ready to receive and process messages in real time.
Key takeaways:
- Respond 200 immediately — process afterwards
- Validate the signature — never trust unverified requests
- Implement idempotency — Meta retries
- Use valid HTTPS — self-signed certificates don't work
- Test with ngrok — validate locally before deploying
If you're just getting started with the WhatsApp Business API, also check out my guide on how to create an App on Meta for Developers to set up the entire environment before implementing webhooks.