Skip to content

Webhooks ​

Set webhookUrl when creating an app, or later with PATCH /api/apps/:id. The gateway POSTs JSON to it for each event.

Events ​

EventWhen
message.receivedA message arrives (WhatsApp Status posts are ignored).
message.statusAn outbound message becomes sent, delivered, read or error.
webhook.testYou 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 ​

HeaderValue
x-wa-gateway-appApp id
x-wa-gateway-eventEvent name
x-wa-gateway-deliveryDelivery id. Use it to de-duplicate.
x-wa-gateway-signaturesha256=<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=50 lists recent events and counts.
  • POST /api/apps/:id/webhooks/retry requeues failed events now.
  • POST /api/apps/:id/webhooks/test sends a signed webhook.test (needs a webhook URL, otherwise 409 NO_WEBHOOK).

Released under the Apache-2.0 License.