TicketWave Logo
Webhooks

示例服务器

一个完整、可运行的 Express 服务器,用于接收并验证 TicketWave Webhook。

示例 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
  1. Dashboard → Webhooks → Endpoints 复制密钥,并将其填入 TICKETWAVE_WEBHOOK_SECRET
  2. 在你的机器上选择一个可用端口,并将其设置到 PORT 变量中。

永远不要把密钥硬编码到 server.js 里,也不要提交到仓库。任何拿到它的人都可以伪造能通过签名校验的请求。

运行它

node server.js

然后在仪表盘里的端点上点击 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);
}

// 签名是基于原始 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?