What Is a WhatsApp Webhook and Why Do You Need One?
A WhatsApp webhook is an HTTP callback that Meta’s servers send to your application whenever something happens: a customer sends a message, reads a message, clicks a button, or reacts to a template. Instead of your server polling the WhatsApp API constantly asking “any new messages?”, webhooks push data to you the moment events occur — in real time, at scale, with no unnecessary API calls.
If you’re building any form of WhatsApp automation — a chatbot, a CRM integration, an order management system, or a customer support platform like Messenjo — webhooks are the foundation. This guide walks through the complete setup process from Meta’s perspective and your server’s perspective.
Prerequisites
Before setting up your webhook, you need: a Meta Developer account, a WhatsApp Business App created in the Meta Developer Portal, a WhatsApp Business API phone number, and a publicly accessible HTTPS endpoint (your webhook URL). Local development is possible using ngrok or Cloudflare Tunnel to expose your localhost.
Step 1: Create Your Webhook Endpoint
Your webhook URL must handle two types of requests: a GET request for initial verification, and POST requests for incoming event data. Here’s a minimal FastAPI implementation:
from fastapi import FastAPI, Request, HTTPException
import hashlib
import hmac
import json
app = FastAPI()
WEBHOOK_VERIFY_TOKEN = "your_verify_token_here"
WHATSAPP_APP_SECRET = "your_app_secret_here"
@app.get("/webhook")
async def verify_webhook(
hub_mode: str = None,
hub_verify_token: str = None,
hub_challenge: str = None
):
"""Meta calls this to verify your webhook endpoint."""
if hub_mode == "subscribe" and hub_verify_token == WEBHOOK_VERIFY_TOKEN:
return int(hub_challenge)
raise HTTPException(status_code=403, detail="Verification failed")
@app.post("/webhook")
async def receive_webhook(request: Request):
"""Meta sends all WhatsApp events here."""
# Verify signature
body = await request.body()
signature = request.headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(
WHATSAPP_APP_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
await process_webhook(payload)
return {"status": "ok"}
Step 2: Register Your Webhook in Meta Developer Portal
In the Meta Developer Portal, navigate to your WhatsApp App → Configuration → Webhooks. Enter your webhook URL and verify token. Click “Verify and Save” — Meta will immediately send a GET request to your endpoint. If it returns the challenge correctly, the webhook is verified.
Then subscribe to the webhook fields you need. For a full WhatsApp integration, subscribe to: messages (incoming messages), message_deliveries (delivery status), message_reads (read receipts), message_reactions (emoji reactions), and messaging_postbacks (button clicks).
Step 3: Parse the Webhook Payload
Every WhatsApp webhook payload follows the same outer structure. The inner message object varies by type:
// Outer structure (always the same)
{
"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": "Customer Name" }, "wa_id": "15559876543" }],
"messages": [
// message object here — varies by type
]
},
"field": "messages"
}]
}]
}
Text Message Payload
{
"id": "wamid.xxx",
"from": "15559876543",
"timestamp": "1700000000",
"type": "text",
"text": { "body": "Hello, I need help with my order" }
}
Interactive Button Reply Payload
{
"id": "wamid.xxx",
"from": "15559876543",
"timestamp": "1700000000",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {
"id": "btn_confirm_order",
"title": "Confirm Order"
}
}
}
Status Update Payload (delivery/read)
{
"id": "wamid.xxx",
"status": "read", // sent | delivered | read | failed
"timestamp": "1700000000",
"recipient_id": "15559876543"
}
Step 4: Build Your Message Router
A clean webhook handler routes incoming events to the right processor based on message type:
async def process_webhook(payload: dict):
entry = payload.get("entry", [])
for entry_item in entry:
for change in entry_item.get("changes", []):
value = change.get("value", {})
# Handle incoming messages
for message in value.get("messages", []):
msg_type = message.get("type")
if msg_type == "text":
await handle_text_message(message, value)
elif msg_type == "interactive":
await handle_button_reply(message, value)
elif msg_type == "image":
await handle_image_message(message, value)
elif msg_type == "document":
await handle_document_message(message, value)
elif msg_type == "location":
await handle_location_message(message, value)
# Handle status updates
for status in value.get("statuses", []):
await handle_status_update(status)
Step 5: Secure Your Webhook
Never skip signature verification. Every legitimate POST from Meta includes an X-Hub-Signature-256 header containing an HMAC-SHA256 hash of the request body signed with your App Secret. Always verify this before processing any payload — without it, anyone who knows your webhook URL can send fake events.
Additional security practices: use a non-guessable webhook path (not /webhook but something like /wh/a7f3b2c9), rate-limit your endpoint, and return 200 OK immediately even before processing (process asynchronously via a queue) — Meta will retry if it doesn’t get a 200 within 20 seconds.
Handling Webhook Retries
Meta retries failed webhooks up to 24 hours. This means your processing logic must be idempotent — processing the same message twice should produce the same result, not duplicate records. Always check if a message ID has already been processed before acting on it:
async def handle_text_message(message: dict, value: dict):
message_id = message["id"]
# Idempotency check
if await redis.exists(f"processed:{message_id}"):
return # Already handled
# Process message
await save_message_to_db(message, value)
await trigger_bot_response(message, value)
# Mark as processed (expire after 48 hours)
await redis.setex(f"processed:{message_id}", 172800, "1")
Testing Your Webhook Locally
Use ngrok to expose your local development server to the internet during testing:
# Install ngrok and expose port 8000
ngrok http 8000
# Your webhook URL will be something like:
# https://abc123.ngrok-free.app/webhook
# Test with a curl simulation
curl -X POST https://abc123.ngrok-free.app/webhook \
-H "Content-Type: application/json" \
-d '{"object":"whatsapp_business_account","entry":[...]}'
The Meta Developer Portal also has a Webhooks test tool that sends sample payloads of each event type directly to your endpoint without requiring a real WhatsApp message.
Common Webhook Issues and Fixes
| Issue | Likely Cause | Fix |
|---|---|---|
| Verification failing | Returning string instead of int for challenge | Return int(hub_challenge) not the string |
| Signature mismatch | Using wrong app secret (use App Secret, not Token) | Find App Secret in App Settings → Basic |
| Not receiving messages | Not subscribed to “messages” field | Enable in Webhooks → Manage Subscriptions |
| Duplicate processing | No idempotency check, Meta retried | Store message IDs in Redis with TTL |
| Timeouts (Meta stops retrying) | Processing takes >20s before returning 200 | Return 200 immediately, process via Celery task |
Using a Platform Instead of Building from Scratch
Building webhook infrastructure from scratch is the right choice if you’re embedding WhatsApp into your own product. If you’re building a customer-facing automation platform, Messenjo handles all webhook infrastructure, message routing, and event processing for you — so you can focus on the automation logic rather than the plumbing.
For related technical deep-dives, see our guides on WhatsApp Business API Access Setup, FastAPI development services, and n8n automation integration.
