WhatsApp Webhook Setup: Real-Time Message Processing
Real-time message processing is the backbone of any interactive WhatsApp integration. Whether you're building a chatbot, syncing messages to a CRM, or tracking delivery receipts, you need to receive events as they happen. WSAPI offers two real-time delivery mechanisms: HTTP webhooks and Server-Sent Events (SSE). This guide covers both.
Webhooks vs SSE: Which to Choose
| Feature | Webhooks | SSE |
|---|---|---|
| Direction | WSAPI pushes to you | You connect to WSAPI |
| Connection | Per-event HTTP POST | Persistent stream |
| Reliability | Auto-retry on failure | Reconnect on drop |
| Firewall | Needs public endpoint | Outbound only |
| Best for | Servers, production | Local dev, debugging |
Use webhooks for production. Use SSE for local development or when you can't expose a public endpoint. You can use both simultaneously.
Setting Up Webhooks
Step 1: Configure Your Webhook URL
Point your WSAPI instance to your webhook endpoint via the dashboard or API:
curl -X PUT https://api.wsapi.chat/account/instances/{instanceId}/settings \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://your-app.com/webhooks/whatsapp",
"eventSigningSecret": "a-long-random-string",
"eventFilters": ["message", "message_read", "user_presence", "group"]
}'Step 2: Build a Webhook Receiver (Node.js)
import express from 'express';
import crypto from 'crypto';
const app = express();
const SIGNING_SECRET = process.env.WSAPI_SIGNING_SECRET;
// Fail closed, at startup: with no secret configured nothing is signed and
// nothing can be verified, and a receiver that skips the check looks exactly
// like one that passes it. Better to refuse to boot than to accept anything.
if (!SIGNING_SECRET) {
throw new Error('WSAPI_SIGNING_SECRET is not set: configure a signing secret on your instance first');
}
// The signature covers the bytes WSAPI sent, so the body is read raw and
// parsed only after the check. express.json() would re-serialise it and
// every signature would fail.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.header('X-Webhook-Signature'))) {
return res.sendStatus(401);
}
let payload;
try {
payload = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
const { eventType, eventData } = payload;
switch (eventType) {
case 'message': {
const direction = eventData.sender.isMe ? 'SENT' : 'RECV';
console.log(`[${direction}] ${eventData.sender.id}: ${eventData.text ?? '[' + eventData.type + ']'}`);
break;
}
case 'message_read':
console.log(`[RECEIPT] ${eventData.receiptType} in ${eventData.chatId}`);
break;
case 'user_presence':
console.log(`[PRESENCE] ${eventData.id} is ${eventData.status}`);
break;
case 'group':
console.log(`[GROUP] ${eventData.id} changed`);
break;
}
res.sendStatus(200);
});
function verifySignature(rawBody, header) {
if (!header) return false;
const expected =
'sha256=' + crypto.createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex');
// timingSafeEqual throws when the two buffers differ in length, which is
// what a forged signature usually looks like, so compare lengths first.
if (expected.length !== header.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
app.listen(3000, () => console.log('Webhook server running on port 3000'));Step 3: Build a Webhook Receiver (Python)
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
# Fail closed at startup: no secret means nothing is verified.
SIGNING_SECRET = os.environ["WSAPI_SIGNING_SECRET"]
@app.post("/webhooks/whatsapp")
async def webhook(request: Request):
raw = await request.body() # the signature covers these bytes
signature = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
SIGNING_SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw)
event_type = payload["eventType"]
data = payload["eventData"]
if event_type == "message":
direction = "SENT" if data["sender"]["isMe"] else "RECV"
print(f"[{direction}] {data['sender']['id']}: {data.get('text', '[' + data['type'] + ']')}")
elif event_type == "message_read":
print(f"[RECEIPT] {data['receiptType']} in {data['chatId']}")
return {"status": "ok"}Event Types Deep Dive
WSAPI delivers events in four categories. Subscribe only to the events you need to reduce noise and bandwidth:
Message Events
message: a new message, incoming or outgoing (text, media, location, contact, reaction)message_delete: a message was deleted by its sendermessage_star: a message was starred or unstarredmessage_history_sync: messages arriving from an on-demand history sync
Delivery Events
message_read: a receipt, withreceiptTypeone ofdelivered,read,played,readSelfand a few more. It is a string, not a numeric code
Presence Events
user_presence: a contact wentavailableorunavailablechat_presence: someone istyping,recordingorpausedin a chat
Group Events
group: one event for every group change. The payload carries what changed:joinandleavearrays,promoteanddemote,name,description,locked,announce
Session Events
logged_in,logged_out,login_error,initial_sync_finished: these four are always delivered and cannot be filtered out
SSE: Server-Sent Events
SSE is perfect for local development or environments where you can't expose a public webhook URL. Connect to the WSAPI event stream and receive events over a persistent HTTP connection:
This runs on your server, never in the browser. The stream authenticates with your API key, and any key placed in browser code is handed to every visitor whether it sits in the URL or in a header. Both clients below are server side for that reason.
// npm install eventsource
import { EventSource } from 'eventsource';
// The stream authenticates with the same two headers as the REST API. The
// browser's built-in EventSource cannot send headers, so this runs in Node
// and injects them through a custom fetch.
const stream = new EventSource('https://api.wsapi.chat/events/stream', {
fetch: (url, init) =>
fetch(url, {
...init,
headers: {
...init.headers,
'x-api-key': process.env.WSAPI_KEY,
'x-instance-id': process.env.INSTANCE_ID,
},
}),
});
stream.onmessage = (e) => {
const { eventType, eventData } = JSON.parse(e.data);
if (eventType === 'message') {
console.log('New message:', eventData.sender.id, eventData.text);
}
};
stream.onerror = () => {
console.log('Connection lost, reconnecting...');
};import json
import os
import requests
with requests.get(
"https://api.wsapi.chat/events/stream",
headers={
"x-api-key": os.environ["WSAPI_KEY"],
"x-instance-id": os.environ["INSTANCE_ID"],
},
stream=True,
) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line.startswith(b"data: "):
continue
payload = json.loads(line[len(b"data: "):])
print(payload["eventType"], payload["eventData"])Production Best Practices
- Always verify signatures: check the
X-Webhook-Signatureheader, which carriessha256=followed by the HMAC-SHA256 of the raw body, to validate that a payload came from WSAPI. - Respond quickly: Return a 200 status within 5 seconds. Do heavy processing asynchronously (queue the event, process later).
- Handle retries idempotently: WSAPI retries failed deliveries. Use the event ID to deduplicate if your handler isn't idempotent.
- Use HTTPS: Always use HTTPS for your webhook endpoint. WSAPI will not deliver to plain HTTP in production.
- Monitor delivery: Track webhook delivery success rates in your logging. The WSAPI dashboard also shows delivery stats.
- Filter events: Only subscribe to events you actually process. Fewer events = less load, lower latency.
Next Steps
- Build a chatbot that uses webhooks for real-time conversations
- Send messages with Python and connect to your webhook receiver
- Read the message event reference for the exact payload your handler receives
- Get started: connect your first WhatsApp account and configure webhooks