示例服务器
一个完整、可运行的 Express 服务器,用于接收并验证 TicketWave Webhook。
示例 Webhook 服务器
一个最小但具备生产环境形态的 Express 接收器:它会验证签名、防止重放、去重重试,并在做任何工作之前先响应。
复制它,把端点指向它,就完成了。
设置
创建项目
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenv下面使用的是 Express 5,但这段代码在 Express 4 上也能原样运行。
设置环境变量
在项目根目录创建一个 .env 文件,并添加以下环境变量:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- 从 Dashboard → Webhooks → Endpoints 复制密钥,并将其填入
TICKETWAVE_WEBHOOK_SECRET。 - 在你的机器上选择一个可用端口,并将其设置到
PORT变量中。
永远不要把密钥硬编码到 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);
}
// 签名是基于原始 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;
// 仪表盘里的测试按钮会发送这个
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 解析成对象;重新序列化时可能改变键的顺序或空白字符,摘要就不再匹配了。
如果你的应用全局使用了 express.json(),请把它放在 webhook 路由之后,或者像上面那样只把 raw 解析器限定在 webhook 路径上。否则 JSON 解析器会先接管,req.body 就会变成对象,而不是 Buffer。
使用 timingSafeEqual 而不是 ===
用 === 比较字符串时,一旦两个字节不同就会立刻返回。这个耗时会泄露签名有多少部分是正确的,这足以一次猜一个字节。crypto.timingSafeEqual 始终耗时相同。
它在两个 buffer 长度不同的时候还会抛出异常,所以必须先检查长度。
时间戳检查
没有它的话,任何捕获到有效请求的人都可以无限次重放。因为时间戳是签名负载的一部分,所以如果不破坏签名,就不能把它替换成一个新的时间戳。
先回复,再处理
你只有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 不接受指向私有地址或回环地址的端点,所以不能直接使用 http://localhost:3000。请在本地服务器前面加一个隧道,并注册它提供给你的公网 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 在接触真实工单之前先检查连接是否正常。
免费的隧道 URL 每次重启都会变化。变化后请在仪表盘里更新端点 URL,否则投递会开始失败。
进一步操作
| 想要…… | 这样做 |
|---|---|
| 处理高消息量 | 在路由里把 payload 推入队列,然后在别处处理 |
| 运行多个端点 | 每个端点都有自己的密钥——按路由选择正确的那个 |
| 调试失败的投递 | 在 Dashboard → Webhooks 中打开该投递,查看完整请求和你的响应 |
| 重新发送事件 | 在投递详情页使用 Retry Webhook |
下一步
How is this guide?
