Webhooks
Real-time signed events: the event catalog with full examples, signature verification, retries, redelivery, ordering and idempotency.
Mursilat calls your HTTPS endpoint when something happens: a customer message, a delivery receipt, a button answer, an opt-out, a connection change, a campaign pause. Inbound message content reaches you through the API only this way, live — the API never returns it later (your dashboard inbox keeps it encrypted, for the retention you choose).
Set up
- Account webhook — one URL for every number:
PUT /v2/webhook. Not available to keys restricted to some numbers. - Per-number webhook —
PUT /v2/numbers/{numberId}/webhook, or in the dashboard (Numbers → Developer). - With both, each event is delivered to both with the same event id.
- Public
https://URL on port 443 (or 80). Private addresses are refused, redirects never followed. The signing secret is returned once; rotate it any time.
- PUTSet the account webhook
/v2/webhook - PUTSet the webhook of a number
/v2/numbers/{numberId}/webhook - POSTSend a test event
/v2/webhook/test - POSTRotate the account webhook secret
/v2/webhook/rotate-secret
The request
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Wasla-Webhooks/2
X-Wasla-Event: message.status
X-Wasla-Delivery: dlv_3c1f0a9b8e7d6c5b4a392817
X-Wasla-Webhook-Version: 2
X-Wasla-Signature: t=1760353203,v1=5d2f…(64 hex)
{ "id": "evt_…", "type": "message.status", "apiVersion": "2026-10", "createdAt": "…", "sessionId": "…", "number": "201001234567", "data": { … } }| Field | Meaning |
|---|---|
id | MeaningUnique per event. Retries and redeliveries carry the same id — dedupe on it. |
type | MeaningEvent type (catalog below). |
apiVersion | Meaning2026-10. |
createdAt | MeaningWhen the event happened. |
sessionId / number | MeaningThe number id and its phone digits. |
data | MeaningEvent-specific payload. |
Event catalog
message.received
A customer sent a message to your number. Text, captions and signed media links reach you here, live; the webhook payload isn’t kept after the retry window.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "message.received",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"messageId": "3EB0C1D2E3F4A5B6C7D8",
"from": {
"phone": "201099887766",
"lid": null,
"name": "منى"
},
"chat": {
"type": "direct"
},
"type": "text",
"text": "عايز أعرف السعر",
"caption": null,
"media": null,
"location": null,
"replyTo": null,
"timestamp": "2026-10-13T08:00:02.000Z"
}
}message.status
A message you sent moved to sent, delivered, read or failed (with the error code).
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "message.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "order-1042",
"to": "201099887766",
"status": "delivered",
"error": null,
"timestamp": "2026-10-13T08:00:05.000Z"
}
}interactive.reply
A customer answered buttons or a list — tapped, or typed the number / title of an option (fallback). Only the first valid answer per message.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "interactive.reply",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "order-1042",
"from": {
"phone": "201099887766",
"lid": null,
"name": "منى"
},
"reply": {
"id": "confirm",
"title": "تأكيد",
"index": 1,
"source": "button"
},
"replyMessageId": "3EB0FFEE0011223344",
"timestamp": "2026-10-13T08:03:00.000Z"
}
}poll.vote
Someone voted on a poll your number sent. selectedOptions is their whole current ballot ([] = vote removed).
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "poll.vote",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "survey-7",
"from": {
"phone": "201099887766",
"lid": null,
"name": "منى"
},
"selectedOptions": [
"الأحد"
],
"timestamp": "2026-10-13T08:04:00.000Z"
}
}contact.opted_out
A customer sent an opt-out keyword; they are now on your suppression list and won’t receive campaigns or cold messages.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "contact.opted_out",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"phone": "201099887766",
"lid": null,
"keyword": "إلغاء",
"timestamp": "2026-10-13T08:05:00.000Z"
}
}contact.opted_in
You removed a person from the suppression list (sent to every v2 number of the account).
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "contact.opted_in",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"phone": "201099887766",
"lid": null,
"keyword": null,
"timestamp": "2026-10-13T09:00:00.000Z"
}
}session.status
The linked-device session of a number connected, disconnected, was logged out or banned.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "session.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"status": "disconnected",
"reason": null,
"timestamp": "2026-10-13T10:00:00.000Z"
}
}number.status
Connection state of a number changed: connected, disconnected, pairing, banned, logged_out, conflict.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "number.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"numberId": "cm1abcd2345efgh6789ijkl",
"status": "connected",
"reason": null,
"timestamp": "2026-10-13T08:00:00.000Z"
}
}number.restricted
WhatsApp restricted messaging new contacts from the number (or lifted it). until says when it ends.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "number.restricted",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"numberId": "cm1abcd2345efgh6789ijkl",
"restricted": true,
"until": "2026-10-14T08:00:00.000Z",
"type": "reachout_timelock",
"timestamp": "2026-10-13T08:00:00.000Z"
}
}campaign.status
A campaign started, paused (with the reason), resumed, completed, failed or was cancelled.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "campaign.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"campaignId": "cm2camp0001xyz",
"name": "عرض الجمعة",
"status": "paused",
"reason": "AUTO_LOW_DELIVERY",
"pausedUntil": "2026-10-13T10:00:00.000Z",
"timestamp": "2026-10-13T09:00:00.000Z"
}
}otp.verified
An OTP sent with POST /v2/otp was verified. The code itself is never included.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "otp.verified",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"otpId": "otp_8f2c1d9a7b3e4f56a1b2c3d4",
"to": "2010****4567",
"verifiedAt": "2026-10-13T08:01:00.000Z"
}
}conversation.attributed
A chat started from your website button or a tracked WhatsApp link (page, utm_*, widget / link). Metadata only.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "conversation.attributed",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"conversationId": "cm5conv0001",
"contact": {
"phone": "201099887766",
"lid": null,
"name": "منى"
},
"messageId": "3EB0C1D2E3F4A5B6C7D8",
"source": {
"type": "widget",
"widgetId": "wgt_k2m7q4x9a3b5c8d2",
"linkId": null,
"name": "Main website",
"agent": "sales",
"pagePath": "/products/bag",
"referrerDomain": "facebook.com",
"utm": {
"source": "facebook",
"medium": "cpc",
"campaign": "summer",
"content": null,
"term": null
}
},
"clickedAt": "2026-10-13T07:58:10.000Z",
"timestamp": "2026-10-13T08:00:02.000Z"
}
}webhook.test
Sent by “Send a test event” and the test endpoints. Always delivered, even if not in your event list.
{
"id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
"type": "webhook.test",
"apiVersion": "2026-10",
"createdAt": "2026-10-13T08:00:03.000Z",
"sessionId": "cm1abcd2345efgh6789ijkl",
"number": "201001234567",
"data": {
"message": "This is a test event from Mursilat",
"timestamp": "2026-10-13T08:00:00.000Z"
}
}Verifying the signature
X-Wasla-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Use the raw request body (before JSON parsing), compare in constant time and reject timestamps more than 5 minutes from now.
// Node.js + Express. Use the RAW body: JSON.parse → JSON.stringify changes bytes and breaks the signature.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WASLA_WEBHOOK_SECRET;
const seen = new Set(); // use Redis/DB in production
function verifyWaslaSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header || !secret) return false;
const parts = {};
for (const pair of header.split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
const t = parts.t;
const v1 = parts.v1;
if (!/^\d+$/.test(t || '') || !/^[0-9a-f]{64}$/i.test(v1 || '')) return false;
// Reject old (replayed) or far-future timestamps.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.')
.update(rawBody) // the RAW bytes exactly as received, not re-serialized JSON
.digest();
const received = Buffer.from(v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
app.post('/webhooks/mursilat', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWaslaSignature(req.body, req.get('X-Wasla-Signature'), SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer fast (within 10s); do the work after
if (seen.has(event.id)) return; // retries reuse the same event id
seen.add(event.id);
if (event.type === 'interactive.reply') {
console.log(event.data.reference, '→', event.data.reply.id);
}
});
app.listen(3000);<?php
// Use the RAW body (php://input), never a re-encoded json_decode() result.
function wasla_verify_signature(string $rawBody, string $header, string $secret, int $toleranceSec = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
$kv = explode('=', trim($pair), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($secret === '' || !ctype_digit($t) || !preg_match('/^[0-9a-f]{64}$/i', $v1)) {
return false;
}
// Reject old (replayed) or far-future timestamps.
if (abs(time() - (int) $t) > $toleranceSec) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($v1)); // constant-time
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WASLA_SIGNATURE'] ?? '';
if (!wasla_verify_signature($rawBody, $header, getenv('WASLA_WEBHOOK_SECRET') ?: '')) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
http_response_code(200); // answer fast (within 10s)
// Retries reuse the same event id — store it and skip duplicates.
if ($event['type'] === 'interactive.reply') {
error_log($event['data']['reference'] . ' -> ' . $event['data']['reply']['id']);
}# Python 3 + Flask. Use the RAW body (request.get_data()), not request.json re-encoded.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WASLA_WEBHOOK_SECRET"]
def verify_wasla_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = {}
for pair in (header or "").split(","):
key, sep, value = pair.strip().partition("=")
if sep:
parts[key] = value
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not secret or not t.isdigit() or len(v1) != 64:
return False
# Reject old (replayed) or far-future timestamps.
if abs(time.time() - int(t)) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.lower()) # constant-time
@app.post("/webhooks/mursilat")
def mursilat_webhook():
raw = request.get_data() # bytes exactly as received
if not verify_wasla_signature(raw, request.headers.get("X-Wasla-Signature", ""), SECRET):
abort(401)
event = json.loads(raw)
# Retries reuse the same event id — store it and skip duplicates.
if event["type"] == "interactive.reply":
print(event["data"]["reference"], "->", event["data"]["reply"]["id"])
return "", 200 # answer fast (within 10s)# Recompute a signature by hand (debugging). body.json = the raw request body, byte for byte.
# Header: X-Wasla-Signature: t=1760097603,v1=<hex>
T=1760097603
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$WASLA_WEBHOOK_SECRET" -hex
# → compare the hex digest with v1 (and check that T is within 5 minutes of now)Not sure your code is right? Paste a real request into the payload inspector — it checks the signature and the envelope in your browser.
Retries
- Answer 2xx within 10 seconds, then do the work asynchronously.
- Anything else (timeout, network error, 3xx, 4xx, 5xx) is retried: at once, then after 10 s, 1 min, 5 min, 15 min and 30 min — 6 attempts over about 51 minutes. Then the delivery is
FAILED. 410 Gonemeans “stop”: the webhook is disabled at once and pending deliveries are cancelled. Save the URL again to re-enable it.- At most 4 concurrent requests per URL. Changing or removing the URL cancels queued retries to the old one.
Redelivery window
Mursilat keeps an event body only in memory for the retry window: about an hour after the event, and 10 minutes after a successful delivery. Within that window POST /v2/webhooks/deliveries/{deliveryId}/redeliver sends the same event (same id) again; afterwards it answers 410 DELIVERY_PAYLOAD_EXPIRED — rebuild the state from the API (e.g. GET /v2/messages/{id}).
- GETDelivery log
/v2/webhooks/deliveries - GETOne delivery
/v2/webhooks/deliveries/{deliveryId} - POSTRedeliver (retry window)
/v2/webhooks/deliveries/{deliveryId}/redeliver
Ordering and idempotency
- Ordering is best effort: retries and parallel deliveries can arrive out of order. Use
createdAt/data.timestamp— e.g. ignoredeliveredafter you sawread. - Duplicates are expected (retries, redeliveries, both account and number webhooks). Store
idand skip events you already handled. - The delivery log keeps metadata for 7 days (event, status, HTTP code, timing, error) — never the payload or your response body.