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- 從 Dashboard → Webhooks → Endpoints 複製 secret,並填入
TICKETWAVE_WEBHOOK_SECRET。 - 在你的電腦上選一個可用的埠,並設定到
PORT變數中。
絕對不要把 secret 直接寫死在 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 — 每條路由選對那一個 |
| 除錯失敗的 delivery | 在 Dashboard → Webhooks 中打開該 delivery,查看完整請求與你的回應 |
| 重新送出事件 | 在 delivery 詳細頁使用 Retry Webhook |
下一步
- Event Reference(每個事件的 payload)
- Webhooks(簽章、重試與 delivery 歷史)
How is this guide?
