Developers
Webhooks: be told when something happens
WaSMS calls your own server when a customer messages you, a lead form is filled in or a campaign finishes.
Updated
On this page
With a webhook, your server does not have to keep asking WaSMS for news. You give WaSMS an address on your server, and WaSMS sends a request to it the moment something happens.
1. Add your address
- In the dashboard menu, open Developers, Webhooks. Only the owner or an admin of the organization can.
- Click Add endpoint and enter a public
https://address on your server. - Tick the events you want.
- Save. Your signing secret is shown once: copy it and keep it on your server. If you lose it, make a new one with New secret.
- Click Send test event. The page tells you whether your server answered.
You can have up to 10 addresses.
Events
message.received— a customer sent you a message on WhatsApp, SMS, email or web chat.message.sent— a message from your organization was recorded in a conversation: a reply from the dashboard, an AI reply, a campaign message, or an SMS or email sent through the API.message.status— a sent message changed status: delivered or read for WhatsApp; sent, delivered or failed for SMS.form.submitted— someone filled in one of your lead forms.campaign.completed— a campaign finished sending.
Delivered and read on WhatsApp
For a WhatsApp message, message.status arrives when the message is delivered and again when it is read: at most two events per message, sometimes only the read one, and the two can arrive in either order. The message in the event carries delivered_at and read_at; a value is null until it is known. What each one means:
- Delivered — WhatsApp reported that the message reached the recipient's phone. You can rely on it.
- Read — the recipient opened the chat and has read receipts switched on in their own WhatsApp. Someone who switched them off never produces read. If your own WhatsApp number has read receipts off, you get no read for anyone. So a message that stays at delivered is not proof that it was not read.
- A voice message that was played counts as read.
- Groups are not tracked. A message sent to a group stays at sent.
- Only messages sent through WaSMS are tracked, from the day this was introduced, and a status is accepted for 30 days after the message was sent.
- If you run the WaSMS app on your own computer, it needs version 1.5.0 or newer to report these.
For a message your software sent through the API, match the event to your own record with data.message.id, which is the id the send answered. One more thing to know: message.sent is not sent for a WhatsApp message your own software sends through the API; the API's reply to that request is your confirmation.
2. What your server receives
A POST request with a JSON body. The outer part is the same for every event; data depends on the event. This is a message from a customer:
{
"id": "6a0f3c0e-2a55-4d1b-b6a5-0f5a8f0d9c11",
"type": "message.received",
"created_at": "2026-10-11T09:15:02Z",
"organization_id": "b3c1f0a2-5d1e-4f0b-8a43-2f7f3b1e9d77",
"data": {
"message": {
"id": "c0a8012e-7d1b-4c7e-9a52-1f0e2d3c4b5a",
"conversation_id": "9b2d6c1a-3e4f-4a5b-8c7d-6e5f4a3b2c1d",
"direction": "inbound",
"channel": "whatsapp",
"status": "delivered",
"content": "Hi, is my order ready?",
"media_url": null,
"media_type": null,
"sender_name": "Sara Ahmed",
"provider_message_id": "3EB0C767D26A1B2E4F5A",
"created_at": "2026-10-11T09:15:01Z"
},
"contact": { "id": "4f1e2d3c-5b6a-4789-8c0d-1e2f3a4b5c6d", "name": "Sara Ahmed", "phone": "447911123456", "email": null },
"account": { "id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "phone": "447900000001" }
}
}Useful headers on every request:
X-WaSMS-Event— the event, the same astype.X-WaSMS-Event-Id— the same asid. It stays the same when WaSMS tries again, so use it to ignore a repeat.X-WaSMS-Attempt— 1 for the first try, up to 5.X-WaSMS-Signature— proof the request is from WaSMS (next step).
3. Check the signature
Every request is signed with your signing secret. Check the signature before you trust a request, using the body exactly as it arrived, before any parsing.
Node.js
const crypto = require("crypto");
function verifyWasms(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(parts.t + "." + rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: the raw body is needed, so do not let express.json() parse first.
app.post("/hooks/wasms", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWasms(req.body.toString("utf8"), req.get("X-WaSMS-Signature") || "", process.env.WASMS_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // answer first
handle(event); // then work
});Python
import hmac, hashlib, time
def verify_wasms(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)PHP
function verify_wasms(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $p) { [$k, $v] = array_pad(explode('=', $p, 2), 2, ''); $parts[$k] = $v; }
if (empty($parts['t']) || empty($parts['v1']) || abs(time() - (int)$parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input');
if (!verify_wasms($raw, $_SERVER['HTTP_X_WASMS_SIGNATURE'] ?? '', getenv('WASMS_WEBHOOK_SECRET'))) { http_response_code(401); exit; }The header looks like t=1760174102,v1=5d2b0f.... v1 is HMAC-SHA256, in lower-case hex, of t, a dot and the raw body, with your signing secret as the key. Refuse a request whose t is more than five minutes old.
4. Answer quickly
- Answer with any
2xxstatus within 10 seconds, then do the slow work. - The same event can arrive more than once. Keep the
idof events you have handled and skip a repeat. - Events can arrive out of order when an earlier one is being retried. Use
created_at. - A redirect counts as a failure; give the final address.
When your server does not answer
WaSMS tries again: 1 minute later, then 5 minutes, 30 minutes and 2 hours after each failure, five attempts in all. After that the delivery is marked failed, and you can send it again yourself from Recent deliveries on the Webhooks page, where every attempt and its result is listed for 30 days.
If an address keeps failing for 3 days, WaSMS switches it off and says so on the page. Fix your server, send a test event, and switch it on again.