Guide to WAHA Webhooks: Features and Best Practices
WAHA webhooks push WhatsApp activity to your own services: a message arrives, a message you sent is delivered or read, someone reacts, joins a group or calls, or a session needs its QR code scanned again. WAHA (WhatsApp HTTP API) is a self-hosted REST API that wraps WhatsApp engines, and webhooks are how it tells your application what happened without you holding a WebSocket open.
This guide covers how WAHA webhooks are configured, how to turn on and verify HMAC signing, the events you can subscribe to and the envelope they share, how retries work, and the best practices for running a WAHA receiver in production.
What are WAHA webhooks?
WAHA runs on your own infrastructure. Each WhatsApp account you connect is a session, and you configure webhooks either per session, in the config.webhooks array when you start the session with POST /api/sessions/, or for every session at once with WHATSAPP_HOOK_* environment variables. Each webhook has a URL, a list of events and optional HMAC, retry and custom header settings.
When a subscribed event happens, WAHA sends a JSON POST to the URL. Every request carries an X-Webhook-Request-Id and an X-Webhook-Timestamp header. Signing is optional: only when you set an HMAC key does WAHA add X-Webhook-Hmac, a hex HMAC-SHA512 of the raw body, and X-Webhook-Hmac-Algorithm, which is sha512. Without a key, nothing in the request proves it came from your WAHA server.
Because WAHA is self-hosted, every installation is its own sender. There is no vendor IP range to allow, and requests come from wherever you run WAHA.
WAHA webhook features
| Feature | Details |
|---|---|
| Configuration | Per session in config.webhooks on POST /api/sessions/, or globally with WHATSAPP_HOOK_* environment variables |
| Subscription model | A list of events per webhook. * subscribes to everything, which WAHA advises against in production |
| Signature header | X-Webhook-Hmac, only when a key is set |
| Scheme | HMAC-SHA512, lowercase hex. X-Webhook-Hmac-Algorithm: sha512 names it |
| Signed content | The raw request body only |
| Secret | A key you choose: hmac.key per webhook or WHATSAPP_HOOK_HMAC_KEY globally |
| Other headers | X-Webhook-Request-Id (unique per request) and X-Webhook-Timestamp (Unix milliseconds, not signed), plus any custom headers you configure |
| Replay window | None. No timestamp is signed |
| Retries | Configurable: constant, linear or exponential (with 20% jitter), with delaySeconds and attempts. Defaults are not documented |
| Response window | Not documented |
| Source IPs | Your own WAHA server |
Common events
WAHA lists 28 current events and three deprecated ones. Which events an engine supports varies, so check WAHA's engine table for the one you run.
| Area | Events | Fires when |
|---|---|---|
| Session | session.status, engine.event | The session status changes (STOPPED, STARTING, SCAN_QR_CODE, WORKING, FAILED and others), or a low-level engine event fires for debugging |
| Messages | message, message.any, message.reaction, message.edited, message.revoked, message.waiting | An incoming message arrives, any message is created including your own, a message gets a reaction, is edited or revoked, or WhatsApp shows "Waiting for this message" |
| Delivery status | message.ack, message.ack.group | A message reaches the server or device, or is read or played (ackName: ERROR, PENDING, SERVER, DEVICE, READ, PLAYED) |
| Chats and presence | chat.archive, presence.update | A chat is archived or unarchived, or a contact's presence changes (online, typing and so on) |
| Groups | group.v2.join, group.v2.leave, group.v2.participants, group.v2.participants.join-request, group.v2.update | You join or leave a group, participants change, someone asks to join, or group info is updated |
| Polls and events | poll.vote, poll.vote.failed, event.response, event.response.failed | A poll vote or a response to an event message arrives. The .failed variants mean WAHA received one it couldn't read |
| Labels | label.upsert, label.deleted, label.chat.added, label.chat.deleted | Labels are created, changed or removed, or applied to or removed from a chat |
| Calls | call.received, call.accepted, call.rejected | A call comes in, is accepted on another device, or is rejected or ends |
| Deprecated | group.join, group.leave, state.change | Replaced by the group.v2.* events and session.status |
Every delivery shares one envelope. Here it is with the payload from WAHA's message.reaction example:
{
"id": "evt_1111111111111111111111111111",
"timestamp": 1741249702485,
"event": "message.reaction",
"session": "default",
"metadata": { "user.id": "123" },
"me": { "id": "79222222222@c.us", "pushName": "WAHA" },
"payload": {
"id": "false_79111111@c.us_11111111111111111111111111111111",
"from": "79111111@c.us",
"fromMe": false,
"timestamp": 1710481111.853,
"reaction": { "text": "🙏", "messageId": "true_79111111@c.us_11111111111111111111111111111111" }
},
"environment": { "tier": "CORE", "version": "2024.3.3" },
"engine": "WEBJS"
}
idisevt_followed by a lower-case ULID, unique per event.timestampis in milliseconds, while timestamps insidepayloadare often in seconds and can be fractional, as above.eventis the event name you route on. No header carries it.sessionis the WAHA session name, andmetadataechoes whatever you passed when you started the session.meis your own WhatsApp account, present when the session is authenticated andWORKING.payloadis event-specific and can vary byengine. Fields such as_datahold internal engine data.
One quirk to plan for: WAHA documents message.ack.group as a separate event, but its published example carries "event": "message.ack". Until you have seen real group acknowledgements on your engine, handle both names in the same branch and tell them apart by the chat ID, which ends in @g.us for groups.
Setting up WAHA webhooks
Choose an HMAC key first. WAHA doesn't generate one, so use a long random string and store it as, for example, WAHA_WEBHOOK_HMAC_KEY on both sides.
To configure a webhook for one session, include it when you start the session:
curl -X POST "http://localhost:3000/api/sessions/" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $WAHA_API_KEY" \
-d '{
"name": "default",
"config": {
"webhooks": [
{
"url": "https://example.com/webhooks/waha",
"events": ["message", "message.ack", "session.status"],
"hmac": { "key": "your-secret-key" },
"retries": { "policy": "exponential", "delaySeconds": 2, "attempts": 10 }
}
]
}
}'
To configure one webhook for every session, set environment variables on the WAHA server instead:
WHATSAPP_HOOK_URL=https://example.com/webhooks/waha
WHATSAPP_HOOK_EVENTS=message,message.ack,session.status
WHATSAPP_HOOK_HMAC_KEY=your-secret-key
WHATSAPP_HOOK_RETRIES_POLICY=exponential
WHATSAPP_HOOK_RETRIES_DELAY_SECONDS=2
WHATSAPP_HOOK_RETRIES_ATTEMPTS=10
A global webhook doesn't appear in session.config when you read the session back with GET /api/sessions/, so document it alongside your deployment.
The retry policies space attempts like this, using the example delay of 2 seconds:
constant: 2, 2, 2, 2 secondslinear: 2, 4, 6, 8 secondsexponential: roughly 2, 4.1, 8.4, 16.3 seconds, with 20% jitter
If you use Hookdeck, create a source with the WAHA source type, set the same HMAC key on the source, and use the Hookdeck source URL as the webhook url. If you don't set a key in WAHA, leave verification off on the Hookdeck source, because unsigned requests would otherwise fail.
Securing WAHA webhooks
Read X-Webhook-Hmac, compute an HMAC-SHA512 of the raw request body with your key as a UTF-8 string, encode it as lowercase hex, and compare the two in constant time. Check that X-Webhook-Hmac-Algorithm is sha512, the only algorithm WAHA documents. If the header is missing, reject the request: it means either signing isn't configured or the request didn't come from WAHA.
const crypto = require('crypto');
const express = require('express');
function verifyWahaSignature(rawBody, signatureHeader, algorithmHeader, key) {
if (!signatureHeader || !key) return false;
if ((algorithmHeader || 'sha512').toLowerCase() !== 'sha512') return false;
const expected = Buffer.from(crypto.createHmac('sha512', key).update(rawBody).digest('hex'), 'utf8');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/waha', express.raw({ type: '*/*' }), (req, res) => {
const valid = verifyWahaSignature(
req.body,
req.get('x-webhook-hmac'),
req.get('x-webhook-hmac-algorithm'),
process.env.WAHA_WEBHOOK_HMAC_KEY
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, route on event.event, then process async.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
def verify_waha_signature(raw_body: bytes, signature_header, algorithm_header, key: str) -> bool:
if not signature_header or not key:
return False
if (algorithm_header or "sha512").lower() != "sha512":
return False
expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha512).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_waha_signature(raw_body, request.headers.get("x-webhook-hmac"),
# request.headers.get("x-webhook-hmac-algorithm"),
# os.environ["WAHA_WEBHOOK_HMAC_KEY"])
WAHA publishes a test vector. With the key my-secret-key and this exact body:
{"event":"message","session":"default","engine":"WEBJS"}
the X-Webhook-Hmac header is:
208f8a55dde9e05519e898b10b89bf0d0b3b0fdf11fdbf09b6b90476301b98d8097c462b2b17a6ce93b6b47a136cf2e78a33a63f6752c2c1631777076153fa89
X-Webhook-Timestamp is not part of the signature, so checking it doesn't protect against replays: an attacker who captures a request can change the timestamp without breaking the HMAC.
Make WAHA webhooks production-ready. Hookdeck Event Gateway verifies the
X-Webhook-Hmacsignature, queues every message, ack and session event durably, and retries delivery to your service on a schedule you control, independent of WAHA's own retry settings.
WAHA webhook limitations and pain points
Signing is off by default
The Problem: Anyone who learns your webhook URL can post fake messages to it.
Why It Happens: WAHA only signs requests when you set hmac.key on the webhook or WHATSAPP_HOOK_HMAC_KEY on the server. Without one, requests carry no signature and nothing else identifies the sender.
Workarounds:
- Always set an HMAC key, and reject any request without
X-Webhook-Hmac. - If you can't, add a secret custom header with
customHeadersorWHATSAPP_and check it, keeping the URL on HTTPS.HOOK_ CUSTOM_ HEADERS
How Hookdeck Can Help: Hookdeck's WAHA source type verifies X-Webhook-Hmac with your key, so unsigned or forged requests are stopped before they reach your service.
Retry defaults are not documented
The Problem: You don't know whether WAHA will retry a failed delivery, how often, or for how long.
Why It Happens: WAHA documents the retry options and an example of 15 constant attempts 2 seconds apart, but not what happens when you don't configure retries. It also doesn't document a response timeout. Even the longest configurations cover seconds to minutes, not a long outage on your side.
Workarounds:
- Always set
retriesexplicitly, ideallyexponentialwith enough attempts to ride out a deploy. - Respond quickly and process asynchronously.
- For critical data, reconcile with WAHA's API after an outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and keeps it, then retries delivery to your service for as long as you configure, and lets you replay any event after an outage.
Payloads differ between engines
The Problem: Code that works on one engine breaks after you switch engines or upgrade WAHA.
Why It Happens: WAHA wraps several WhatsApp engines (WEBJS, WPP, NOWEB, GOWS). The envelope is the same, but payload contents vary, _data holds engine-specific internals, and some events are only supported on some engines. Reactions on NOWEB, for example, moved from message events to message.reaction.
Workarounds:
- Read only the documented fields and ignore
_data. - Use optional access throughout, and log unknown shapes rather than failing.
- Test with the engine you run in production, and re-test after upgrades.
How Hookdeck Can Help: Hookdeck logs every payload as it arrived, so you can compare deliveries before and after an engine change and replay them against fixed code.
* floods your endpoint
The Problem: Your endpoint is overwhelmed with events you don't need.
Why It Happens: * subscribes to every event, including high-volume ones such as message.any, message.ack and presence.update. WAHA itself recommends against it in production.
Workarounds:
- List only the events you handle.
- Prefer
messageovermessage.anyunless you need your own outgoing messages too.
How Hookdeck Can Help: Hookdeck filters can drop the events you don't need, and rate limiting can smooth bursts before they reach your service.
Best practices
Verify against the raw body
The HMAC covers the exact bytes WAHA sent. Read the raw body (express.raw(), await request.body()) and parse only after verification.
Route on event, and on session when you run several
The event name is only in the body. Switch on event, and on session too if one endpoint serves several WhatsApp accounts. Use metadata to attach your own tenant or user IDs when you start a session, so every event arrives with them.
Deduplicate on the envelope id
Retries resend the same event. Store each evt_ ID before processing and skip ones you have seen. See our guide to webhook idempotency.
Mind the units
The envelope timestamp and X-Webhook-Timestamp are milliseconds. Message timestamps inside payload are usually seconds and can carry a fractional part.
Watch session.status
Subscribe to session.status and alert on SCAN_QR_CODE and FAILED. A session that has been logged out stops producing message events, and nothing else tells you.
Acknowledge fast and process asynchronously
Return 200 as soon as the event is verified and stored, and do the real work afterwards. See why to process webhooks asynchronously.
Conclusion
WAHA sends every webhook as one JSON envelope, but it only signs them when you set an HMAC key: then X-Webhook-Hmac carries a hex HMAC-SHA512 of the raw body. Set a key, verify against the raw bytes, route on event and session, configure retries explicitly, and keep your handlers tolerant of the differences between WhatsApp engines.
Hookdeck Event Gateway sits in front of your receiver, checks every WAHA signature, and holds each event until your service has processed it, so a deploy or outage on your side doesn't lose WhatsApp messages.
Get started with Hookdeck for free and handle WAHA webhooks reliably in minutes.