Appearance
Webhooks
Set webhookUrl when creating an app, or later with PATCH /api/apps/:id. The gateway POSTs JSON to it for each event.
Events
| Event | When |
|---|---|
message.received | A message arrives (WhatsApp Status posts are ignored). |
message.status | An outbound message becomes sent, delivered, read or error. |
webhook.test | You called POST /api/apps/:id/webhooks/test. |
message.received
json
{
"deliveryId": 42,
"event": "message.received",
"appId": "app_9f2c...",
"attempt": 1,
"sentAt": "2026-10-01T02:00:00.000Z",
"messageId": "false_15557654321@c.us_3EB0...",
"chatId": "15557654321@c.us",
"from": "15557654321@c.us",
"to": "15551234567@c.us",
"author": null,
"isGroup": false,
"notifyName": "Ada",
"body": "Hi there",
"type": "chat",
"hasMedia": false,
"mimetype": null,
"quotedMessageId": null,
"timestamp": 1727745600
}Reply to chatId. In groups, author is the sender inside the group.
message.status
json
{ "deliveryId": 43, "event": "message.status", "appId": "app_9f2c...", "attempt": 1,
"sentAt": "...", "messageId": "true_15557654321@c.us_3EB0...", "status": "read",
"to": "15557654321@c.us", "timestamp": 1727745660 }Headers
| Header | Value |
|---|---|
x-wa-gateway-app | App id |
x-wa-gateway-event | Event name |
x-wa-gateway-delivery | Delivery id. Use it to de-duplicate. |
x-wa-gateway-signature | sha256=<hex> HMAC of the raw body |
Verify the signature
Sign the raw request body, not re-serialized JSON. Compare in constant time.
js
const crypto = require('crypto');
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body).digest('hex');
const got = req.get('x-wa-gateway-signature') || '';
const ok = got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// handle event...
res.sendStatus(200);
});python
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header or "", "sha256=" + digest)php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_WA_GATEWAY_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}The secret is returned once at app creation. Rotate it with POST /api/apps/:id/webhook-secret; the old one stops working immediately. If an app has no secret, the signature header is omitted.
Delivery and retries
Events are saved to a queue and delivered by a background worker. Answer with any 2xx within WEBHOOK_TIMEOUT_MS (default 8 seconds). Otherwise the gateway retries with exponential backoff: 30s, 1m, 2m and so on up to WEBHOOK_MAX_DELAY_MS, for at most WEBHOOK_MAX_ATTEMPTS (default 10, about 17 hours), then marks the event failed.
Delivery is at least once, so make your handler idempotent using x-wa-gateway-delivery or messageId. Respond fast and do slow work afterwards.
Useful admin calls:
GET /api/apps/:id/webhooks?limit=50lists recent events and counts.POST /api/apps/:id/webhooks/retryrequeues failed events now.POST /api/apps/:id/webhooks/testsends a signedwebhook.test(needs a webhook URL, otherwise409 NO_WEBHOOK).