TicketWave Logo
Webhooks

Приклад сервера

Повноцінний, готовий до запуску Express-сервер, який приймає та перевіряє TicketWave webhook-и.

Приклад сервера webhook-ів

Мінімальний, але наближений до 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
  1. Скопіюйте секрет із Dashboard → Webhooks → Endpoints і передайте його в TICKETWAVE_WEBHOOK_SECRET.
  2. Виберіть відкритий порт на вашій машині та вкажіть його у змінній PORT.

Ніколи не вшивайте секрет прямо в server.js і не комітьте його. Будь-хто, хто його має, може підробити запити, які пройдуть перевірку підпису.

Запустіть його

node server.js

Потім натисніть Send test event на вашому endpoint у dashboard і стежте за консоллю.

Сервер

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);
}

// The signature is built over the RAW body, so keep the untouched bytes around.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));

// Reject anything older than this, so a captured request cannot be replayed later.
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. The timestamp must be recent
    const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
    if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;

    // 2. Recompute the HMAC over `${timestamp}.${rawBody}`
    const Expected = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(`${Timestamp}.${req.body}`)
        .digest('hex');

    // 3. Compare in constant time
    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);
}

// Remember handled delivery ids, because a retry sends the same event again.
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);

    // Answer immediately - you have 10 seconds, and slow replies get retried.
    res.status(200).json({ received: true });

    // Ignore a delivery we already handled
    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;

    // The dashboard test button sends this
    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:
            // New event types are added over time - never throw on one you do not know.
            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(), підключайте його після webhook-роуту або обмежте raw-парсер лише шляхом webhook-а, як показано вище. Інакше JSON-парсер переможе, а req.body буде об’єктом, а не Buffer.

timingSafeEqual замість ===

Порівняння рядків через === завершується, щойно два байти відрізняються. Час, який це займає, розкриває, яка частина підпису була правильною, а цього достатньо, щоб підбирати його по одному байту. crypto.timingSafeEqual завжди працює однаково довго.

Воно також кидає виняток, коли два буфери мають різну довжину, тому спочатку перевіряється довжина.

Перевірка timestamp

Без неї хтось, хто перехопив валідний запит, міг би повторювати його безкінечно. Оскільки timestamp входить до підписаного payload, його не можна підмінити свіжим без порушення підпису.

Відповідь до виконання роботи

У вас є 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-а в dashboard, коли це стається, інакше доставки почнуть падати.

Далі

Хочете…Зробіть так
Обробляти великий потік повідомленьПокладіть payload у чергу в роуті та обробляйте його в іншому місці
Запустити кілька endpoint-івУ кожного є власний секрет — вибирайте правильний для кожного роуту
Діагностувати невдалу доставкуВідкрийте delivery у Dashboard → Webhooks, щоб побачити точний запит і вашу відповідь
Надіслати подію ще разВикористайте Retry Webhook на сторінці деталей delivery

Наступні кроки

  • Event Reference (Payload кожної події)
  • Webhooks (Підписування, повтори та історія доставок)

How is this guide?