Example Server
Một máy chủ Express hoàn chỉnh, có thể chạy ngay, dùng để nhận và xác minh webhook TicketWave.
Máy chủ Webhook mẫu
Một bộ nhận tối giản nhưng có dáng dấp production trong Express: nó xác minh chữ ký, chống phát lại, khử trùng lặp các lần thử lại và phản hồi trước khi làm bất kỳ việc gì.
Chỉ cần sao chép, trỏ endpoint vào đó, thế là xong.
Thiết lập
Tạo dự án
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvBên dưới dùng Express 5, nhưng mã vẫn chạy nguyên trên Express 4.
Thêm máy chủ
Lưu tệp từ phần tiếp theo thành server.js.
Thiết lập biến môi trường của bạn
Tạo một tệp .env ở thư mục gốc của dự án và thêm các biến môi trường sau:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Sao chép secret từ Dashboard → Webhooks → Endpoints và truyền vào
TICKETWAVE_WEBHOOK_SECRET. - Chọn một cổng đang trống trên máy của bạn và đặt vào biến
PORT.
Đừng bao giờ hardcode secret trong server.js hoặc commit nó. Bất kỳ ai giữ được nó đều có thể giả mạo các request vượt qua bước kiểm tra chữ ký của bạn.
Chạy nó
node server.jsSau đó bấm Send test event trên endpoint của bạn trong dashboard và theo dõi console.
Máy chủ
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);
}
// Chữ ký được tạo trên phần body RAW, vì vậy hãy giữ nguyên các byte gốc.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));
// Từ chối mọi thứ cũ hơn mức này, để request bị chặn không thể bị phát lại sau này.
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. Timestamp phải còn mới
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. Tính lại HMAC trên `${timestamp}.${rawBody}`
const Expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${Timestamp}.${req.body}`)
.digest('hex');
// 3. So sánh theo thời gian hằng định
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);
}
// Ghi nhớ các delivery id đã xử lý, vì retry sẽ gửi lại cùng một event.
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);
// Trả lời ngay lập tức - bạn chỉ có 10 giây, và phản hồi chậm sẽ bị thử lại.
res.status(200).json({ received: true });
// Bỏ qua một delivery mà chúng ta đã xử lý rồi
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;
// Nút test trên dashboard sẽ gửi event này
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:
// Các loại event mới sẽ được thêm dần theo thời gian - đừng bao giờ ném lỗi với một loại bạn chưa biết.
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});Vì sao mã lại trông như thế này
Có bốn chi tiết rất dễ làm sai, và cả bốn đều là lỗi âm thầm.
express.raw thay vì express.json
Chữ ký bao phủ chính xác các byte TicketWave đã gửi. express.json() phân tích body thành một object; việc serialize lại có thể làm thay đổi thứ tự key hoặc khoảng trắng và digest sẽ không còn khớp.
Nếu app của bạn dùng express.json() toàn cục, hãy mount nó sau route webhook, hoặc giới hạn raw parser đúng vào đường dẫn webhook như ví dụ ở trên. Nếu không, JSON parser sẽ thắng và req.body sẽ là một object, không phải Buffer.
timingSafeEqual thay vì ===
So sánh chuỗi bằng === sẽ trả về ngay khi hai byte đầu tiên khác nhau. Thời gian đó tiết lộ bao nhiêu phần của chữ ký là đúng, và như vậy đủ để brute-force từng byte một. crypto.timingSafeEqual luôn mất cùng một khoảng thời gian.
Nó cũng ném lỗi khi hai buffer có độ dài khác nhau, vì vậy cần kiểm tra độ dài trước.
Kiểm tra timestamp
Nếu không có nó, ai đó đã chụp được một request hợp lệ có thể phát lại nó mãi mãi. Vì timestamp nằm trong payload đã được ký, nên không thể thay nó bằng một giá trị mới mà không làm hỏng chữ ký.
Phản hồi trước rồi mới xử lý
Bạn chỉ có 10 giây. Bất kỳ thứ gì chậm hơn đều bị xem là thất bại và sẽ bị thử lại, nên một lần ghi database chậm có thể biến một event thành ba lần. Hãy trả 200 trước, rồi mới xử lý.
Khử trùng lặp đúng cách
Set ở trên đủ dùng cho demo nhưng sẽ tăng mãi và sẽ trống lại sau khi khởi động lại. Trong production, hãy lưu delivery id ở nơi nó có thể tồn tại lâu hơn:
// Ví dụ với bất kỳ cơ sở dữ liệu SQL nào
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;
}Một unique index trên delivery_id sẽ giúp cách này an toàn ngay cả khi hai lần retry đến cùng lúc.
Phát triển cục bộ
TicketWave từ chối các endpoint trên địa chỉ private hoặc loopback, nên không thể dùng trực tiếp http://localhost:3000. Hãy đặt một tunnel trước máy chủ cục bộ của bạn và đăng ký URL HTTPS công khai mà nó cung cấp:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Đăng ký URL https://….ngrok-free.app/webhooks/ticketwave được in ra làm endpoint của bạn, và dùng Send test event để kiểm tra kết nối trước khi chạm vào ticket thật.
Các URL tunnel miễn phí sẽ thay đổi mỗi lần khởi động lại. Hãy cập nhật URL endpoint trong dashboard khi điều đó xảy ra, nếu không các delivery sẽ bắt đầu thất bại.
Làm tiếp
| Muốn… | Làm thế này |
|---|---|
| Xử lý lượng tin nhắn lớn | Đẩy payload vào một queue trong route rồi xử lý ở nơi khác |
| Chạy nhiều endpoint | Mỗi endpoint có secret riêng — hãy chọn đúng secret cho từng route |
| Gỡ lỗi một delivery thất bại | Mở delivery trong Dashboard → Webhooks để xem request chính xác và phản hồi của bạn |
| Gửi lại một event | Dùng Retry Webhook trên trang chi tiết delivery |
Các bước tiếp theo
- Event Reference (Payload của mọi event)
- Webhooks (Ký, retry và lịch sử delivery)
How is this guide?
