Webhooks
A webhook sends a session's events to your server as they happen: incoming messages, delivery receipts, status changes and more. Each session has its own webhook URL, event list and signing secret.
Set up
On the session's page open Webhook, enter your URL and tick the events you want. Or use a personal access token:
curl -X PUT https://api.warsita.com/v1/sessions/ses_3kV9mQ2pX7aL0zR5tN8w/webhook \
-H "Authorization: Bearer $WARSITA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/warsita/webhook", "events": ["messages.received", "message-receipt.update", "session.status"]}'
The URL must be public and use https. The answer includes the signing secret (whsec_…); rotate it with POST /v1/sessions/{id}/webhook/secret. "url": "" turns the webhook off.
To try your endpoint, send a signed sample of any event with POST /v1/sessions/{id}/webhook/test and {"event": "messages.received"}, or use the simulator on the Webhook tab.
Requests
Every event is a POST with a JSON body:
{
"event": "messages.received",
"session_id": "ses_3kV9mQ2pX7aL0zR5tN8w",
"timestamp": "2026-10-27T10:00:00.123Z",
"data": { "…": "…" }
}
and these headers:
| Header | Value |
|---|---|
X-Warsita-Event |
The event name. |
X-Warsita-Delivery |
The delivery id (whd_…), the same on every retry. Use it to ignore duplicates. |
X-Warsita-Signature |
t=<unix seconds>,v1=<signature> |
Answer with any 2xx within 10 seconds. Anything else, or no answer, is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours. The session's delivery log, GET /v1/sessions/{id}/webhook/deliveries, shows the last 50 deliveries with their status code and error.
Verify the signature
v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the webhook secret. Check it against the raw bytes before parsing the JSON, and reject old timestamps so a captured request cannot be replayed.
Node.js (Express):
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/warsita/webhook", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries(req.get("X-Warsita-Signature").split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.WARSITA_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// handle event.event and event.data
res.sendStatus(200);
});
Python:
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + body, hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts.get("v1", ""))
PHP:
function warsita_verify(string $secret, string $body, string $header): bool {
parse_str(str_replace(",", "&", $header), $parts);
$expected = hash_hmac("sha256", $parts["t"] . "." . $body, $secret);
return abs(time() - (int) $parts["t"]) < 300 && hash_equals($expected, $parts["v1"] ?? "");
}
$ok = warsita_verify(getenv("WARSITA_WEBHOOK_SECRET"), file_get_contents("php://input"), $_SERVER["HTTP_X_WARSITA_SIGNATURE"]);
Events
| Event | When |
|---|---|
messages.received |
A message arrived in any chat. |
messages-personal.received |
A message arrived in a personal chat. |
messages-group.received |
A message arrived in a group. |
messages-newsletter.received |
A channel the number follows posted. |
message.sent |
A message you sent through the API reached WhatsApp. |
message-receipt.update |
Messages were delivered or read. |
messages.update |
Someone edited a message. |
messages.delete |
Someone deleted a message for everyone. |
messages.reaction |
Someone reacted to a message. |
session.status |
The session connected, disconnected, was logged out, banned or paused. |
qrcode.updated |
A new pairing QR code is ready. |
call |
Someone is calling the number. |
groups.update |
A group's name, description or settings changed. |
group-participants.update |
Members were added, removed, promoted or demoted. |
messages.received covers every chat, and the three chat-specific events fire as well, so subscribe to one or the other. Messages sent from the number itself are not reported as received. The session settings ignore_groups, ignore_broadcasts and ignore_channels drop those chats' messages before they reach you.
Incoming message
data of the messages…received events:
{
"id": "3EB0C431C26A1916E2A3",
"chat": "6281234567890@s.whatsapp.net",
"chat_type": "personal",
"sender": "6281234567890@s.whatsapp.net",
"sender_phone": "+6281234567890",
"push_name": "Rina",
"timestamp": "2026-10-27T10:00:00Z",
"type": "image",
"media": { "mimetype": "image/jpeg", "file_length": 48213, "caption": "My receipt" },
"quoted_id": "3EB0A1B2C3D4E5F6A7B8"
}
| Field | |
|---|---|
chat, chat_type |
Where it was sent: personal, group, newsletter or broadcast. |
sender, sender_phone, push_name |
Who sent it. sender may be a privacy id (…@lid); sender_phone is their number when WhatsApp shares it, in groups too. |
type |
text, image, video, audio, document, sticker, location, contact or other. |
text |
The text of a text message. |
media |
For media: mimetype, file_length, caption, filename, seconds, ptt. The file itself is not included. |
location, contact |
For locations and contact cards. |
mentions, quoted_id, quoted_sender, is_view_once |
Mentioned JIDs, the message it replies to, and whether it is view-once. |
The API Reference lists the data of every other event.