الويب هوك
أحداث لحظية موقّعة: قائمة الأحداث بأمثلة كاملة، والتحقق من التوقيع، والمحاولات، وإعادة الإرسال، والترتيب ومنع التكرار.
مرسلات بتكلم عنوان HTTPS بتاعك لما حاجة تحصل: رسالة من عميل، إيصال تسليم، رد على زرار، إلغاء اشتراك، تغيير في الاتصال، إيقاف حملة. محتوى الرسايل الواردة بيوصلك من الـ API بالطريقة دي بس، لحظيًا — والـ API مش بيرجّعه بعد كده (صندوق الوارد في لوحة التحكم بيحتفظ بيه مشفّر للمدة اللي تختارها).
الإعداد
- ويب هوك الحساب — رابط واحد لكل الأرقام:
PUT /v2/webhook. مش متاح للمفاتيح المقصورة على أرقام. - ويب هوك لكل رقم —
PUT /v2/numbers/{numberId}/webhook، أو من لوحة التحكم (الأرقام ← المطوّر). - لو الاتنين موجودين، كل حدث بيوصل للاتنين بـ نفس id الحدث.
- رابط
https://عام على بورت 443 (أو 80). العناوين الخاصة مرفوضة والـ redirects مش بتتتبع. سر التوقيع بيرجع مرة واحدة؛ وتقدر تغيّره في أي وقت.
- PUTضبط ويب هوك الحساب
/v2/webhook - PUTضبط ويب هوك الرقم
/v2/numbers/{numberId}/webhook - POSTإرسال حدث تجريبي
/v2/webhook/test - POSTتغيير مفتاح ويب هوك الحساب
/v2/webhook/rotate-secret
شكل الطلب
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": { … } }| الحقل | المعنى |
|---|---|
id | المعنىفريد لكل حدث. المحاولات وإعادة الإرسال بنفس الـ id — امنع التكرار بيه. |
type | المعنىنوع الحدث (القائمة تحت). |
apiVersion | المعنى2026-10. |
createdAt | المعنىالحدث حصل إمتى. |
sessionId / number | المعنىid الرقم وأرقام تليفونه. |
data | المعنىبيانات الحدث نفسه. |
قائمة الأحداث
message.received
عميل بعت رسالة لرقمك. النص والتعليقات وروابط الميديا الموقّعة بتوصلك هنا لحظيًا؛ ومحتوى الويب هوك مش بيتحفظ بعد فترة المحاولات.
{
"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
رسالة بعتها اتغيرت حالتها لـ sent أو delivered أو read أو failed (ومعاها كود الخطأ).
{
"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
عميل رد على أزرار أو قائمة — داس، أو كتب رقم/اسم الاختيار (البديل بالأرقام). أول رد صحيح بس لكل رسالة.
{
"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
حد صوّت في استطلاع بعته رقمك. selectedOptions هي اختياراته الحالية كلها ([] = شال صوته).
{
"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
عميل بعت كلمة إلغاء الاشتراك؛ بقى في قائمة الحظر ومش هتوصله حملات أو رسايل باردة.
{
"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
شلت شخص من قائمة الحظر (بيتبعت لكل أرقام الحساب على v2).
{
"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
جلسة الجهاز المربوط للرقم اتوصلت أو فصلت أو اتعمل لها خروج أو اتحظرت.
{
"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
حالة اتصال الرقم اتغيرت: 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
واتساب قفل (أو فتح) إرسال الرقم لجهات اتصال جديدة. until بيقول إمتى بيخلص.
{
"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
حملة بدأت أو وقفت (ومعاها السبب) أو كمّلت أو خلصت أو فشلت أو اتلغت.
{
"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
كود OTP اتبعت بـ POST /v2/otp اتأكد. الكود نفسه عمره ما بيتبعت.
{
"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
بيتبعت من «ابعت حدث تجريبي» ومن endpoints الاختبار. بيوصل دايمًا حتى لو مش في قائمة أحداثك.
{
"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"
}
}التحقق من التوقيع
X-Wasla-Signature: t=<ثواني unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. استخدم الـ body الخام (قبل تحويل الـ JSON)، وقارن بوقت ثابت، وارفض أي طابع زمني بعيد عن دلوقتي بأكتر من 5 دقايق.
// 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)مش متأكد إن كودك صح؟ الصق طلب حقيقي في فاحص الويب هوك — بيتحقق من التوقيع والغلاف جوه المتصفح.
المحاولات
- رد بـ 2xx خلال 10 ثواني، وبعدين اعمل الشغل في الخلفية.
- أي حاجة تانية (timeout، خطأ شبكة، 3xx، 4xx، 5xx) بتتكرر: فورًا، وبعد 10 ثواني، ودقيقة، و5، و15، و30 دقيقة — 6 محاولات في حوالي 51 دقيقة. بعدها التوصيل بيبقى
FAILED. 410 Goneمعناها «بطّل»: الويب هوك بيتقفل فورًا والتوصيلات المستنية بتتلغي. احفظ الرابط تاني عشان يرجع.- بالكتير 4 طلبات في نفس الوقت لكل رابط. تغيير الرابط أو مسحه بيلغي المحاولات المستنية للقديم.
فترة إعادة الإرسال
مرسلات بتحتفظ بمحتوى الحدث في الذاكرة بس طول فترة المحاولات: حوالي ساعة بعد الحدث، و10 دقايق بعد التوصيل الناجح. جوه الفترة دي POST /v2/webhooks/deliveries/{deliveryId}/redeliver بيبعت نفس الحدث (بنفس الـ id) تاني؛ بعدها بيرد 410 DELIVERY_PAYLOAD_EXPIRED — ابني الحالة من الـ API (مثلًا GET /v2/messages/{id}).
- GETسجل التسليم
/v2/webhooks/deliveries - GETتسليم واحد
/v2/webhooks/deliveries/{deliveryId} - POSTإعادة الإرسال (خلال فترة المحاولات)
/v2/webhooks/deliveries/{deliveryId}/redeliver
الترتيب ومنع التكرار
- الترتيب مش مضمون: المحاولات والتوصيلات المتوازية ممكن توصل مش بالترتيب. استخدم
createdAt/data.timestamp— مثلًا تجاهلdeliveredبعد ما شفتread. - التكرار متوقع (محاولات، إعادة إرسال، ويب هوك الحساب والرقم مع بعض). خزّن الـ
idوتخطى اللي اتعامل معاه قبل كده. - سجل التوصيل بيحتفظ ببيانات وصفية 7 أيام (الحدث، الحالة، كود HTTP، المدة، الخطأ) — عمره ما بيحتفظ بالمحتوى أو برد سيرفرك.