Webhooks
Webhooks let WAFlow push events to your server in real time — inbound customer messages, delivery/read receipts, and campaign completion — so you don't have to poll.
Subscribe
- In the WAFlow dashboard, go to Settings → Webhooks.
- Add an endpoint: your HTTPS URL and the events you want to receive.
- Copy the generated signing secret (
whsec_…) — you'll use it to verify payloads.
WAFlow delivers each event as an HTTP POST to your URL, with retries on failure.
Request format
Each delivery is a JSON POST with these headers:
| Header | Description |
|---|---|
X-Webhook-Event |
The event name, e.g. message.received |
X-Webhook-Signature |
HMAC‑SHA256 signature of the raw body (see Verifying) |
Content-Type |
application/json |
Body:
{
"event": "message.received",
"timestamp": "2026-07-21T09:00:00.000Z",
"data": { }
}
Respond with a 2xx status quickly (within 5 seconds). Non‑2xx responses are retried.
Event catalog
| Event | Fires when |
|---|---|
message.received |
An inbound customer message arrives |
message.sent |
An outbound message was accepted by WhatsApp |
message.delivered |
An outbound message was delivered |
message.read |
An outbound message was read |
message.failed |
An outbound message failed |
campaign.completed |
A campaign finished (with final stats) |
bot.conversation.started / completed / timeout / handed_over / abandoned |
Bot conversation lifecycle |
sla.first_response.at_risk / breached, sla.resolution.at_risk / breached |
SLA monitoring |
message.received
{
"event": "message.received",
"timestamp": "2026-07-21T09:00:00.000Z",
"data": {
"message_id": "wamid.HBgMOTE5...",
"conversation_id": 2304,
"from": "+919547531359",
"contact_id": 88,
"contact_name": "Raj Sharma",
"type": "text",
"text": "Hi, is my order shipped?",
"timestamp": "2026-07-21T09:00:00.000Z"
}
}
Note: WhatsApp/WAFlow may retry deliveries, so
message.receivedis at‑least‑once. De‑duplicate onmessage_id.
message.sent / delivered / read / failed
{
"event": "message.delivered",
"timestamp": "2026-07-21T09:00:03.000Z",
"data": {
"message_id": "wamid.HBgMOTE5...",
"recipient": "+919812345678",
"status": "delivered",
"campaign_id": 128,
"timestamp": "2026-07-21T09:00:03.000Z"
}
}
For message.failed, data.error carries the reason:
{
"event": "message.failed",
"data": {
"message_id": "wamid.HBgMOTE5...",
"recipient": "+919812345678",
"status": "failed",
"error": { "code": 131049, "title": "Re-engagement message", "message": "..." }
}
}
campaign_id is null for messages sent outside a campaign (e.g. via POST /api/v1/messages).
campaign.completed
{
"event": "campaign.completed",
"timestamp": "2026-07-21T09:05:00.000Z",
"data": {
"campaign_id": 128,
"campaignName": "July Promo",
"successCount": 480,
"failureCount": 20,
"totalContacts": 500
}
}
Verifying signatures
WAFlow signs every payload with HMAC‑SHA256 using your endpoint's signing secret, sent in the X-Webhook-Signature header (hex). Always verify it before trusting a request, using the raw request body.
Node.js / Express Example:
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.WAFLOW_WEBHOOK_SECRET; // whsec_...
// Capture the raw body so the signature can be verified.
app.use('/webhooks/waflow', express.raw({ type: 'application/json' }));
app.post('/webhooks/waflow', (req, res) => {
const signature = req.get('X-Webhook-Signature');
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const ok =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body.toString());
console.log(event.event, event.data);
res.sendStatus(200); // acknowledge quickly
});
Note: Compute the HMAC over the exact bytes you received — parsing and re‑serializing the JSON first will change the bytes and break verification. Use a constant‑time comparison (
crypto.timingSafeEqual).
Retries & reliability
- Failed deliveries (non‑
2xx, timeouts, or connection errors) are retried. - Design your handler to be idempotent — key on
data.message_id(messaging events) ordata.campaign_id(campaign events). - Acknowledge with
2xxas soon as you've stored the event; do heavy processing asynchronously so you stay under the timeout.