الويب هوك والأحداث
الحدث هو شيء جرى على أحد أرقامك: شخص كتب إليك، أو رسالة أرسلتها وصلت، أو رسالة جماعية توقفت مؤقتًا. نرسل كل حدث إلى رابط الاستقبال لديك بصيغة JSON، موقّعًا.
استقبل أول حدث
- حدّد الرابط. في لوحة التحكم، افتح للمطورين ← الويب هوك (للمسؤولين فقط) واضغط «تحديد رابط الويب هوك». يجب أن يكون عنوانًا عامًّا يبدأ بـ
https://. - انسخ سر التوقيع. يبدأ بـ
whsec_، ويظهر عند حفظ الرابط. لتراه مرة أخرى، اضغط «سر التوقيع ← عرض سر التوقيع» في الصفحة نفسها. - شغّل خادم استقبال يصل إليه عنوان
https://الخاص بك. هذا المثال كامل: يقرأ جسم الطلب الخام، ويتحقق من التوقيع، ويرد بـ200فورًا، ويتجاهل المكرر. ويستمع على المنفذ 3000.
// Node.js 18 or later, no packages. Run: THREADWIRE_WEBHOOK_SECRET=whsec_… node endpoint.mjs
import crypto from 'node:crypto';
import http from 'node:http';
const key = Buffer.from(process.env.THREADWIRE_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const handled = new Set(); // in production: your database or cache
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
// The raw body, exactly as it arrived: the signature covers these bytes.
const body = Buffer.concat(chunks);
const id = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
// 1. Verify: sent in the last 5 minutes, and signed with your secret.
const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.`).update(body).digest('base64'));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const signed = (req.headers['webhook-signature'] ?? '').split(' ').some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!fresh || !signed) {
res.writeHead(401).end();
return;
}
// 2. Answer 200 at once, before any slow work.
res.writeHead(200).end();
// 3. Drop repeats: an event sent again keeps its webhook-id.
if (handled.has(id)) return;
handled.add(id);
const event = JSON.parse(body);
console.log(event.type);
if (event.type === 'message.received') {
console.log(`${event.data.phone}: ${event.data.body}`);
}
});
}).listen(3000);
- اضغط «إرسال حدث تجريبي» في صفحة الويب هوك. يُرسل حدث
webhook.testإلى رابط الاستقبال لديك، وتعرض القائمة في تلك الصفحة كيف ردّ عليه. ثم اكتب إلى رقمك من هاتف آخر: يصلك حدثmessage.received.
ليس لديك رابط عام بعد؟ اقرأ الأحداث نفسها عبر GET /v1/events: راجع الجلب.
لكل حدث هذا الشكل:
{
"id": "evt_01K…",
"type": "message.received",
"created_at": "2026-10-01T10:00:00+00:00",
"data": { "…": "the message, as in GET /v1/messages/{id}" }
}
ردّ بأي رمز 2xx خلال 15 ثانية. هذا كل ما تحتاجه لتبدأ؛ وبقية هذه الصفحة هي التفاصيل.
الأحداث
| الحدث | متى | data |
|---|---|---|
message.received |
كتب شخص إلى أحد أرقامك | الرسالة |
message.sent_from_phone |
كتب شخص رسالة على الهاتف المربوط نفسه، لا عبر الـ API | الرسالة، وفيها origin بقيمة phone |
message.status |
رسالة أرسلتها أُرسلت أو وصلت أو قُرئت أو فشلت (أو أُلغيت قبل إرسالها: failed، مع error وerror_code الخاصين بذلك) |
الرسالة |
message.edited |
عُدّلت رسالة على واتساب: عدّلها الطرف الآخر، أو عُدّلت من الهاتف أو عبر الـ API | الرسالة، مع body الجديد وedited_at |
message.deleted |
حُذفت رسالة لدى الجميع | الرسالة، مع deleted_at وبلا محتوى |
message.poll_vote |
صوّت شخص على استطلاع أرسلته، أو غيّر تصويته، أو سحبه | الاستطلاع، مع results وvotes، ومعه vote: phone وselected_options (فارغة عند سحب التصويت) |
message.reaction |
تفاعل شخص مع رسالة برمز تعبيري، أو غيّره، أو سحبه | الرسالة، مع reactions، ومعها reacted: phone (من تفاعل؛ ومعرّفه الخاص على واتساب حين يخفي واتساب الرقم)، وemoji (null عند السحب) وat. وفي المجموعات، فقط حين تستقبل group.message أيضًا |
group.message |
كتب شخص في مجموعة ينتمي إليها أحد أرقامك (أو كتب فريقك فيها من الهاتف) | الرسالة: chat_id هو المجموعة، وparticipant هو من كتبها |
group.update |
انضم رقمك إلى مجموعة أو غادرها، أو انضم أشخاص أو غادروا أو صاروا مشرفين، أو تغيّر اسمها أو إعداداتها | instance_id وchat_id. وchange (joined أو left لرقمك؛ وjoin أو leave أو promote أو demote لـ participants؛ وupdated). وparticipants (لكل منهم id، وهو معرّف الشخص على واتساب لا رقم هاتف، وrole). وsubject وdescription وat. الحقل الذي ليس فيه ما يُذكر يُحذف |
call.received |
اتصل شخص بأحد أرقامك على واتساب | id وinstance_id وchat_id وphone وis_video وat، وrejected (true حين يرفض الرقم المكالمات) |
call.rejected |
رُفضت مكالمة (رفضتها أنت، أو إعداد الرقم، أو الهاتف) | id وinstance_id وchat_id وphone وis_video وat |
instance.status |
رُبط رقم، أو فقد ربطه، أو تغيّرت حالته | id وexternal_ref وstatus وphone |
otp.completed |
اكتمل تحقق OTP: وصل الرمز من الشخص (رابط)، أو أُدخل صحيحًا (رمز) | id وinstance_id وchannel وreference وphone (من أتمّ التحقق؛ null إن أخفاه واتساب) وverified_at |
otp.failed |
فشل تحقق OTP: تعذّر إرسال رمزه (مع السبب)، أو أُدخلت رموز خاطئة كثيرة | id وinstance_id وchannel وreference وphone وreason وfailed_at |
broadcast.paused |
توقفت رسالة جماعية مؤقتًا: أوقفتها أنت، أو توقفت من تلقاء نفسها لحماية الرقم (الرسائل الجماعية) | id وinstance_id وname وstatus وreason (code وtext) وprogress وstarted_at وcompleted_at؛ ولا يُرسل نصها ولا رقم أي شخص أبدًا |
broadcast.completed |
أُرسلت الرسالة الجماعية إلى كل من فيها | مثل broadcast.paused، مع reason بقيمة null |
webhook.test |
ضغطت «إرسال حدث تجريبي» في لوحة التحكم | message |
تختار الأحداث التي تستقبلها من لوحة التحكم (للمطورين ← الويب هوك ← الأحداث، للمسؤولين فقط). هي قائمة واحدة لحسابك كله: تنطبق على كل رابط ويب هوك، وعلى GET /v1/events. تصلك افتراضيًا كل الأحداث ما عدا group.message وgroup.update، وهذان تحددهما لتستقبلهما؛ والحدث التجريبي يُرسل دائمًا.
في الرسالة: contact_name هو الاسم الذي يظهره الشخص على واتساب (null إن لم يكن له اسم). written_at هو وقت كتابة الرسالة المستلمة، أو المكتوبة على الهاتف، كما يذكره واتساب. وcreated_at هو وقت حفظنا لها، ويتأخر حتى 10 ثوانٍ للرسالة المكتوبة على الهاتف. written_at قيمته null في الرسائل التي ترسلها، ولها sent_at. origin يكون api (أرسلتها أنت)، أو auto (رد تلقائي أعددته في لوحة التحكم)، أو ai (ردّ وكيل الذكاء الاصطناعي لديك). ويكون panel (ردّ فريقك من صفحة المحادثات في اللوحة)، أو phone (كُتبت على الهاتف)، أو otp (رمز OTP بأرقام محجوبة، أو تأكيد رابط). ويكون broadcast (رسالة شخص واحد من رسالة جماعية) أو contact (كتبها الطرف الآخر). الردود التلقائية وردود الذكاء الاصطناعي وردود اللوحة ترسل أحداث message.status مثل أي رسالة ترسلها. الملصقات والاستطلاعات والمواقع وبطاقات جهات الاتصال المستلمة تصل وفيها type بقيمة sticker أو poll أو location أو contact (البطاقة الأولى حين يشارك الشخص عدة بطاقات). وقد يغيب name أو phone في البطاقة المستلمة. وphone هو الرقم كما كُتب في البطاقة حين لا يكون لها رقم واتساب، فقد يكون محليًا.
الملفات في الرسائل المستلمة تجدها في data.media.url، وهو رابط يعمل 24 ساعة. الرسالة التي معها ملف تُرسل حين يصبح الملف عندنا. لذلك قد تصل بعد أحداث عن رسائل لاحقة، أو عن الرسالة نفسها (إشعار استلام، أو تعديل، أو حذف). نزّل ما تريد الاحتفاظ به: تُحذف الرسائل والملفات بعد 30 يومًا. الملف الأكبر من 100 ميجابايت لا يُحفظ: تصل رسالته وفيها media بقيمة null وerror_code بقيمة file_too_large.
تحقق أن الحدث جاء منا
تُوقَّع الأحداث وفق مواصفة Standard Webhooks، فأي مكتبة من مكتباتها تتحقق منها في استدعاء واحد بسرّك whsec_…. في كل طلب الترويسات webhook-id وwebhook-timestamp وwebhook-signature. والأخيرة قيمة واحدة أو أكثر تفصل بينها مسافات. كل قيمة هي v1, يتبعها HMAC-SHA256 بترميز base64 للنص {webhook-id}.{webhook-timestamp}.{body}. ومفتاحه هو الجزء الذي يلي whsec_ من سرّك، بعد فك ترميز base64 عنه. اقبل الحدث إذا طابقت أي قيمة منها. استخدم جسم الطلب الخام كما وصل تمامًا، قبل أي تحليل لـ JSON:
// PHP: $id, $timestamp and $signatures are the webhook-id, webhook-timestamp and
// webhook-signature headers ($_SERVER['HTTP_WEBHOOK_ID'] ?? '' and so on); $body
// is file_get_contents('php://input').
function threadwireEventIsGenuine(string $id, string $timestamp, string $signatures, string $body, string $secret): bool
{
// Reject old or future timestamps: a replayed request is not a new event.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// Node.js: headers as lower-case names, rawBody as a string (in Express, express.text({ type: '*/*' })).
import crypto from 'node:crypto';
function isGenuine(headers, rawBody, secret) {
// Reject old or future timestamps: a replayed request is not a new event.
if (Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
.update(`${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`)
.digest('base64'));
return (headers['webhook-signature'] ?? '').split(' ').some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
# Python: headers as lower-case names, raw_body as bytes (in Flask, request.get_data()).
import base64, hashlib, hmac, time
def is_genuine(headers: dict, raw_body: bytes, secret: str) -> bool:
# Reject old or future timestamps: a replayed request is not a new event.
if abs(time.time() - int(headers['webhook-timestamp'])) > 300:
return False
key = base64.b64decode(secret.removeprefix('whsec_'))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
expected = 'v1,' + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in headers.get('webhook-signature', '').split(' '))
تُوقَّع كل محاولة وقت إرسالها، فإعادة المحاولة بعد ساعات تجتاز فحص الـ 5 دقائق أيضًا.
بعد أن تنشئ سر توقيع جديدًا، تحمل الأحداث توقيعين لمدة 24 ساعة: توقيع لكل سر، تفصل بينهما مسافة كما تسمح المواصفة. وهكذا يواصل نظامك التحقق وأنت تنقله إلى السر الجديد.
ليتحقق رابط الاستقبال لديك أيضًا من مفتاح خاص بك (بوابة API مثلًا)، حدّد ترويسة مصادقة من صفحة الويب هوك («ترويسة المصادقة»). يُرسل اسمها وقيمتها مع كل حدث إلى ذلك الرابط، بجانب التوقيع. الرقم الذي له رابط خاص له ترويسته الخاصة، وتُحدَّد من صفحته. نقل الرابط إلى نطاق آخر، أو إزالته، يزيل ترويسته.
التسليم
- كل طلب يعرّف بنفسه:
User-Agent: Threadwire-Webhooks/1 (+https://threadwire.net/docs/guides/webhooks). استخدمه لتجد طلباتنا في سجلاتك أو لتسمح بمرورها من جدار الحماية، لا كدليل أبدًا: التوقيع وحده يثبت أن الحدث جاء منا. - التسليم يعني أي رد
2xxخلال 15 ثانية. وكل ما عدا ذلك محاولة فاشلة: رمز آخر، أو لا رد في الوقت المحدد، أو تحويل. لا نتبع التحويلات: أعطنا الرابط النهائي. - إعادة المحاولة. نحاول إرسال الحدث 10 مرات إجمالًا: مرة أخرى بعد 10 ثوانٍ، ثم بعد دقيقة واحدة (1) و5 دقائق و30 دقيقة وساعة واحدة (1) وساعتين (2) و4 ساعات و6 ساعات و12 ساعة. أي ما يزيد قليلًا على 25 ساعة من المحاولة الأولى إلى الأخيرة. الحدث الذي استنفد كل محاولاته يظهر بحالة «فشلت» في صفحة الويب هوك، وهناك «إرسال مرة أخرى» يرسله مرة إضافية. الحدث التجريبي يُحاوَل مرة واحدة.
- حدثان في المرة الواحدة. نرسل حدثين على الأكثر في الوقت نفسه إلى كل رابط ويب هوك؛ والبقية تنتظر دورها.
- تجاهل المكرر اعتمادًا على
webhook-id. الحدث الذي يُرسل مرة أخرى (في إعادة محاولة، أو بطلب منك) يحتفظ بمعرّفه. قد تصل الأحداث بغير ترتيبها: استخدمcreated_atوأوقات الرسالة نفسها. - إذا لم يقبل رابط الاستقبال لديك أي شيء لمدة 24 ساعة، يتوقف الويب هوك إليه مؤقتًا، ويصل المسؤولين لديك بريد إلكتروني. تُحفظ الأحداث الجديدة (حتى 30 يومًا)، وتُرسل كلها، الأقدم أولًا، فور أن يرد من جديد. نجربه كل ساعة، و«جرّب الآن» في صفحة الويب هوك يجربه فورًا.
رابط لكل رقم
يمكن أن يكون للرقم رابط ويب هوك خاص به («الويب هوك» في تبويب «الإعدادات» الخاص به في لوحة التحكم). عندها تذهب أحداثه إلى هناك بدلًا من رابط حسابك، موقّعة بسر whsec_… الخاص بذلك الرقم. الأحداث التي اخترتها تنطبق عليه كما تنطبق على الحساب. ترويسة المصادقة الخاصة به مستقلة (تُحدَّد بجانب رابطه)، وترويسة حسابك لا تُرسل إليه أبدًا. فالمفتاح المخصص لنظام لا يصل أبدًا إلى نظام آخر. لكل رابط إعادة محاولاته وتوقفه واستئنافه بمفرده، فتعطّل نظام لا يؤخر أبدًا أحداث نظام آخر. أزله، فتعود أحداث الرقم، ومنها المحفوظة، إلى رابط حسابك.
عبر الـ API
النظام الذي ينشئ أرقامه عبر الـ API يستطيع ضبط الويب هوك لها بالطريقة نفسها، بمفتاح وصول كامل للحساب كله. /v1/webhook هو الويب هوك الخاص بحسابك؛ و/v1/instances/{id}/webhook هو الخاص برقم بعينه، وله الطلبات الخمسة نفسها. المفتاح المُنشأ لرقم واحد يقرأ الويب هوك الخاص بذلك الرقم، ولا يغيّر أي ويب هوك.
| الطلب | ماذا يفعل |
|---|---|
GET /v1/webhook |
يعيد url الخاص به، وevents المرسلة، واسم auth_header، وهل هو paused ولماذا (paused_reason)، وعدد الأحداث المحفوظة له (events_kept). ولا يعيد السر أبدًا، ولا قيمة الترويسة |
PATCH /v1/webhook |
أرسل ما يتغير فقط: url (القيمة null توقف الإرسال)، وevents (القيمة null للمجموعة الافتراضية؛ وفي الويب هوك الخاص بالحساب فقط)، وauth_header (name وvalue الخاصان بها، أو null لإزالتها). 6 تغييرات في الدقيقة على الأكثر لكل حساب |
POST /v1/webhook/secret |
سر توقيع جديد، يظهر في الرد مرة واحدة. يواصل السر القديم التوقيع حتى previous_valid_until، أي بعد 24 ساعة؛ وحتى ذلك الحين يُرد على أي استبدال ثانٍ بـ 409 |
POST /v1/webhook/test |
يرسل حدث webhook.test، مرة واحدة في الدقيقة على الأكثر |
POST /v1/webhook/try |
يجرب فورًا ويب هوك متوقفًا مؤقتًا، مرة واحدة في الدقيقة على الأكثر: إذا رد، تُرسل الأحداث المحفوظة |
curl -X PATCH https://threadwire.net/api/v1/webhook \
-H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://your-system.example/webhooks/whatsapp", "events": ["message.received", "message.status"]}'
متى تحصل على السر. لا يظهر secret في الرد إلا حين يُنشأ للتو: في طلب PATCH الذي يحدد url لويب هوك لم يكن له سر بعد، وفي POST …/secret. احفظه حينها: لا يعيده أي طلب لاحق، وإن ظلت لوحة التحكم تعرضه تحت «سر التوقيع». الحساب الذي أعددناه لك له سر بالفعل، فأول url له يُرد عليه بلا سر: استبدله مرة واحدة لتعرفه. أرسل Idempotency-Key مع POST …/secret. فإن ضاع الرد، يُرد على إعادة المحاولة بالمفتاح نفسه بـ 200 بلا السر (مع الترويسة Idempotent-Replayed: true). ولا يُستبدل السر مرة أخرى، فذلك كان سيوقف السر الذي ما زال نظامك يتحقق به. القواعد هي قواعد صفحة الويب هوك: عنوان https على الإنترنت العام، وترويسة ليست من ترويساتنا. يُقبل الطلب كاملًا أو يُرفض كاملًا. يظهر كل تغيير في سجل النشاط لديك مع المفتاح الذي أجراه.
الجلب بدلًا من الويب هوك: GET /v1/events
هل نظامك لا يستطيع استقبال الويب هوك (لأنه خلف جدار حماية، أو لأن n8n أو Make بلا عنوان عام)؟ أو فاتته أحداث وهو متوقف؟ اجلب الأحداث نفسها عبر GET /v1/events. تُحفظ 30 يومًا سواء حددت رابط ويب هوك أم لا: وهي الأحداث التي اخترتها من للمطورين ← الويب هوك ← الأحداث. كل حدث هو تمامًا ما يحمله الويب هوك (id وtype وcreated_at وdata)، فمعالج واحد يخدم الطريقتين. سجل الأحداث في لوحة التحكم (تحت «للمطورين») يعرض الأحداث نفسها، الأحدث أولًا، مع ما حمله كل حدث.
{
"data": [{ "id": "evt_01K…", "type": "message.received", "created_at": "…", "data": { "…": "the message" } }],
"next_after": "evt_01K…",
"has_more": false
}
- الحلقة: ابدأ بلا
after(فيصلك أقدم المحفوظ أولًا)، ثم مرّر دائمًاnext_afterمن آخر رد كقيمةafter. اطلب مرة أخرى فورًا ما دامhas_moreبقيمة true؛ وإلا فانتظر بضع ثوانٍ (من 5 إلى 60 تكفي). احفظnext_afterفي مكان يبقى بعد إعادة التشغيل، لتكمل من حيث توقفت. لا يُحذف شيء حين تقرؤه: كل ما في الأمر أنه لم يعد يأتي بعد المؤشر. - الأقدم أولًا. يظهر الحدث هنا بعد ثانية أو ثانيتين من وقوعه، فلا يتخطى المؤشر أبدًا حدثًا كان ما زال في الطريق.
- تجاهل المكرر اعتمادًا على
id. الحدث الذي وصل إلى الويب هوك لديك أيضًا له المعرّف نفسه، وإعادة محاولة الجلب قد تعطيك حدثًا مرة أخرى؛ فتخطَّ المعرّفات التي عالجتها. - ضيّق النتائج بـ
limit(من 1 إلى 100، و50 افتراضيًا)، وtypes[](مثلtypes[]=message.received)، وinstance_id؛ ويظلnext_afterيتجاوز الأحداث التي تستبعدها هذه. المفتاح المُنشأ لرقم واحد يرى أحداث ذلك الرقم فقط. - الحدود: كل عملية جلب هي طلب API كأي طلب آخر (تُحسب من الحصة الشهرية للباقة). ويمكن للمفتاح أن يجلب 60 مرة في الدقيقة على الأكثر (ثم
429معRetry-After). إن لم يعد الحدث الذي فيafterمحفوظًا (أقدم من 30 يومًا، أو أُزيل رقمه)، تكمل من الحدث التالي له زمنيًا.
يبقى الويب هوك أسرع طريقة لتعرف بالحدث؛ واستخدم الطريقتين معًا إن شئت، والجلب لتستدرك ما فات رابط الاستقبال لديك.