TicketWave Logo
Webhooks

Example Server

一個完整、可直接執行的 Express 伺服器,用來接收並驗證 TicketWave Webhooks。

範例 Webhook 伺服器

一個精簡但具備 production-shaped 的 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
  1. Dashboard → Webhooks → Endpoints 複製 secret,並填入 TICKETWAVE_WEBHOOK_SECRET
  2. 在你的電腦上選一個可用的埠,並設定到 PORT 變數中。

絕對不要把 secret 直接寫死在 server.js 裡,也不要提交到版本控制。任何拿到它的人都能偽造通過你簽章檢查的請求。

執行它

node server.js

接著到 dashboard 的端點上按 Send test event,然後觀察主控台。

伺服器

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

// 簽章是針對 RAW body 建立的,所以要保留未經處理的原始位元組。
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));

// 拒絕任何比這個時間更舊的請求,避免已擷取的請求之後被重放。
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. 時間戳必須是最近的
    const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
    if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;

    // 2. 重新計算 `${timestamp}.${rawBody}` 上的 HMAC
    const Expected = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(`${Timestamp}.${req.body}`)
        .digest('hex');

    // 3. 以常數時間比較
    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);
}

// 記住已處理過的 delivery id,因為重試會再次送出同一個事件。
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);

    // 立刻回應 - 你只有 10 秒,太慢的回覆會被重試。
    res.status(200).json({ received: true });

    // 忽略我們已經處理過的 delivery
    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;

    // dashboard 的測試按鈕會送出這個
    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:
            // 新的事件類型會隨時間加入 - 對於不認識的事件,絕對不要丟出例外。
            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() 會把 body 解析成物件;重新序列化時可能改變 key 的順序或空白,digest 就不會再匹配。

如果你的應用程式全域使用 express.json(),請把它掛在 webhook 路由之後,或像上面那樣只把 raw parser 限定在 webhook 路徑上。否則 JSON parser 會先接手,而 req.body 會是物件,不是 Buffer。

使用 timingSafeEqual 而不是 ===

=== 比較字串時,只要兩個位元組不同就會立刻返回。這個時間差會洩漏簽章有多少部分是正確的,足以一次暴力破解一個位元組。crypto.timingSafeEqual 則永遠花相同的時間。

另外,當兩個 buffer 長度不同時它也會 丟出例外,所以才要先檢查長度。

時間戳檢查

如果沒有這個檢查,任何擷取到有效請求的人都可以無限次重放。因為時間戳也是簽章 payload 的一部分,所以不能在不破壞簽章的情況下把它換成新的。

先回應,再處理

你只有 10 秒。任何更慢的回應都會被視為失敗並重試,所以一次緩慢的資料庫寫入就可能讓一個事件變成三次。先回 200,再做後續處理。

正確去重

上面的 Set 適合示範,但它會一直成長,而且在重新啟動後又會是空的。正式環境中,請把 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;
}

delivery_id 上建立唯一索引,即使兩個重試同時到達也能確保安全。

本機開發

TicketWave 不接受私人或 loopback 位址上的端點,所以不能直接使用 http://localhost:3000。請在本機伺服器前面放一個 tunnel,並註冊它提供給你的公開 HTTPS URL:

# ngrok
ngrok http 3000

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

把印出的 https://….ngrok-free.app/webhooks/ticketwave URL 註冊為你的端點,並使用 Send test event 先確認連線是否正確,再去碰真實 ticket。

免費的 tunnel URL 每次重新啟動都會變。當它變更時,請更新 dashboard 裡的端點 URL,否則 delivery 會開始失敗。

進一步延伸

想要…這樣做
處理大量訊息在路由中把 payload 推進 queue,然後在別處處理
執行多個端點每個端點都有自己的 secret — 每條路由選對那一個
除錯失敗的 deliveryDashboard → Webhooks 中打開該 delivery,查看完整請求與你的回應
重新送出事件在 delivery 詳細頁使用 Retry Webhook

下一步

How is this guide?