TicketWave Logo
Webhooks

เซิร์ฟเวอร์ตัวอย่าง

เซิร์ฟเวอร์ Express ที่ใช้งานได้จริงแบบครบถ้วน สำหรับรับและตรวจสอบ TicketWave webhooks

เซิร์ฟเวอร์ Webhook ตัวอย่าง

ตัวรับใน Express ที่เรียบง่ายแต่มีลักษณะเหมือน production: ตรวจสอบลายเซ็น ป้องกันการส่งซ้ำจาก replay จัดการ retry แบบไม่ให้เกิดข้อมูลซ้ำ และตอบกลับก่อนทำงานใด ๆ

คัดลอกไป ชี้ endpoint มาที่นี่ แล้วก็เสร็จ

การตั้งค่า

สร้างโปรเจกต์

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
  1. คัดลอก secret จาก Dashboard → Webhooks → Endpoints แล้วใส่ลงใน TICKETWAVE_WEBHOOK_SECRET
  2. เลือกพอร์ตที่ว่างบนเครื่องของคุณ แล้วตั้งค่าในตัวแปร PORT

อย่า hardcode secret ไว้ใน server.js หรือ commit มันเข้าไป ใครก็ตามที่มี secret นี้สามารถปลอมคำขอให้ผ่านการตรวจสอบลายเซ็นของคุณได้

รันมัน

node server.js

จากนั้นกด Send test event ที่ endpoint ของคุณใน dashboard แล้วดูที่คอนโซล

เซิร์ฟเวอร์

server.js
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() จะ parse body ให้เป็น object; การ serialize กลับอาจเปลี่ยนลำดับคีย์หรือช่องว่าง และ digest จะไม่ตรงกันอีกต่อไป

ถ้าแอปของคุณใช้ express.json() แบบ global ให้ mount มัน หลัง route ของ webhook หรือกำหนด raw parser เฉพาะ path ของ webhook ตามที่แสดงไว้ด้านบน ไม่อย่างนั้น JSON parser จะทำงานก่อน และ req.body จะเป็น object ไม่ใช่ Buffer

timingSafeEqual แทน ===

การเปรียบเทียบสตริงด้วย === จะหยุดทันทีเมื่อมีไบต์ใดไบต์หนึ่งต่างกัน เวลาที่ใช้จะบอกได้ว่าลายเซ็นตรงมามากแค่ไหน ซึ่งเพียงพอให้ brute-force ทีละไบต์ได้ crypto.timingSafeEqual จะใช้เวลาเท่ากันเสมอ

และมันยัง throw เมื่อ buffer ทั้งสองมีความยาวต่างกันด้วย จึงต้องตรวจความยาวก่อน

การตรวจ timestamp

ถ้าไม่มีการตรวจนี้ คนที่ดักจับคำขอที่ถูกต้องได้จะ replay มันซ้ำได้ตลอด เพราะ timestamp เป็นส่วนหนึ่งของ payload ที่ถูกเซ็นไว้ จึงไม่สามารถเปลี่ยนเป็นค่าใหม่ได้โดยไม่ทำให้ลายเซ็นเสีย

ตอบกลับก่อนทำงาน

คุณมีเวลา 10 วินาที ถ้าช้ากว่านั้นจะถูกมองว่าเป็นความล้มเหลวและถูก retry ดังนั้นการเขียนลงฐานข้อมูลที่ช้าจะทำให้ event เดียวกลายเป็นสามครั้ง ตอบ 200 ก่อน แล้วค่อยทำงาน

การ deduplicate อย่างถูกต้อง

Set ด้านบนใช้ได้สำหรับเดโม แต่จะโตขึ้นเรื่อย ๆ และจะว่างอีกครั้งหลังรีสตาร์ต ใน production ให้เก็บ delivery id ไว้ในที่ที่มันอยู่รอดได้:

// 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;
}

การมี unique index บน delivery_id จะทำให้ปลอดภัยแม้มี retry สองครั้งมาพร้อมกัน

การพัฒนาแบบโลคัล

TicketWave ไม่อนุญาต endpoint ที่เป็น private หรือ loopback address ดังนั้น http://localhost:3000 จึงใช้ตรง ๆ ไม่ได้ ให้วาง tunnel ไว้หน้าซิร์ฟเวอร์โลคัลของคุณ แล้วลงทะเบียน public HTTPS URL ที่มันให้มา:

# ngrok
ngrok http 3000

# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000

ลงทะเบียน URL https://….ngrok-free.app/webhooks/ticketwave ที่แสดงออกมาเป็น endpoint ของคุณ แล้วใช้ Send test event เพื่อตรวจสอบการเชื่อมต่อก่อนแตะ ticket จริง

URL ของ tunnel แบบฟรีจะเปลี่ยนทุกครั้งที่รีสตาร์ต อัปเดต URL ของ endpoint ใน dashboard เมื่อมันเปลี่ยน ไม่อย่างนั้น deliveries จะเริ่มล้มเหลว

ไปต่อ

Want to…Do this
Handle high message volumePush the payload onto a queue in the route and process it elsewhere
Run several endpointsEach has its own secret — pick the right one per route
Debug a failing deliveryOpen the delivery in Dashboard → Webhooks to see the exact request and your response
Re-send an eventUse Retry Webhook on the delivery detail page

ขั้นตอนถัดไป

  • Event Reference (เพย์โหลดของทุก event)
  • Webhooks (การเซ็นลายเซ็น, retry และประวัติการส่ง)

How is this guide?