Webhooks
Receiving as it happens — two signature schemes, and how to verify either.
Set a webhook URL on a session and wapi POSTs events to it, retrying up to five times with exponential backoff. Configure it under Settings on the session, or with the API.
Configure
curl -X PUT https://api.wapi.crafter.run/api/whatsapp-sessions/1 \
-H "Authorization: Bearer $PAT" \
-H 'Content-Type: application/json' \
-d '{"webhook_url":"https://your.app/hook",
"webhook_enabled":true,
"webhook_events":["messages.received","session.status"]}'An empty webhook_events array means "send everything", which is not the same as an absent
one meaning nothing.
Note this takes a Personal Access Token, not a session key — it configures a session rather than acting through one.
Two signature schemes
By default X-Webhook-Signature carries the webhook secret itself, and you compare strings.
That is WasenderAPI's scheme, reproduced so their clients work unchanged.
Turning on HMAC in Settings switches the header to HMAC-SHA256 over the raw request body. Prefer it: it proves the payload was not altered, and it never puts the secret on the wire.
HMAC is a wapi addition
It is not part of the cloned interface, so it is not a field on the sessions endpoint. Turn it on under Settings for the session in the dashboard.
Receive and verify
// Accepts either scheme, so enabling HMAC later needs no redeploy.
// Read the RAW body: express.json() consumes the stream, leaving
// nothing to compute a hash over.
app.post("/hook", express.raw({ type: "*/*" }), (req, res) => {
const secret = process.env.WAPI_WEBHOOK_SECRET;
const sent = req.headers["x-webhook-signature"];
const hmac = crypto.createHmac("sha256", secret)
.update(req.body).digest("hex");
if (sent !== secret && sent !== hmac) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body);
if (event === "messages.received") {
console.log(data.key.remoteJid, data.message?.conversation);
}
// Acknowledge fast; do the work asynchronously.
res.json({ received: true });
});Two things in there are the whole lesson. Read the raw body — any JSON middleware consumes the stream and leaves you nothing to hash. And acknowledge fast: wapi retries on a non-2xx, so slow handlers turn into duplicate deliveries.
The payload
{
"event": "messages.received",
"sessionId": 1,
"timestamp": 1787537909,
"data": {
"key": { "id": "3EB0...", "remoteJid": "46274715893950@lid",
"remoteJidAlt": "51999888777@s.whatsapp.net", "fromMe": false },
"message": { "conversation": "hello" },
"pushName": "Ada"
}
}Three things in there are worth knowing before you write the handler.
remoteJid is often a LID rather than a phone number, with the phone number in remoteJidAlt
where WhatsApp supplied one. Code that assumes a phone number will break on real traffic — see
LIDs.
message is WhatsApp's own node, so a text message is message.conversation while a media
message is message.imageMessage (or videoMessage, documentMessage, …) and carries no
readable text at all. Reach for ?. rather than assuming a shape.
key is what you pass to react and
read receipts. Inbound messages have no msgId,
because wapi never assigned one — the key is the only handle you get.
Which events to subscribe to
There are twenty-two. The ones that matter in practice:
| Event | Fires on |
|---|---|
messages.received | Inbound only |
messages.upsert | Everything, including your own sends |
message.sent | A message you sent going out |
messages.update | Delivery and read receipts |
session.status | Connection changes |
qrcode.updated | During pairing |
messages-personal, messages-group and messages-newsletter are filtered views of
messages.received rather than separate events, so subscribe to one of those if you only care
about a single chat kind — and do not subscribe to both, or you will handle each message twice.
Inspecting deliveries
GET /api/dispatches returns what wapi tried to send, with status and attempt count — the first
place to look when your handler is not being called. A dispatch that shows attempts and failures
is a problem at your end; no dispatch at all means the event never matched
webhook_events, or the webhook is disabled.
Media in a payload arrives encrypted
An inbound imageMessage carries a CDN link whose bytes are ciphertext. Pass the node to
/api/decrypt-media — see Media.
What goes wrong
| Symptom | Cause |
|---|---|
| No dispatch recorded at all | The event is not in webhook_events, or webhook_enabled is false |
| Dispatch recorded, attempts climbing | Your endpoint returned a non-2xx, or timed out |
| Signature never matches | JSON middleware consumed the body before you hashed it |
| Handler runs twice for one message | Subscribed to messages.received and a filtered view, or acknowledged too slowly |
remoteJid is not a phone number | It is a LID. Expected — read remoteJidAlt |
wapi retries up to five times with exponential backoff, then gives up. A dispatch that exhausted its attempts is not redelivered later, so a handler that was down for an hour has genuinely missed those messages.
Proving your handler works
You do not need a real conversation, or a second phone. A sandbox can be made to receive a message, which fires a genuine, signed delivery at your endpoint:
wapi sandbox inbound "hello from a fake human"That is the shortest path from "I have a webhook handler" to "I have watched it run" — see Testing webhooks.
Operations covered