TicketWave Logo
Webhooks

Exemplu de server

Un server Express complet, rulabil, care primește și verifică webhook-urile TicketWave.

Exemplu de server webhook

Un receptor minimal, dar pregătit pentru producție, în Express: verifică semnăturile, protejează împotriva replay-urilor, deduplichează retry-urile și răspunde înainte de a face orice altceva.

Copiază-l, indică-i un endpoint și gata.

Configurare

Creează proiectul

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

Mai jos este folosit Express 5, dar codul funcționează neschimbat și pe Express 4.

Adaugă serverul

Salvează fișierul din secțiunea următoare ca server.js.

Setează variabilele de mediu

Creează un fișier .env în rădăcina proiectului și adaugă următoarele variabile de mediu:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Copiază secretul din Dashboard → Webhooks → Endpoints și trece-l în TICKETWAVE_WEBHOOK_SECRET.
  2. Alege un port liber pe mașina ta și setează-l în variabila PORT.

Nu hardcoda niciodată secretul în server.js și nu-l comite în repository. Oricine îl deține poate falsifica cereri care trec verificarea semnăturii.

Rulează-l

node server.js

Apoi apasă Send test event pe endpointul tău din dashboard și urmărește consola.

Serverul

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

// Semnătura este construită peste corpul RAW, așa că păstrează bytes-ii originali.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));

// Respinge orice este mai vechi decât atât, ca o cerere capturată să nu poată fi redată ulterior.
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-ul trebuie să fie recent
    const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
    if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;

    // 2. Recalculează HMAC-ul peste `${timestamp}.${rawBody}`
    const Expected = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(`${Timestamp}.${req.body}`)
        .digest('hex');

    // 3. Compară în timp constant
    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);
}

// Ține minte delivery ID-urile deja procesate, pentru că un retry trimite din nou același eveniment.
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);

    // Răspunde imediat - ai 10 secunde, iar răspunsurile lente sunt retrimise.
    res.status(200).json({ received: true });

    // Ignoră un delivery pe care l-am procesat deja
    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;

    // Butonul de test din dashboard trimite asta
    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:
            // Noile tipuri de evenimente sunt adăugate în timp - nu arunca niciodată pentru unul pe care nu-l cunoști.
            console.log(`[${guildId}] Unhandled event ${event}`);
    }
}

app.listen(PORT, () => {
    console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});

De ce arată codul așa

Patru detalii sunt ușor de greșit, iar toate patru eșuează în tăcere.

express.raw în loc de express.json

Semnătura acoperă exact bytes-ii trimiși de TicketWave. express.json() parsează corpul într-un obiect; re-serializarea lui poate schimba ordinea cheilor sau spațierea, iar digestul nu va mai coincide.

Dacă aplicația ta folosește express.json() global, montează-l după ruta webhook-ului sau limitează parserul raw exact la calea webhook, așa cum este arătat mai sus. Altfel, parserul JSON câștigă, iar req.body este un obiect, nu un Buffer.

timingSafeEqual în loc de ===

Compararea șirurilor cu === se oprește imediat ce doi bytes diferă. Timpul necesar dezvăluie cât din semnătură era corect, suficient pentru a forța brut câte un byte pe rând. crypto.timingSafeEqual durează mereu la fel.

De asemenea, aruncă o eroare atunci când cele două buffer-e au lungimi diferite, motiv pentru care lungimea este verificată mai întâi.

Verificarea timestamp-ului

Fără ea, cineva care a capturat o cerere validă ar putea să o redea la nesfârșit. Pentru că timestamp-ul face parte din payload-ul semnat, nu poate fi înlocuit cu unul nou fără a rupe semnătura.

Răspunsul înainte de procesare

Ai 10 secunde. Orice este mai lent este tratat ca eșec și retrimis, așa că un write lent în baza de date transformă un eveniment în trei. Răspunde mai întâi cu 200, apoi procesează.

Deduplicare corectă

Set-ul de mai sus este bun pentru un demo, dar crește la nesfârșit și devine gol din nou după un restart. În producție, stochează delivery ID-ul într-un loc în care persistă:

// Exemplu cu orice bază de date SQL
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;
}

Un index unic pe delivery_id face asta sigur chiar și atunci când două retry-uri sosesc în același timp.

Dezvoltare locală

TicketWave refuză endpoint-urile pe adrese private sau loopback, așa că http://localhost:3000 nu poate fi folosit direct. Pune un tunnel în fața serverului local și înregistrează URL-ul public HTTPS pe care îl oferă:

# ngrok
ngrok http 3000

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

Înregistrează ca endpoint URL-ul afișat https://….ngrok-free.app/webhooks/ticketwave, și folosește Send test event pentru a verifica legătura înainte să atingi un ticket real.

URL-urile gratuite de tunnel se schimbă la fiecare restart. Actualizează URL-ul endpointului în dashboard când se întâmplă asta, altfel delivery-urile vor începe să eșueze.

Mai departe

Vrei să…Fă asta
Gestionezi un volum mare de mesajePune payload-ul într-o coadă în rută și procesează-l în altă parte
Rulezi mai multe endpoint-uriFiecare are propriul secret — alege-l pe cel potrivit pentru fiecare rută
Depanezi un delivery eșuatDeschide delivery-ul în Dashboard → Webhooks ca să vezi cererea exactă și răspunsul tău
Retrimiți un evenimentFolosește Retry Webhook pe pagina de detalii a delivery-ului

Pașii următori

How is this guide?