Gareth Wilson Gareth Wilson

Guide to WAHA Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPer session in config.webhooks on POST /api/sessions/, or globally with WHATSAPP_HOOK_* environment variables
Subscription modelA list of events per webhook. * subscribes to everything, which WAHA advises against in production
Signature headerX-Webhook-Hmac, only when a key is set
SchemeHMAC-SHA512, lowercase hex. X-Webhook-Hmac-Algorithm: sha512 names it
Signed contentThe raw request body only
SecretA key you choose: hmac.key per webhook or WHATSAPP_HOOK_HMAC_KEY globally
Other headersX-Webhook-Request-Id (unique per request) and X-Webhook-Timestamp (Unix milliseconds, not signed), plus any custom headers you configure
Replay windowNone. No timestamp is signed
RetriesConfigurable: constant, linear or exponential (with 20% jitter), with delaySeconds and attempts. Defaults are not documented
Response windowNot documented
Source IPsYour 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.

AreaEventsFires when
Sessionsession.status, engine.eventThe session status changes (STOPPED, STARTING, SCAN_QR_CODE, WORKING, FAILED and others), or a low-level engine event fires for debugging
Messagesmessage, message.any, message.reaction, message.edited, message.revoked, message.waitingAn 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 statusmessage.ack, message.ack.groupA message reaches the server or device, or is read or played (ackName: ERROR, PENDING, SERVER, DEVICE, READ, PLAYED)
Chats and presencechat.archive, presence.updateA chat is archived or unarchived, or a contact's presence changes (online, typing and so on)
Groupsgroup.v2.join, group.v2.leave, group.v2.participants, group.v2.participants.join-request, group.v2.updateYou join or leave a group, participants change, someone asks to join, or group info is updated
Polls and eventspoll.vote, poll.vote.failed, event.response, event.response.failedA poll vote or a response to an event message arrives. The .failed variants mean WAHA received one it couldn't read
Labelslabel.upsert, label.deleted, label.chat.added, label.chat.deletedLabels are created, changed or removed, or applied to or removed from a chat
Callscall.received, call.accepted, call.rejectedA call comes in, is accepted on another device, or is rejected or ends
Deprecatedgroup.join, group.leave, state.changeReplaced 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"
}
  • id is evt_ followed by a lower-case ULID, unique per event.
  • timestamp is in milliseconds, while timestamps inside payload are often in seconds and can be fractional, as above.
  • event is the event name you route on. No header carries it.
  • session is the WAHA session name, and metadata echoes whatever you passed when you started the session.
  • me is your own WhatsApp account, present when the session is authenticated and WORKING.
  • payload is event-specific and can vary by engine. Fields such as _data hold 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 seconds
  • linear: 2, 4, 6, 8 seconds
  • exponential: 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-Hmac signature, 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 customHeaders or WHATSAPP_HOOK_CUSTOM_HEADERS and check it, keeping the URL on HTTPS.

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 retries explicitly, ideally exponential with 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 message over message.any unless 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.