تخطَّ إلى المحتوى
المحتويات
الإرسال والاستقبال

Webhooks

الـ webhooks هي كيف تعرف كل ما لم تخبرك به الـ API في اللحظة نفسها: رسالة واردة، أو إشعار تسليم، أو إخفاق. وعمليات التسليم موقّعة، وتُعاد بتباعد متزايد، وتُحفظ للفحص، وتقبل إعادة الإرسال.

تسجيل نقطة

GET/v1/webhook-event-typesPOST/v1/webhook-endpointsGET/v1/webhook-endpointsGET/v1/webhook-endpoints/{endpointId}PATCH/v1/webhook-endpoints/{endpointId}DELETE/v1/webhook-endpoints/{endpointId}POST/v1/webhook-endpoints/{endpointId}/rotate-secretPOST/v1/webhook-endpoints/{endpointId}/test

سجّل رابطا وأنواع الأحداث التي تريدها. وتسمية * تشترك في كل شيء، بما يُضاف بعد اشتراكك — وهو غالبا ما تقصده.

curl
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 تعريفية.

HTTP
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 مختلفا عن الرسالة الواردة — فهو يصف انتقالا لا رسالة:

json
{
  "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
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.receiveddocs.pages.webhooks.events.list.message.receivedنعم
message.queueddocs.pages.webhooks.events.list.message.queuedنعم
message.sentdocs.pages.webhooks.events.list.message.sentنعم
message.delivereddocs.pages.webhooks.events.list.message.deliveredنعم
message.readdocs.pages.webhooks.events.list.message.readنعم
message.faileddocs.pages.webhooks.events.list.message.failedنعم
conversation.createddocs.pages.webhooks.events.list.conversation.createdنعم
conversation.assigneddocs.pages.webhooks.events.list.conversation.assignedنعم
conversation.updateddocs.pages.webhooks.events.list.conversation.updatedنعم
conversation.closeddocs.pages.webhooks.events.list.conversation.closedنعم
conversation.reopeneddocs.pages.webhooks.events.list.conversation.reopenedنعم
conversation.note_addeddocs.pages.webhooks.events.list.conversation.note_addedنعم
channel.createddocs.pages.webhooks.events.list.channel.createdنعم
channel.connecteddocs.pages.webhooks.events.list.channel.connectedنعم
channel.disconnecteddocs.pages.webhooks.events.list.channel.disconnectedنعم
channel.requires_repairdocs.pages.webhooks.events.list.channel.requires_repairنعم
channel.archiveddocs.pages.webhooks.events.list.channel.archivedنعم
contact.createddocs.pages.webhooks.events.list.contact.createdنعم
contact.updateddocs.pages.webhooks.events.list.contact.updatedنعم
contact.deleteddocs.pages.webhooks.events.list.contact.deletedنعم
contact.import_completeddocs.pages.webhooks.events.list.contact.import_completedنعم
template.createddocs.pages.webhooks.events.list.template.createdنعم
template.updateddocs.pages.webhooks.events.list.template.updatedنعم
template.archiveddocs.pages.webhooks.events.list.template.archivedنعم
campaign.starteddocs.pages.webhooks.events.list.campaign.startedليس بعد
campaign.pauseddocs.pages.webhooks.events.list.campaign.pausedليس بعد
campaign.resumeddocs.pages.webhooks.events.list.campaign.resumedليس بعد
campaign.completeddocs.pages.webhooks.events.list.campaign.completedليس بعد
campaign.cancelleddocs.pages.webhooks.events.list.campaign.cancelledليس بعد
campaign.faileddocs.pages.webhooks.events.list.campaign.failedليس بعد
automation.faileddocs.pages.webhooks.events.list.automation.failedليس بعد

الأنواع الموسومة بأنها لا تُرسل بعد يمكن الاشتراك فيها وتجتاز التحقق؛ لكن لا شيء في المنصة يصدرها. وقد سُردت بدل إخفائها حتى لا يضطر اشتراك يُكتب اليوم إلى التغيّر حين تبدأ بالوصول.

إعادة المحاولة والتباعد

لكل تسليم 6 محاولات إجمالا — محاولة أولى وخمس إعادات — بمهلة 10 ثوان لكل منها. والتأخير عشوائي كامل، حتى لا تعود مجموعة تسليمات مخفقة دفعة واحدة.

التأخير قبل المحاولة التالية، بالإعداد الافتراضي.
بعد المحاولةالمحاولة التالية بعد
15–10s
210–20s
320–40s
440–80s
580–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.

الفحص وإعادة الإرسال

GET/v1/webhook-endpoints/{endpointId}/deliveriesGET/v1/webhook-deliveriesGET/v1/webhook-deliveries/{deliveryId}POST/v1/webhook-deliveries/{deliveryId}/replay

كل تسليم مسجّل بمحاولاته وحالة استجابته وسبب إخفاقه. والحالات هي pending, delivering, succeeded, failed, dead، وتُحفظ التسليمات 30 يوما.

curl
# 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 لتطلب إعادة محاولة. راجع الأخطاء والحدود لشكل أخطاء المنصة نفسها.