Пример сервера
Полноценный, запускаемый сервер Express, который принимает и проверяет вебхуки TicketWave.
Пример сервера вебхуков
Минимальный, но похожий на production приёмник на Express: он проверяет подписи, защищает от повторных отправок, дедуплицирует ретраи и отвечает до выполнения любой работы.
Скопируйте его, укажите на него endpoint — и готово.
Настройка
Создайте проект
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 и передайте его в
TICKETWAVE_WEBHOOK_SECRET. - Выберите свободный порт на своей машине и укажите его в переменной
PORT.
Никогда не хардкодьте секрет в server.js и не коммитьте его. Любой, у кого он есть, сможет подделывать запросы, которые пройдут проверку подписи.
Запустите его
node server.jsЗатем нажмите Send test event на вашем endpoint в панели управления и следите за консолью.
Сервер
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. Timestamp должен быть свежим
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. Пересчитываем HMAC по `${timestamp}.${rawBody}`
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() парсит тело в объект; при повторной сериализации может измениться порядок ключей или пробелы, и дайджест больше не совпадёт.
Если ваше приложение глобально использует express.json(), подключайте его после маршрута вебхука или ограничьте raw-парсер только путём вебхука, как показано выше. Иначе JSON-парсер сработает первым, и req.body будет объектом, а не Buffer.
timingSafeEqual вместо ===
Сравнение строк через === завершается, как только байты начинают отличаться. По времени выполнения можно понять, какая часть подписи была верной, а этого достаточно, чтобы подбирать её по одному байту. crypto.timingSafeEqual всегда работает одинаковое время.
Он также выбрасывает исключение, когда длины двух буферов различаются, поэтому сначала проверяется длина.
Проверка timestamp
Без неё тот, кто перехватил валидный запрос, мог бы повторять его бесконечно. Поскольку timestamp входит в подписанную полезную нагрузку, подменить его на свежий без нарушения подписи нельзя.
Ответ до выполнения работы
У вас есть 10 секунд. Всё, что медленнее, считается ошибкой и повторяется, так что медленная запись в базу превращает одно событие в три. Сначала верните 200, потом выполняйте работу.
Правильная дедупликация
Set выше подходит для демо, но он растёт бесконечно и после перезапуска снова пустой. В production храните 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 не принимает endpoint'ы на приватных или loopback-адресах, поэтому http://localhost:3000 нельзя использовать напрямую. Поставьте туннель перед локальным сервером и зарегистрируйте публичный HTTPS URL, который он выдаст:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Зарегистрируйте напечатанный URL https://….ngrok-free.app/webhooks/ticketwave как ваш endpoint и используйте Send test event, чтобы проверить связку до работы с реальным тикетом.
Бесплатные tunnel URL меняются при каждом перезапуске. Когда это происходит, обновите URL endpoint'а в панели управления, иначе доставки начнут падать.
Что можно сделать дальше
| Хотите… | Сделайте так |
|---|---|
| Обрабатывать большой поток сообщений | Отправляйте payload в очередь прямо из маршрута и обрабатывайте его в другом месте |
| Запускать несколько endpoint'ов | У каждого свой собственный секрет — выбирайте правильный для каждого маршрута |
| Отладить неудачную доставку | Откройте delivery в Dashboard → Webhooks, чтобы увидеть точный запрос и ваш ответ |
| Повторно отправить событие | Используйте Retry Webhook на странице деталей delivery |
Следующие шаги
- Event Reference (Полезная нагрузка каждого события)
- Webhooks (Подпись, ретраи и история доставок)
How is this guide?
