مثال على خادم
خادم Express كامل وقابل للتشغيل يستقبل Webhooks الخاصة بـ TicketWave ويتحقق منها.
مثال على خادم Webhook
مستقبل بسيط لكن بهيئة إنتاجية في Express: يتحقق من التواقيع، ويحمي من إعادة الإرسال، ويمنع التكرار في المحاولات، ويُجيب قبل تنفيذ أي عمل.
انسخه، ووجّه نقطة نهاية إليه، وانتهى الأمر.
الإعداد
أنشئ المشروع
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvيُستخدم Express 5 أدناه، لكن الكود يعمل دون تغيير على Express 4.
أضف الخادم
احفظ الملف من القسم التالي باسم server.js.
اضبط متغيرات البيئة
أنشئ ملف .env في جذر مشروعك وأضف متغيرات البيئة التالية:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- انسخ السر من Dashboard → Webhooks → Endpoints وضعه في
TICKETWAVE_WEBHOOK_SECRET. - اختر منفذًا مفتوحًا على جهازك واضبطه في المتغير
PORT.
لا تضع السر مباشرة داخل server.js ولا تلتزم به في المستودع. أي شخص يملكه يمكنه تزوير طلبات تجتاز فحص التوقيع.
شغّله
node server.jsثم اضغط Send test event على نقطة النهاية الخاصة بك في لوحة التحكم وراقب وحدة التحكم.
الخادم
const express = require('express');
const crypto = require('node:crypto');
const dotenv = require('dotenv');
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.TICKETWAVE_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
console.error('Missing TICKETWAVE_WEBHOOK_SECRET');
process.exit(1);
}
// The signature is built over the RAW body, so keep the untouched bytes around.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));
// Reject anything older than this, so a captured request cannot be replayed later.
const MAX_TIMESTAMP_AGE = 5 * 60; // 5 minutes
function verifySignature(req) {
const Signature = req.get('X-TicketWave-Signature');
const Timestamp = req.get('X-TicketWave-Timestamp');
if (!Signature || !Timestamp) return false;
// 1. The timestamp must be recent
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. Recompute the HMAC over `${timestamp}.${rawBody}`
const Expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${Timestamp}.${req.body}`)
.digest('hex');
// 3. Compare in constant time
const Received = Signature.replace('sha256=', '');
const ExpectedBuffer = Buffer.from(Expected, 'hex');
const ReceivedBuffer = Buffer.from(Received, 'hex');
if (ExpectedBuffer.length !== ReceivedBuffer.length) return false;
return crypto.timingSafeEqual(ExpectedBuffer, ReceivedBuffer);
}
// Remember handled delivery ids, because a retry sends the same event again.
const HandledDeliveries = new Set();
app.post('/webhooks/ticketwave', (req, res) => {
if (!verifySignature(req)) {
console.warn('Rejected a request with an invalid signature');
return res.status(401).json({ error: 'invalid signature' });
}
const DeliveryId = req.get('X-TicketWave-Delivery');
const Payload = JSON.parse(req.body);
// Answer immediately - you have 10 seconds, and slow replies get retried.
res.status(200).json({ received: true });
// Ignore a delivery we already handled
if (HandledDeliveries.has(DeliveryId)) return;
HandledDeliveries.add(DeliveryId);
handleEvent(Payload).catch((err) => {
console.error(`Failed to handle ${Payload.event}:`, err);
});
});
async function handleEvent(payload) {
const { event, guild_id: guildId, data } = payload;
// The dashboard test button sends this
if (data.test) {
console.log(`Test event (${event}) received from guild ${guildId}`);
return;
}
switch (event) {
case 'ticket.created':
console.log(`[${guildId}] ${data.ticket_id} opened by ${data.user?.username}`);
break;
case 'ticket.closed':
console.log(`[${guildId}] ${data.ticket_id} closed by ${data.closed_by?.username} (${data.reason ?? 'no reason'})`);
break;
case 'ticket.updated':
console.log(`[${guildId}] ${data.ticket_id} updated: ${data.action}`, data.changes);
break;
case 'message.sent':
console.log(`[${guildId}] ${data.ticket_id} ${data.is_staff ? 'staff' : 'member'} ${data.author?.username}: ${data.content}`);
break;
case 'blacklist.added':
console.log(`[${guildId}] ${data.user?.username} blacklisted (${data.reason ?? 'no reason'})`);
break;
case 'blacklist.removed':
console.log(`[${guildId}] ${data.user?.username} removed from the blacklist`);
break;
default:
// New event types are added over time - never throw on one you do not know.
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});لماذا يبدو الكود بهذه الصورة
هناك أربع تفاصيل يسهل الخطأ فيها، وجميعها تؤدي إلى فشل صامت.
express.raw بدلًا من express.json
يُبنى التوقيع على البايتات الدقيقة التي أرسلها TicketWave. تقوم express.json() بتحليل الجسم إلى كائن؛ وإعادة تسلسله قد تغيّر ترتيب المفاتيح أو المسافات البيضاء، وعندها لن تتطابق البصمة بعد الآن.
إذا كان تطبيقك يستخدم express.json() بشكل عام، فقم بتركيبه بعد مسار الـ webhook، أو اجعل محلل البيانات الخام مقتصرًا على مسار الـ webhook تمامًا كما هو موضح أعلاه. وإلا فسيغلب محلل JSON، وسيكون req.body كائنًا لا Buffer.
timingSafeEqual بدلًا من ===
المقارنة بين السلاسل باستخدام === تتوقف بمجرد اختلاف بايتين. الزمن الذي تستغرقه يكشف مقدار ما كان صحيحًا من التوقيع، وهذا يكفي لتخمينه بايتًا بعد بايت. أما crypto.timingSafeEqual فيستغرق الوقت نفسه دائمًا.
كما أنه يرمي خطأ عندما يختلف طول المخزنين، ولهذا يتم التحقق من الطول أولًا.
فحص الطابع الزمني
من دونه، يمكن لأي شخص التقط طلبًا صالحًا أن يعيد إرساله إلى الأبد. وبما أن الطابع الزمني جزء من الحمولة الموقعة، فلا يمكن استبداله بآخر جديد دون كسر التوقيع.
الرد قبل المعالجة
لديك 10 ثوانٍ. أي شيء أبطأ من ذلك يُعامل على أنه فشل ويُعاد إرساله، لذا فإن كتابة بطيئة إلى قاعدة البيانات قد تحوّل حدثًا واحدًا إلى ثلاثة. أرسل 200 أولًا، ثم نفّذ العمل.
منع التكرار بالشكل الصحيح
الـ Set أعلاه مناسب للتجربة، لكنه ينمو إلى ما لا نهاية ويصبح فارغًا مرة أخرى بعد إعادة التشغيل. في الإنتاج، خزّن معرّف التسليم في مكان يبقى محفوظًا:
// Example with any SQL database
async function alreadyHandled(deliveryId) {
const [rows] = await db.query(
'SELECT 1 FROM webhook_deliveries WHERE delivery_id = ?',
[deliveryId]
);
if (rows.length > 0) return true;
await db.query(
'INSERT INTO webhook_deliveries (delivery_id) VALUES (?)',
[deliveryId]
);
return false;
}وجود فهرس فريد على delivery_id يجعل هذا آمنًا حتى عندما تصل محاولتا إعادة إرسال في الوقت نفسه.
التطوير محليًا
يرفض TicketWave النقاط النهائية على العناوين الخاصة أو عناوين loopback، لذا لا يمكن استخدام http://localhost:3000 مباشرة. ضع نفقًا أمام خادمك المحلي وسجّل عنوان HTTPS العام الذي يمنحه لك:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000سجّل عنوان https://….ngrok-free.app/webhooks/ticketwave المطبوع كنقطة نهاية، واستخدم Send test event للتحقق من الإعداد قبل لمس تذكرة حقيقية.
عناوين النفق المجانية تتغير مع كل إعادة تشغيل. حدّث عنوان نقطة النهاية في لوحة التحكم عندما يحدث ذلك، وإلا ستبدأ عمليات التسليم بالفشل.
المزيد
| Want to… | Do this |
|---|---|
| Handle high message volume | Push the payload onto a queue in the route and process it elsewhere |
| Run several endpoints | Each has its own secret — pick the right one per route |
| Debug a failing delivery | Open the delivery in Dashboard → Webhooks to see the exact request and your response |
| Re-send an event | Use Retry Webhook on the delivery detail page |
الخطوات التالية
- Event Reference (حمولة كل حدث)
- Webhooks (التوقيع، وإعادة المحاولة، وسجل التسليم)
How is this guide?
