WAAPI Webhook Setup
Receive incoming messages, delivery status and template events on your own server. Add a webhook URL once — WAAPI handles the verification, dedup and signing for you.
Reference
A WAAPI webhook is an outbound HTTP POST that WAAPI sends to your server every time something happens on a WhatsApp number you own — a customer messages you, a message you sent is delivered or read, a template is approved, and so on.
You don't need to talk to Meta directly: WAAPI already receives Meta's raw webhooks, verifies them, deduplicates them, persists the data and then forwards a clean, signed event to your endpoint.
Configure from the dashboard
Setup → Webhooks per WhatsApp number. Each entry is scoped to one account, has its own secret, headers, filters and schedule.Resources
Download references
Architecture
How it works
WhatsApp user
│ sends / reads / replies
▼
Meta Cloud API
│ delivers to WAAPI (handled for you)
▼
WAAPI Backend
│ persists, dedupes, fans out to active webhooks
▼
Your server
│ POST {your url} + X-Webhook-Signature: <hmac-sha256>
│ + any custom headers you configured
▼
Respond 2xx
(failures bump a counter — no automatic retries)
A single inbound event can fan out to multiple webhooks — for example, you can keep one webhook that mirrors every event into your data lake and a second one filtered to fire only on messages that start with ORDER.
Setup
Create a webhook
- Open
Setup → Webhooksin the WAAPI dashboard. - Pick the WhatsApp number this webhook belongs to.
- Enter a friendly name and the public URL on your server.
- Choose which events should trigger the webhook (or pick
all). - (Optional) Set a secret — WAAPI will sign every payload with it.
- (Optional) Add custom headers, message content filters and an active schedule.
- Save — your webhook starts firing immediately on the next matching event.
Full configuration shape
{
"name": "Order Bot",
"url": "https://api.yourapp.com/waapi/incoming",
"events": ["message.received"],
"secret": "whk_8f3c...",
"headers": [
{ "key": "X-Tenant", "value": "acme" }
],
"message_filters": [
{ "type": "starts_with", "value": "ORDER" }
],
"filter_logic": "all",
"schedule": {
"type": "weekly",
"days": [1, 2, 3, 4, 5],
"start_time": "09:00",
"end_time": "18:00"
},
"schedule_timezone": "Asia/Kolkata",
"is_active": true
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Friendly name (max 100 chars) |
url | string | Yes | Public HTTPS endpoint on your server |
events | string[] | Yes | One or more event types (see below) or ["all"] |
secret | string | No | Used to sign every payload (X-Webhook-Signature). Strongly recommended. |
headers | array | No | Custom headers added to every outbound request |
message_filters | array | No | Text-content filters (only applied for message.received) |
filter_logic | string | No | any (default) or all |
schedule | object | No | Time-of-day activation rules — default { type: "always" } |
schedule_timezone | string | No | IANA tz for schedule (default: Asia/Kolkata) |
is_active | boolean | No | Toggle without deleting (default: true) |
Catalog
Event types
| Event | Fires when |
|---|---|
message.received | A customer sends a new message to your business number |
message.sent | A message your account sends (via API or dashboard) is accepted by Meta |
message.status | Delivery / read receipt — sent → delivered → read → failed |
template.approved | A template you submitted is approved by Meta |
template.rejected | A template is rejected |
template.pending | A template moves into review |
account.update | Phone-number or WABA-level policy / quality changes |
all | Wildcard — match every supported event |
Schema
Event payloads
Every webhook body is JSON. The event field tells you which event you're receiving — branch on it.
message.received
{
"event": "message.received",
"timestamp": "2026-03-05T10:30:00.000Z",
"phone_number_id": "PHONE_NUMBER_ID",
"display_phone_number": "+15551234567",
"from": "+919876543210",
"customer_name": "Jane Doe",
"wa_message_id": "wamid.HBgL...",
"type": "text",
"text": "Hello, I need help with my order",
"media_url": null,
"media_id": null
}message.sent
{
"event": "message.sent",
"message_id": "6789abcdef1234567890abcd",
"wa_message_id": "wamid.HBgL...",
"conversation_id": "6789abcdef1234567890aaaa",
"to": "+919876543210",
"type": "text",
"status": "sent",
"timestamp": "2026-03-05T10:30:00.000Z"
}message.status
{
"event": "message.status",
"wa_message_id": "wamid.HBgL...",
"status": "delivered",
"recipient": "+919876543210",
"timestamp": "2026-03-05T10:30:04.000Z"
}template.approved / rejected / pending
{
"event": "template.approved",
"template_name": "order_confirmation",
"language": "en_US",
"category": "UTILITY",
"timestamp": "2026-03-05T10:30:00.000Z"
}account.update
{
"event": "account.update",
"phone_number_id": "PHONE_NUMBER_ID",
"display_phone_number": "+15551234567",
"field": "quality_update",
"value": { "quality_score": "GREEN" },
"timestamp": "2026-03-05T10:30:00.000Z"
}Routing
Message content filters
When the event is message.received, WAAPI evaluates filters against the incoming text before firing. This lets you wire one webhook per intent — orders, support, OTPs — without parsing on your side. Filters do not apply to other event types.
| Filter type | Matches when text… |
|---|---|
contains | contains the value (case-insensitive) |
not_contains | does NOT contain the value |
equals | is exactly the value (case-insensitive) |
not_equals | is anything other than the value |
starts_with | starts with the value |
ends_with | ends with the value |
Combine multiple filters with filter_logic: any (fire if at least one matches) or all (fire only when every filter matches).
{
"message_filters": [
{ "type": "starts_with", "value": "ORDER" },
{ "type": "not_contains", "value": "test" }
],
"filter_logic": "all"
}Time control
Active schedule
Limit when a webhook fires — useful for routing business-hours traffic to one endpoint and after-hours to another. Times are evaluated in schedule_timezone (default Asia/Kolkata).
| schedule.type | Behavior | Shape |
|---|---|---|
always | Fires whenever the event matches (default) | { "type": "always" } |
datetime | Fires only inside the given start/end windows | { "type": "datetime", "ranges": [{ "start": "2026-03-05T09:00", "end": "2026-03-05T18:00" }] } |
weekly | Fires on the given weekdays, within start_time / end_time | { "type": "weekly", "days": [1,2,3,4,5], "start_time": "09:00", "end_time": "18:00" } |
special | Holiday / promo overrides on specific dates | { "type": "special", "entries": [{ "date": "2026-12-25", "start_time": "00:00", "end_time": "23:59", "label": "Christmas" }] } |
Date.getDay() convention: 0 = Sunday … 6 = Saturday.Customization
Custom headers
Each webhook can carry arbitrary key/value headers on every request — handy for static tenant ids, internal bearer tokens, or a custom signature your gateway expects.
{
"headers": [
{ "key": "X-Tenant", "value": "acme" },
{ "key": "Authorization", "value": "Bearer your_internal_token" }
]
}Content-Type here — WAAPI always sends application/json.Security
Signing & verification
When you set a secret on the webhook, WAAPI signs every body with HMAC-SHA256 and sends the hex digest in X-Webhook-Signature. Verify it on your side before trusting the payload.
import crypto from 'crypto';
import express from 'express';
const app = express();
app.post('/waapi/incoming', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const expected = crypto
.createHmac('sha256', process.env.WAAPI_WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expected) return res.sendStatus(401);
// Branch on event type
switch (req.body.event) {
case 'message.received': /* … */ break;
case 'message.status': /* … */ break;
case 'template.approved': /* … */ break;
}
res.sendStatus(200);
});Other languages
import hmac, hashlib, os
def verify(body_bytes, signature):
expected = hmac.new(
os.environ['WAAPI_WEBHOOK_SECRET'].encode(),
body_bytes,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
function verify($body, $signature) {
$ expected = hash_hmac('sha256', $body, getenv('WAAPI_WEBHOOK_SECRET'));
return hash_equals($expected, $signature);
}
Use the raw bytes you received
createHmac.Operations
Delivery & retries
| Property | Value |
|---|---|
Method | POST |
Content-Type | application/json |
Signature header | X-Webhook-Signature (only when a secret is configured) |
Timeout | 10 seconds per request |
Success | Any 2xx response — failure_count is reset to 0 |
Failure | Non-2xx or timeout — failure_count is incremented |
Automatic retry | None — WAAPI does not retry. Use a queue on your side if you need durability. |
Auto-disable | Webhooks repeatedly failing should be reviewed; check failure_count in the dashboard. |
Idempotency on your side
wa_message_id or message_id as your deduplication key.Operations
Troubleshooting
| Symptom | Most likely cause |
|---|---|
Webhook is not firing | is_active is off, the event isn't subscribed, or the schedule is outside the current time |
Fires for some messages only | A message_filters rule is rejecting the rest — try filter_logic: "any" |
401 on your server | Signature mismatch — make sure you're hashing the raw body bytes with the exact secret |
Timeout / repeated 5xx | Your handler is too slow — return 2xx first, then process async |
Missing events | Add the event type, or use "all" as a wildcard |
Duplicate processing | Dedupe on wa_message_id / message_id on your side |
Plan limit hit | Webhook creation is plan-gated (per-account count + monthly creation cap). Check Setup → Billing. |
Tip — test from the playground