Webhooks
الـ webhooks هي كيف تعرف كل ما لم تخبرك به الـ API في اللحظة نفسها: رسالة واردة، أو إشعار تسليم، أو إخفاق. وعمليات التسليم موقّعة، وتُعاد بتباعد متزايد، وتُحفظ للفحص، وتقبل إعادة الإرسال.
تسجيل نقطة
سجّل رابطا وأنواع الأحداث التي تريدها. وتسمية * تشترك في كل شيء، بما يُضاف بعد اشتراكك — وهو غالبا ما تقصده.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-endpoints \
-H "Authorization: Bearer $WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/whatsapp",
"event_types": ["message.received", "message.delivered", "message.failed"],
"description": "Order service"
}'
# 201 Created — "secret" appears in THIS response and nowhere else.
# {
# "id": "wh_...",
# "url": "https://example.com/hooks/whatsapp",
# "event_types": ["message.received", "message.delivered", "message.failed"],
# "status": "active",
# "secret_last4": "4c1e",
# "secret": "..."
# }
# Subscribe to everything instead — including event types added later:
# "event_types": ["*"]- إدارة النقاط تتطلب صلاحية
webhooks.manage، وهي ليست ضمن المجموعة الافتراضية لأي API key. اطلبها صراحة عند إنشاء المفتاح. - الرابط لا بد أن يكون مطلقا، وبلا بيانات اعتماد فيه، وعلى المنفذ 80 أو 443، وألا يشير إلى عنوان خاص. والرفض يعطي 422 وسببه في
details.reason. - النقطة إما
activeوإماpaused؛ ولا يمكنك ضبطها علىdisabled، لأن ذلك شيء تفعله المنصة بك. - نقطة الاختبار ترسل تسليما حقيقيا عبر المسار الحقيقي، موسوما بـ
is_test. ولا تُرسل رسالة إلى أحد.
كيف يبدو التسليم
طلب POST بجسم JSON وستة headers تعريفية.
POST /hooks/whatsapp HTTP/1.1
content-type: application/json
x-webhook-event-id: evt_...
x-webhook-delivery-id: whd_...
x-webhook-event: message.received
x-webhook-timestamp: 1786608000
x-webhook-attempt: 1
x-webhook-signature: v1=8f4c...
{
"id": "evt_...",
"type": "message.received",
"created_at": "2026-08-13T10:00:00.000Z",
"workspace_id": "ws_...",
"data": {
"object": "message",
"object_id": "msg_...",
"message_id": "msg_...",
"conversation_id": "cnv_...",
"contact_id": "cnt_...",
"channel_id": "ch_...",
"direction": "inbound",
"type": "text"
}
}الـ Headers
| Header | الوصف |
|---|---|
| X-Webhook-Event-Id | معرّف الحدث. ثابت عبر كل إعادة محاولة وكل إعادة إرسال — وهو ما تزيل التكرار عليه. |
| X-Webhook-Delivery-Id | صف هذه المحاولة. يتغيّر عند إعادة الإرسال. |
| X-Webhook-Event | نوع الحدث، وهو موجود أيضا باسم type في الجسم. |
| X-Webhook-Timestamp | ثوان بتوقيت يونكس. وجزء من الحمولة الموقّعة. |
| X-Webhook-Attempt | رقم المحاولة، ابتداء من 1. |
| X-Webhook-Signature | مدخل واحد أو أكثر بصيغة v1=. انظر أدناه. |
الغلاف
| الحقل | الوصف |
|---|---|
| id | معرّف الحدث، مطابق للـ header. أزل التكرار عليه. |
| type | نوع الحدث. |
| created_at | وقت وقوع الحدث، لا وقت تسليمه. |
| workspace_id | مساحة العمل التي ينتمي إليها الحدث. |
| data.object | نوع الشيء الذي يتحدث عنه الحدث، مثل message. |
| data.object_id | معرّفه العام. |
حدث الحالة يحمل شكل data مختلفا عن الرسالة الواردة — فهو يصف انتقالا لا رسالة:
{
"id": "evt_...",
"type": "message.delivered",
"created_at": "2026-08-13T10:00:02.980Z",
"workspace_id": "ws_...",
"data": {
"object": "message",
"object_id": "msg_...",
"message_id": "msg_...",
"from_state": "sent",
"to_state": "delivered",
"category": "utility"
}
}
// On message.failed, "data" also carries:
// "failure_class", "error_code" and "reason" when the dispatcher set them.التحقق من التوقيع
كل تسليم موقّع بـ HMAC-SHA256 على {timestamp}.{body}، ويُقدَّم بصيغة v1=<hex> بأحرف ست عشرية صغيرة.
X-Webhook-Timestamp: 1786608000
X-Webhook-Signature: v1=8f4c…
signed_payload = "1786608000." + raw_request_body
signature = HMAC_SHA256(secret, signed_payload) # lowercase hex- وقّع على
البايتات الخامالتي استقبلتها. تحليل الـ JSON ثم إعادة تسلسله يغيّر ترتيب المفاتيح والمسافات، ولن يطابق التوقيع. - ارفض طابعا زمنيا يبعد أكثر من
300ثانية عن الآن في أي من الاتجاهين. وهذا ما يمنع إعادة تسليم مُلتقط عليك لاحقا. - قارن بزمن ثابت. فالمقارنة النصية الساذجة تسرّب التوقيع بايتا بايتا.
- قد يحمل الـ header أكثر من مدخل. اقبل التسليم إن طابق أيٌّ من أسرارك النشطة أيَّ مدخل.
التحقق كاملا
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* @param rawBody the EXACT bytes received. Do not re-serialise the parsed
* JSON — key order and whitespace are part of the signature.
*/
export function verify(rawBody, headers, secrets) {
const timestamp = Number(headers["x-webhook-timestamp"]);
if (!Number.isFinite(timestamp)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const signed = `${timestamp}.${rawBody}`;
// The header may carry SEVERAL "v1=<hex>" entries during a secret
// rotation. Accept the delivery if ANY of your secrets matches ANY entry.
const presented = String(headers["x-webhook-signature"] ?? "")
.split(",")
.map((part) => part.trim())
.filter((part) => part.startsWith("v1="))
.map((part) => part.slice(3));
return secrets.some((secret) => {
const expected = crypto
.createHmac("sha256", secret)
.update(signed, "utf8")
.digest("hex");
return presented.some(
(candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)),
);
});
}تدوير السر
التدوير يعيد سرا جديدا ويُبقي السابق يوقّع لنافذة تداخل مدتها 24 ساعة. وخلالها يحمل كل تسليم مدخلي v1= — الجديد أولا — فتنشر السر الجديد دون أن يسقط تسليم واحد.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-endpoints/wh_.../rotate-secret \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK — the new secret, once.
# {
# "id": "wh_...",
# "secret": "...",
# "secret_last4": "b217",
# "secret_rotated_at": "2026-08-13T10:00:00.000Z",
# "previous_secret_expires_at": "2026-08-14T10:00:00.000Z"
# }
# Until previous_secret_expires_at, deliveries are signed with BOTH secrets
# and x-webhook-signature carries two entries:
# x-webhook-signature: v1=<new>,v1=<old>دليل الأحداث
عدد الأنواع 31. وهذه القائمة كاملة، مقروءة من دليل المنصة نفسه؛ والقائمة نفسها يقدّمها GET /v1/webhook-event-types.
حدث الوارد اسمه message.received
وليس message.inbound، والاشتراك في نوع غير موجود خطأ تحقق لا تجاهل صامت. فإن لم يصلك شيء فتحقق من الاسم أولا.
| الحدث | متى يحدث | يُرسل اليوم |
|---|---|---|
| message.received | docs.pages.webhooks.events.list.message.received | نعم |
| message.queued | docs.pages.webhooks.events.list.message.queued | نعم |
| message.sent | docs.pages.webhooks.events.list.message.sent | نعم |
| message.delivered | docs.pages.webhooks.events.list.message.delivered | نعم |
| message.read | docs.pages.webhooks.events.list.message.read | نعم |
| message.failed | docs.pages.webhooks.events.list.message.failed | نعم |
| conversation.created | docs.pages.webhooks.events.list.conversation.created | نعم |
| conversation.assigned | docs.pages.webhooks.events.list.conversation.assigned | نعم |
| conversation.updated | docs.pages.webhooks.events.list.conversation.updated | نعم |
| conversation.closed | docs.pages.webhooks.events.list.conversation.closed | نعم |
| conversation.reopened | docs.pages.webhooks.events.list.conversation.reopened | نعم |
| conversation.note_added | docs.pages.webhooks.events.list.conversation.note_added | نعم |
| channel.created | docs.pages.webhooks.events.list.channel.created | نعم |
| channel.connected | docs.pages.webhooks.events.list.channel.connected | نعم |
| channel.disconnected | docs.pages.webhooks.events.list.channel.disconnected | نعم |
| channel.requires_repair | docs.pages.webhooks.events.list.channel.requires_repair | نعم |
| channel.archived | docs.pages.webhooks.events.list.channel.archived | نعم |
| contact.created | docs.pages.webhooks.events.list.contact.created | نعم |
| contact.updated | docs.pages.webhooks.events.list.contact.updated | نعم |
| contact.deleted | docs.pages.webhooks.events.list.contact.deleted | نعم |
| contact.import_completed | docs.pages.webhooks.events.list.contact.import_completed | نعم |
| template.created | docs.pages.webhooks.events.list.template.created | نعم |
| template.updated | docs.pages.webhooks.events.list.template.updated | نعم |
| template.archived | docs.pages.webhooks.events.list.template.archived | نعم |
| campaign.started | docs.pages.webhooks.events.list.campaign.started | ليس بعد |
| campaign.paused | docs.pages.webhooks.events.list.campaign.paused | ليس بعد |
| campaign.resumed | docs.pages.webhooks.events.list.campaign.resumed | ليس بعد |
| campaign.completed | docs.pages.webhooks.events.list.campaign.completed | ليس بعد |
| campaign.cancelled | docs.pages.webhooks.events.list.campaign.cancelled | ليس بعد |
| campaign.failed | docs.pages.webhooks.events.list.campaign.failed | ليس بعد |
| automation.failed | docs.pages.webhooks.events.list.automation.failed | ليس بعد |
الأنواع الموسومة بأنها لا تُرسل بعد يمكن الاشتراك فيها وتجتاز التحقق؛ لكن لا شيء في المنصة يصدرها. وقد سُردت بدل إخفائها حتى لا يضطر اشتراك يُكتب اليوم إلى التغيّر حين تبدأ بالوصول.
إعادة المحاولة والتباعد
لكل تسليم 6 محاولات إجمالا — محاولة أولى وخمس إعادات — بمهلة 10 ثوان لكل منها. والتأخير عشوائي كامل، حتى لا تعود مجموعة تسليمات مخفقة دفعة واحدة.
| بعد المحاولة | المحاولة التالية بعد |
|---|---|
| 1 | 5–10s |
| 2 | 10–20s |
| 3 | 20–40s |
| 4 | 40–80s |
| 5 | 80–160s |
ما يُعدّ إخفاقا
| الاستجابة | يُعاد المحاولة | failure_reason |
|---|---|---|
| 2xx | لا | ok |
| 3xx | لا | redirect |
| 429 | نعم | rate_limited |
| 4xx | لا | client_error |
| 5xx | نعم | server_error |
| خطأ اتصال أو DNS أو TLS | نعم | network_error |
| لا استجابة خلال المهلة | نعم | timeout |
حين تنفد المحاولات ينتهي التسليم إلى dead إن كان آخر إخفاق قابلا لإعادة المحاولة، وإلى failed إن لم يكن. ولا تُتبَع التحويلات أبدا — أعد 2xx من الرابط الذي سجّلته.
الإخفاق المتكرر يعطّل النقطة
بعد 20 محاولة مخفقة متتالية تُعطَّل النقطة وتتوقف عن استقبال أي شيء. ونجاح واحد يصفّر العدّاد. ولإعادتها، أصلح النقطة واضبط حالتها على active — وهو ما يصفّر العدّاد في الاستدعاء نفسه.
أسباب الإخفاق
قيمة failure_reason للتسليم واحدة من ok, server_error, rate_limited, redirect, client_error, network_error, timeout, blocked_target, not_deliverable.
الفحص وإعادة الإرسال
كل تسليم مسجّل بمحاولاته وحالة استجابته وسبب إخفاقه. والحالات هي pending, delivering, succeeded, failed, dead، وتُحفظ التسليمات 30 يوما.
# List what went out, and why it failed.
curl "https://whats.azzamkh.sa/api/v1/webhook-endpoints/wh_.../deliveries?status=failed" \
-H "Authorization: Bearer $WA_API_KEY"
# {
# "data": [{
# "id": "whd_...",
# "event_id": "evt_...",
# "event_type": "message.received",
# "status": "dead",
# "attempt_count": 6,
# "max_attempts": 6,
# "response_status": 500,
# "failure_reason": "server_error",
# "latency_ms": 812,
# "replay_count": 0
# }],
# "page": { "next_cursor": null, "has_more": false }
# }
# Send the SAME stored bytes again. The replay resets the delivery row;
# it does not create a new event.
curl -X POST https://whats.azzamkh.sa/api/v1/webhook-deliveries/whd_.../replay \
-H "Authorization: Bearer $WA_API_KEY"
# 202 Acceptedإعادة الإرسال هي الحدث نفسه لا حدث جديد
إعادة الإرسال تبعث الغلاف المخزَّن حرفا بحرف وتصفّر الصف نفسه — ومعرّف الحدث لا يتغيّر، فالمستهلك الصحيح الذي يزيل التكرار عليه سيتعرّف على إعادة عالجها من قبل.
كتابة مستقبِل
- ردّ بسرعة بأي 2xx. وأنجز العمل بعدها: فالمعالج البطيء يتحول إلى مهلة منتهية وإعادة محاولة.
- أزل التكرار على
معرّف الحدث. فإعادات المحاولة والإرسال تكرره حتما، والتسليم مرة واحدة على الأقل يعني أنك ستراه يوما. - لا تفترض ترتيبا. فقد يصل حدث delivered قبل حدث sent الذي يسبقه.
- تجاهل أنواع الأحداث التي لا تعرفها بدل الإخفاق عليها. فالأنواع تُضاف، والاشتراك الشامل سيبدأ باستقبالها.
- أعد 4xx فقط حين يكون التسليم غير مقبول فعلا — فلن يُعاد. وأعد 5xx لتطلب إعادة محاولة. راجع الأخطاء والحدود لشكل أخطاء المنصة نفسها.