wapi.
Guides

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:

EventFires on
messages.receivedInbound only
messages.upsertEverything, including your own sends
message.sentA message you sent going out
messages.updateDelivery and read receipts
session.statusConnection changes
qrcode.updatedDuring 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

SymptomCause
No dispatch recorded at allThe event is not in webhook_events, or webhook_enabled is false
Dispatch recorded, attempts climbingYour endpoint returned a non-2xx, or timed out
Signature never matchesJSON middleware consumed the body before you hashed it
Handler runs twice for one messageSubscribed to messages.received and a filtered view, or acknowledged too slowly
remoteJid is not a phone numberIt 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.

On this page