Build a production-ready webhook endpoint to receive real-time message notifications from the WhatsApp Business API with proper security, error handling, and message processing.
Webhook endpoints are the critical infrastructure that enables your application to receive real-time events from the WhatsApp Business API. When a user sends a message to your WhatsApp Business number, Meta's servers push that event to your registered webhook URL, allowing your application to respond instantly without polling.
This guide provides complete, production-ready implementations for both Python (Flask) and Node.js (Express.js). You'll learn to configure secure endpoints, verify webhook signatures to prevent spoofing, handle various message types, and implement robust error handling and retry logic.
Prerequisites:
- A registered WhatsApp Business API account with Meta Business Platform access
- Basic understanding of HTTP protocols and REST APIs
- Python 3.8+ or Node.js 16+ installed locally
- A publicly accessible server or tunneling service (ngrok) for local development
- Your WhatsApp App ID and App Secret from the Meta Developer Dashboard
What You'll Learn:
- SSL certificate requirements and webhook endpoint configuration
- Implementing signature verification using your App Secret
- Processing text, media, and location message events
- Debugging common webhook failures (403 errors, timeouts, signature mismatches)
- Building idempotent webhook handlers with proper retry handling
Understanding Webhook Endpoint Requirements
Before writing code, you need to understand the technical requirements Meta enforces for webhook endpoints. These requirements ensure secure, reliable communication between Meta's servers and your application.
SSL Certificate Requirements
Meta requires all webhook URLs to use HTTPS with a valid SSL certificate. Self-signed certificates are not accepted in production environments. Your certificate must:
- Be issued by a trusted Certificate Authority (CA) ā Let's Encrypt, DigiCert, Sectigo, and similar providers are accepted
- Support TLS 1.2 or higher ā TLS 1.0 and 1.1 are deprecated and rejected
- Include the full certificate chain ā Intermediate certificates must be properly configured
- Match the hostname in the webhook URL exactly (no IP addresses allowed)
š Development Tip: For local development, use ngrok to create a secure HTTPS tunnel: ngrok http 5000. This provides a valid certificate for testing without deploying to production.
Webhook URL Validation Process
When you register a webhook URL in the Meta Developer Dashboard, Meta performs a validation request:
- Meta sends a
GETrequest to your webhook URL with ahub.mode=subscribeparameter and ahub.verify_token - Your endpoint must verify the token matches your configured value
- Your endpoint must return the
hub.challengevalue as plain text with HTTP 200 - Meta confirms the subscription and begins sending events
ā ļø Important: The verification request is sent as a GET request, while actual webhook events are sent as POST requests. Your endpoint must handle both methods.
Building the Webhook Endpoint
Below are complete implementations for both Flask (Python) and Express.js (Node.js). Both include subscription verification, signature validation, and message handling.
Flask Implementation (Python)
Install the required dependencies:
# requirements.txt
flask==3.0.0
requests==2.31.0
python-dotenv==1.0.0
Create your webhook endpoint:
# webhook_server.py
import os
import hmac
import hashlib
import json
from flask import Flask, request, jsonify
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
# Configuration ā store these in environment variables
VERIFY_TOKEN = os.getenv('WEBHOOK_VERIFY_TOKEN', 'your-verify-token')
APP_SECRET = os.getenv('WHATSAPP_APP_SECRET', 'your-app-secret')
def verify_signature(payload: bytes, signature: str) -> bool:
"""
Verify the webhook signature using HMAC-SHA256.
The signature header format: 'sha256='
"""
if not signature or not signature.startswith('sha256='):
return False
expected_signature = signature.split('=')[1]
# Generate HMAC using App Secret
mac = hmac.new(
APP_SECRET.encode('utf-8'),
payload,
hashlib.sha256
)
computed_signature = mac.hexdigest()
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed_signature, expected_signature)
@app.route('/webhook', methods=['GET'])
def verify_webhook():
"""
Handle the subscription verification request from Meta.
This responds to the initial webhook registration challenge.
"""
mode = request.args.get('hub.mode')
token = request.args.get('hub.verify_token')
challenge = request.args.get('hub.challenge')
# Validate the verification request
if mode == 'subscribe' and token == VERIFY_TOKEN:
print(f"ā
Webhook verified successfully")
return challenge, 200
else:
print(f"ā Verification failed: mode={mode}, token_match={token == VERIFY_TOKEN}")
return 'Verification failed', 403
@app.route('/webhook', methods=['POST'])
def handle_webhook():
"""
Handle incoming webhook events from WhatsApp Business API.
Verifies signature, processes message events, and returns appropriate responses.
"""
# Get the signature from headers
signature = request.headers.get('X-Hub-Signature-256')
# Get raw payload for signature verification
payload = request.get_data()
# Verify webhook signature
if not verify_signature(payload, signature):
print("ā Signature verification failed")
return jsonify({'error': 'Invalid signature'}), 401
try:
# Parse the JSON payload
data = request.get_json()
# Process each entry in the webhook payload
for entry in data.get('entry', []):
for change in entry.get('changes', []):
if change.get('field') == 'messages':
value = change.get('value', {})
# Handle different event types
if 'messages' in value:
for message in value['messages']:
process_message(message, value)
if 'statuses' in value:
for status in value['statuses']:
process_status_update(status)
# Return 200 OK to acknowledge receipt
# Meta will retry if non-2xx status is returned
return jsonify({'status': 'received'}), 200
except Exception as e:
print(f"ā Error processing webhook: {str(e)}")
# Still return 200 to prevent unnecessary retries for unrecoverable errors
return jsonify({'status': 'error', 'message': str(e)}), 200
def process_message(message: dict, value: dict):
"""
Process an incoming message based on its type.
Supported types: text, image, video, audio, document, location, contacts
"""
message_id = message.get('id')
from_number = message.get('from')
timestamp = message.get('timestamp')
message_type = message.get('type')
print(f"š© Message {message_id} from {from_number} at {timestamp}")
# Handle different message types
if message_type == 'text':
text_body = message['text']['body']
print(f" š Text: {text_body}")
# TODO: Implement your business logic here
elif message_type == 'image':
image_id = message['image']['id']
caption = message['image'].get('caption', '')
mime_type = message['image']['mime_type']
print(f" š¼ļø Image ID: {image_id}, Caption: {caption}, Type: {mime_type}")
# TODO: Download and process image using Media API
elif message_type == 'video':
video_id = message['video']['id']
caption = message['video'].get('caption', '')
print(f" š„ Video ID: {video_id}, Caption: {caption}")
elif message_type == 'audio':
audio_id = message['audio']['id']
mime_type = message['audio']['mime_type']
print(f" šµ Audio ID: {audio_id}, Type: {mime_type}")
elif message_type == 'location':
latitude = message['location']['latitude']
longitude = message['location']['longitude']
location_name = message['location'].get('name', 'Unknown')
print(f" š Location: {latitude}, {longitude} ({location_name})")
elif message_type == 'document':
document_id = message['document']['id']
filename = message['document']['filename']
print(f" š Document: {filename} (ID: {document_id})")
else:
print(f" ā ļø Unknown message type: {message_type}")
def process_status_update(status: dict):
"""
Process message status updates (sent, delivered, read, failed).
"""
message_id = status.get('id')
status_value = status.get('status')
timestamp = status.get('timestamp')
print(f"š Status update for {message_id}: {status_value} at {timestamp}")
if status_value == 'failed':
error = status.get('errors', [{}])[0]
print(f" ā Error {error.get('code')}: {error.get('title')}")
if __name__ == '__main__':
# Use a production WSGI server (gunicorn, uWSGI) in production
# This is for development only
app.run(host='0.0.0.0', port=5000, debug=True)
Express.js Implementation (Node.js)
Install the required dependencies:
# package.json dependencies
npm install express crypto dotenv
Create your webhook endpoint:
// webhook-server.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const app = express();
// Configuration ā store these in environment variables
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN || 'your-verify-token';
const APP_SECRET = process.env.WHATSAPP_APP_SECRET || 'your-app-secret';
const PORT = process.env.PORT || 3000;
// Middleware to parse JSON bodies with raw body preservation for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
/**
* Verify the webhook signature using HMAC-SHA256
* @param {string} body - Raw request body
* @param {string} signature - X-Hub-Signature-256 header value
* @returns {boolean}
*/
function verifySignature(body, signature) {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = signature.split('=')[1];
// Generate HMAC using App Secret
const hmac = crypto.createHmac('sha256', APP_SECRET);
hmac.update(body, 'utf8');
const computedSignature = hmac.digest('hex');
// Use timingSafeEqual to prevent timing attacks
try {
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
const computedBuffer = Buffer.from(computedSignature, 'hex');
return crypto.timingSafeEqual(expectedBuffer, computedBuffer);
} catch (e) {
return false;
}
}
/**
* GET /webhook - Handle subscription verification from Meta
*/
app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
// Validate the verification request
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
console.log('ā
Webhook verified successfully');
res.status(200).send(challenge);
} else {
console.error(`ā Verification failed: mode=${mode}, token_match=${token === VERIFY_TOKEN}`);
res.sendStatus(403);
}
});
/**
* POST /webhook - Handle incoming webhook events
*/
app.post('/webhook', (req, res) => {
// Get signature from headers
const signature = req.headers['x-hub-signature-256'];
// Verify webhook signature
if (!verifySignature(req.rawBody, signature)) {
console.error('ā Signature verification failed');
return res.status(401).json({ error: 'Invalid signature' });
}
try {
const data = req.body;
// Process each entry
(data.entry || []).forEach(entry => {
(entry.changes || []).forEach(change => {
if (change.field === 'messages') {
const value = change.value || {};
// Handle incoming messages
if (value.messages) {
value.messages.forEach(message => {
processMessage(message, value);
});
}
// Handle status updates
if (value.statuses) {
value.statuses.forEach(status => {
processStatusUpdate(status);
});
}
}
});
});
// Return 200 OK to acknowledge receipt
res.status(200).json({ status: 'received' });
} catch (error) {
console.error('ā Error processing webhook:', error);
// Return 200 to prevent unnecessary retries for application errors
res.status(200).json({ status: 'error', message: error.message });
}
});
/**
* Process an incoming message based on its type
*/
function processMessage(message, value) {
const messageId = message.id;
const fromNumber = message.from;
const timestamp = message.timestamp;
const messageType = message.type;
console.log(`š© Message ${messageId} from ${fromNumber} at ${timestamp}`);
switch (messageType) {
case 'text':
const textBody = message.text.body;
console.log(` š Text: ${textBody}`);
// TODO: Implement your business logic
break;
case 'image':
const imageId = message.image.id;
const imageCaption = message.image.caption || '';
const imageMimeType = message.image.mime_type;
console.log(` š¼ļø Image ID: ${imageId}, Caption: ${imageCaption}, Type: ${imageMimeType}`);
// TODO: Download using Media API
break;
case 'video':
const videoId = message.video.id;
const videoCaption = message.video.caption || '';
console.log(` š„ Video ID: ${videoId}, Caption: ${videoCaption}`);
break;
case 'audio':
case 'voice':
const audioId = message[messageType].id;
const audioMimeType = message[messageType].mime_type;
console.log(` šµ Audio ID: ${audioId}, Type: ${audioMimeType}`);
break;
case 'location':
const { latitude, longitude } = message.location;
const locationName = message.location.name || 'Unknown';
console.log(` š Location: ${latitude}, ${longitude} (${locationName})`);
break;
case 'document':
const documentId = message.document.id;
const filename = message.document.filename;
console.log(` š Document: ${filename} (ID: ${documentId})`);
break;
case 'contacts':
const contacts = message.contacts;
console.log(` š„ Received ${contacts.length} contact(s)`);
break;
default:
console.log(` ā ļø Unknown message type: ${messageType}`);
}
}
/**
* Process message status updates
*/
function processStatusUpdate(status) {
const messageId = status.id;
const statusValue = status.status;
const timestamp = status.timestamp;
console.log(`š Status update for ${messageId}: ${statusValue} at ${timestamp}`);
if (statusValue === 'failed') {
const error = (status.errors || [])[0];
console.error(` ā Error ${error?.code}: ${error?.title}`);
}
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`š Webhook server running on port ${PORT}`);
console.log(`š Webhook URL: https://your-domain.com/webhook`);
});
Implementing Webhook Signature Verification
Signature verification is your primary defense against spoofed webhook requests. Meta signs every webhook payload using your App Secret with HMAC-SHA256. By verifying this signature, you ensure the request genuinely came from Meta and hasn't been tampered with in transit.
How Signature Verification Works
The verification process follows these steps:
- Extract the signature from the
X-Hub-Signature-256header (format:sha256=<hex_hash>) - Compute your own signature by creating an HMAC-SHA256 hash of the raw request body using your App Secret
- Compare signatures using a constant-time comparison function to prevent timing attacks
- Reject the request if signatures don't match
ā ļø Security Warning: Never use simple string comparison (===) for signature verification. Always use constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js) to prevent timing attacks that could leak information about your App Secret.
Environment Variable Configuration
Create a .env file to store your sensitive configuration:
# .env - Add this file to .gitignore!
WEBHOOK_VERIFY_TOKEN=your-custom-verify-token-min-16-chars
WHATSAPP_APP_SECRET=your-app-secret-from-meta-dashboard
Find your App Secret in the Meta Developer Dashboard:
- Navigate to your App Dashboard
- Go to Settings > Basic
- Click Show next to App Secret
- Copy the value to your
.envfile
ā ļø Never commit your App Secret to version control. Treat it with the same security as database passwords or API keys. If compromised, regenerate it immediately in the Meta Dashboard.
Handling Incoming Message Events
The WhatsApp Business API sends various event types to your webhook endpoint. Understanding the payload structure enables you to build comprehensive message processing logic.
Webhook Payload Structure
A typical webhook payload contains nested objects:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550000000",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [
{
"profile": { "name": "Customer Name" },
"wa_id": "CUSTOMER_PHONE_NUMBER"
}
],
"messages": [
{
"from": "CUSTOMER_PHONE_NUMBER",
"id": "MESSAGE_ID",
"timestamp": "1691234567",
"type": "text",
"text": { "body": "Hello, I have a question!" }
}
]
},
"field": "messages"
}
]
}
]
}
Message Type Reference
| Message Type | Description | Key Fields |
|---|---|---|
text |
Plain text message | body |
image |
Image file (JPEG, PNG) | id, caption, mime_type, sha256 |
video |
Video file (MP4) | id, caption, mime_type |
audio |
Audio file | id, mime_type, voice (boolean) |
document |
Document file (PDF, etc.) | id, filename, mime_type |
location |
Shared location | latitude, longitude, name, address |
contacts |
Shared contact cards | phones, emails, name |
sticker |
Animated/static sticker | id, mime_type, animated |
button |
Button reply | payload, text |
interactive |
List/button reply | type, list_reply or button_reply |
For media messages (image, video, audio, document), you receive a media ID rather than the file itself. Download the actual file using the WhatsApp Business API Media endpoint.
Status Update Events
Meta also sends status updates for messages you send:
{
"statuses": [
{
"id": "MESSAGE_ID",
"recipient_id": "CUSTOMER_PHONE_NUMBER",
"status": "delivered",
"timestamp": "1691234567",
"conversation": {
"id": "CONVERSATION_ID",
"origin": { "type": "user_initiated" }
}
}
]
}
Status values include:
sentā Message accepted by WhatsApp serversdeliveredā Message delivered to the recipient's devicereadā Message opened by the recipient (read receipts enabled)failedā Message delivery failed (includes error details)
Troubleshooting Common Webhook Failures
Even with correct implementation, you may encounter issues when setting up or maintaining webhook endpoints. Here's how to diagnose and resolve the most common problems.
HTTP 403 Forbidden Errors
A 403 error during webhook registration typically indicates a verification token mismatch:
| Symptom | Cause | Solution |
|---|---|---|
| 403 during registration | Verify token doesn't match | Confirm the token in your code matches exactly what's entered in the Meta Dashboard |
| 403 on event delivery | Signature verification failing | Check App Secret is correct; ensure raw body is used for HMAC computation |
| Intermittent 403s | Load balancer stripping headers | Configure your proxy/CDN to preserve the X-Hub-Signature-256 header |
Timeout Issues
Meta expects a response within 20 seconds. Exceeding this triggers a retry:
- High latency ā Move your server closer to your users or use a CDN with edge computing
- Database locks ā Process webhooks asynchronously; return 200 immediately, then handle the message in a background worker
- Large payloads ā Implement streaming JSON parsing for very large webhook batches
Example of asynchronous processing pattern:
# Python async processing with Celery/Redis
@app.route('/webhook', methods=['POST'])
def handle_webhook():
if not verify_signature(request.get_data(),
request.headers.get('X-Hub-Signature-256')):
return jsonify({'error': 'Invalid signature'}), 401
# Queue for background processing, return 200 immediately
process_webhook_task.delay(request.get_json())
return jsonify({'status': 'queued'}), 200
@celery.task
def process_webhook_task(data):
# Handle message processing here (can take longer than 20s)
for entry in data.get('entry', []):
# ... processing logic
pass
Signature Mismatch Errors
If signature verification fails despite correct App Secret:
- Verify raw body access ā Ensure you're using the raw request body, not the parsed JSON object
- Check encoding ā The signature is computed on UTF-8 encoded body content
- Inspect headers ā Some middleware modifies request bodies; verify
X-Hub-Signature-256is present - Compare hex strings ā Ensure both signatures are lowercase hex strings
Debugging helper to log signature details:
def verify_signature_debug(payload: bytes, signature: str) -> bool:
"""Debug version with detailed logging"""
if not signature:
print("ā No signature header provided")
return False
expected = signature.split('=')[1] if '=' in signature else signature
computed = hmac.new(
APP_SECRET.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
print(f"Expected: {expected[:20]}...")
print(f"Computed: {computed[:20]}...")
print(f"Body length: {len(payload)} bytes")
print(f"Body preview: {payload[:200]}...")
return hmac.compare_digest(computed, expected)
SSL Certificate Errors
If Meta cannot establish an SSL connection:
- Use SSL Labs Test to verify your certificate chain is complete
- Ensure intermediate certificates are included in your server configuration
- Verify TLS 1.2+ is enabled and SSLv3/TLS 1.0/1.1 are disabled
- Check your domain matches the certificate exactly (www vs non-www)
Best Practices for Webhook Idempotency and Retry Handling
Meta's webhook system implements at-least-once delivery semantics. Your endpoint must handle duplicate events gracefully and acknowledge receipts properly.
Implementing Idempotent Handlers
Every webhook event includes a unique message ID. Use this to prevent duplicate processing:
# Python with Redis for deduplication
import redis
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def process_message_idempotent(message: dict):
message_id = message['id']
# Use Redis SETNX (set if not exists) for atomic check-and-set
# TTL of 24 hours prevents memory bloat
was_new = redis_client.set(
f"whatsapp:processed:{message_id}",
"1",
nx=True, # Only set if key doesn't exist
ex=86400 # Expire after 24 hours
)
if not was_new:
print(f"ā ļø Duplicate message {message_id}, skipping")
return
# Process the message
handle_message(message)
// Node.js with Redis
const Redis = require('ioredis');
const redis = new Redis();
async function processMessageIdempotent(message) {
const messageId = message.id;
const key = `whatsapp:processed:${messageId}`;
// SET with NX (only if not exists) and EX (expiration)
const wasNew = await redis.set(key, '1', 'EX', 86400, 'NX');
if (!wasNew) {
console.log(`ā ļø Duplicate message ${messageId}, skipping`);
return;
}
await handleMessage(message);
}
Understanding Meta's Retry Behavior
When your endpoint returns a non-2xx status code or times out, Meta retries with exponential backoff:
| Retry Attempt | Delay After Previous Attempt |
|---|---|
| 1st retry | ~5 minutes |
| 2nd retry | ~10 minutes |
| 3rd retry | ~20 minutes |
| 4th retry | ~40 minutes |
| 5th retry | ~1.5 hours |
After the final retry, the event is dropped. Monitor your webhook error rates in the Meta Dashboard.
Response Code Strategy
Choose response codes strategically to control retry behavior:
- 200 OK ā Event processed successfully; no retry needed
- 4xx errors ā Client error; Meta will retry (assumes transient issue)
- 5xx errors ā Server error; Meta will retry
- Timeout (>20s) ā Treated as failure; Meta will retry
š Best Practice: Return 200 even for business logic errors (e.g., invalid message format) to prevent unnecessary retries. Only return error codes for infrastructure failures (database unavailable, out of memory) where a retry might succeed.
Monitoring and Alerting
Implement comprehensive monitoring for your webhook endpoint:
# Add monitoring metrics to your webhook handler
from prometheus_client import Counter, Histogram
webhook_requests = Counter('whatsapp_webhook_requests_total',
'Total webhook requests',
['status', 'type'])
webhook_latency = Histogram('whatsapp_webhook_latency_seconds',
'Webhook processing latency')
@app.route('/webhook', methods=['POST'])
def handle_webhook():
with webhook_latency.time():
# ... verification and processing ...
webhook_requests.labels(
status='success' if success else 'error',
type=message_type
).inc()
return response
Set up alerts for:
- Webhook error rate > 5% over 5 minutes
- 95th percentile latency approaching 15 seconds (before 20s timeout)
- Spike in duplicate message processing (indicates retry storms)
- SSL certificate expiration within 30 days
Summary and Next Steps
You now have a complete, production-ready webhook implementation for the WhatsApp Business API. Your endpoint:
- ā Validates webhook signatures using HMAC-SHA256 for security
- ā Handles all message types (text, media, location, interactive)
- ā Processes status updates for sent message tracking
- ā Implements idempotency to prevent duplicate processing
- ā Follows retry handling best practices
Immediate next steps:
- Deploy your webhook endpoint to a server with a valid SSL certificate
- Configure your webhook URL in the Meta Developer Dashboard
- Subscribe to the
messageswebhook field - Test with the WhatsApp Business API test number
- Set up monitoring and alerting for production use
For a comprehensive understanding of the WhatsApp Business API architecture, review our complete technical guide covering authentication, message templates, and conversation-based pricing.
Reference Documentation:
⢠Meta Webhooks Setup Guide
⢠Graph API Webhooks Documentation
⢠Webhook Components Reference
This content is for educational purposes. Always consult official Meta documentation and legal counsel for compliance requirements specific to your use case.

