TicketWave Logo
Webhooks

Példa szerver

Egy teljes, futtatható Express szerver, amely TicketWave webhookokat fogad és ellenőriz.

Példa webhook szerver

Egy minimális, de éles használatra formált fogadó Expressben: ellenőrzi az aláírásokat, véd az ismételt lejátszás ellen, kiszűri az ismételt próbálkozásokat, és még a munka megkezdése előtt válaszol.

Másold be, állíts rá egy végpontot, és kész.

Beállítás

A projekt létrehozása

mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenv

Az alábbi példában az Express 5 szerepel, de a kód változtatás nélkül működik Express 4-en is.

A szerver hozzáadása

Mentsd el a következő szakasz fájlját server.js néven.

Környezeti változók beállítása

Hozz létre egy .env fájlt a projekt gyökerében, és add hozzá a következő környezeti változókat:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Másold ki a titkot a Dashboard → Webhooks → Endpoints részből, és add meg a TICKETWAVE_WEBHOOK_SECRET értékeként.
  2. Válassz egy szabad portot a gépeden, és állítsd be a PORT változóban.

Soha ne írd be hardcode-olva a titkot a server.js fájlba, és ne committold. Bárki, aki megszerzi, hamis kéréseket tud küldeni, amelyek átmennek az aláírás-ellenőrzésen.

Futtatás

node server.js

Ezután kattints az endpointodnál a dashboardon a Send test event gombra, és figyeld a konzolt.

A szerver

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

Miért néz ki így a kód

Négy rész könnyen elrontható, és mind a négy csendes hibát eredményez.

express.raw az express.json helyett

Az aláírás a TicketWave által küldött pontos bájtokra épül. Az express.json() objektummá alakítja a törzset; az újbóli sorosítás megváltoztathatja a kulcsok sorrendjét vagy a whitespace-t, és az összegzés már nem fog egyezni.

Ha az alkalmazásod globálisan használja az express.json()-t, akkor a webhook útvonal után csatold be, vagy korlátozd a raw parser használatát pontosan a webhook útvonalra, ahogy fent látható. Különben a JSON parser nyer, és a req.body egy objektum lesz, nem pedig egy Buffer.

timingSafeEqual az === helyett

A stringek ===-szel való összehasonlítása azonnal visszatér, amint két bájt eltér. Az így eltelt idő elárulja, hogy az aláírásból mennyi volt helyes, és ez elég ahhoz, hogy bájtonként brute force-szal feltörjék. A crypto.timingSafeEqual mindig ugyanannyi ideig fut.

Az is igaz, hogy hibát dob, ha a két buffer hossza eltér, ezért kell előbb a hosszát ellenőrizni.

Az időbélyeg-ellenőrzés

Enélkül bárki, aki elfogott egy érvényes kérést, örökké újrajátszhatná. Mivel az időbélyeg az aláírt payload része, nem lehet frissre cserélni az aláírás megsértése nélkül.

Válaszadás a munka előtt

10 másodperced van. Ami ennél lassabb, azt hibának tekintik és újrapróbálják, így egy lassú adatbázis-írás egy eseményből hármat csinál. Előbb válaszolj 200-zal, aztán dolgozz.

A duplikációk helyes kiszűrése

A fenti Set demóra megfelel, de örökké nő, és újraindítás után üres lesz. Éles környezetben tárold a delivery id-t olyan helyen, ahol megmarad:

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

A delivery_id-n lévő egyedi index akkor is biztonságossá teszi ezt, ha két újrapróbálkozás egyszerre érkezik.

Helyi fejlesztés

A TicketWave elutasítja a privát vagy loopback címeken lévő végpontokat, ezért a http://localhost:3000 közvetlenül nem használható. Tegyél egy tunnelt a helyi szervered elé, és regisztráld azt a nyilvános HTTPS URL-t, amit ad:

# ngrok
ngrok http 3000

# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000

Regisztráld a kiírt https://….ngrok-free.app/webhooks/ticketwave URL-t végpontként, és használd a Send test event gombot a bekötés ellenőrzésére, mielőtt valódi tickethez nyúlnál.

Az ingyenes tunnel URL-ek minden újraindításkor megváltoznak. Ilyenkor frissítsd az endpoint URL-jét a dashboardon, különben a kézbesítések hibára futnak.

További lehetőségek

Want to…Do this
Handle high message volumePush the payload onto a queue in the route and process it elsewhere
Run several endpointsEach has its own secret — pick the right one per route
Debug a failing deliveryOpen the delivery in Dashboard → Webhooks to see the exact request and your response
Re-send an eventUse Retry Webhook on the delivery detail page

Következő lépések

  • Event Reference (Minden esemény payloadja)
  • Webhooks (Aláírás, újrapróbálkozások és kézbesítési előzmények)

How is this guide?