TicketWave Logo
Webhooks

Server di esempio

Un server Express completo ed eseguibile che riceve e verifica i webhook di TicketWave.

Server webhook di esempio

Un ricevitore minimale ma strutturato per la produzione in Express: verifica le firme, protegge dai replay, deduplica i retry e risponde prima di fare qualsiasi lavoro.

Copialo, punta un endpoint lì e hai finito.

Configurazione

Crea il progetto

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

Qui sotto viene usato Express 5, ma il codice funziona senza modifiche anche su Express 4.

Aggiungi il server

Salva il file della sezione successiva come server.js.

Imposta le variabili d'ambiente

Crea un file .env nella root del progetto e aggiungi le seguenti variabili d'ambiente:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Copia il secret da Dashboard → Webhooks → Endpoints e inseriscilo in TICKETWAVE_WEBHOOK_SECRET.
  2. Scegli una porta libera sulla tua macchina e impostala nella variabile PORT.

Non inserire mai il secret direttamente in server.js e non committarlo. Chiunque lo possieda può falsificare richieste che superano il controllo della firma.

Avvialo

node server.js

Poi premi Send test event sul tuo endpoint nella dashboard e osserva la console.

Il server

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

Perché il codice è fatto così

Quattro dettagli sono facili da sbagliare, e tutti e quattro falliscono in silenzio.

express.raw invece di express.json

La firma copre i byte esatti inviati da TicketWave. express.json() analizza il body in un oggetto; serializzarlo di nuovo può cambiare l'ordine delle chiavi o gli spazi bianchi e il digest non corrisponderà più.

Se la tua app usa express.json() globalmente, montalo dopo la route del webhook, oppure limita il parser raw esattamente al percorso del webhook come mostrato sopra. Altrimenti il parser JSON prevale e req.body è un oggetto, non un Buffer.

timingSafeEqual invece di ===

Confrontare stringhe con === restituisce il risultato non appena due byte differiscono. Il tempo impiegato rivela quanta parte della firma era corretta, e questo basta per forzare un byte alla volta. crypto.timingSafeEqual impiega sempre lo stesso tempo.

Inoltre lancia un errore quando i due buffer hanno lunghezze diverse, ed è per questo che prima viene controllata la lunghezza.

Il controllo del timestamp

Senza di esso, chiunque abbia catturato una richiesta valida potrebbe riprodurla all'infinito. Poiché il timestamp fa parte del payload firmato, non può essere sostituito con uno nuovo senza invalidare la firma.

Rispondere prima di elaborare

Hai 10 secondi. Qualsiasi cosa più lenta viene considerata un fallimento e ritentata, quindi una scrittura lenta sul database trasforma un evento in tre. Rispondi con 200 prima, poi elabora.

Deduplicare correttamente

Il Set qui sopra va bene per una demo, ma cresce all'infinito e si svuota di nuovo dopo un riavvio. In produzione, salva l'id della delivery in un posto che sopravvive:

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

Un indice univoco su delivery_id rende tutto sicuro anche quando arrivano due retry nello stesso momento.

Sviluppo in locale

TicketWave rifiuta endpoint su indirizzi privati o loopback, quindi http://localhost:3000 non può essere usato direttamente. Metti un tunnel davanti al tuo server locale e registra l'URL HTTPS pubblico che ti fornisce:

# ngrok
ngrok http 3000

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

Registra come endpoint l'URL https://….ngrok-free.app/webhooks/ticketwave stampato, e usa Send test event per verificare il collegamento prima di toccare un ticket reale.

Gli URL gratuiti dei tunnel cambiano a ogni riavvio. Aggiorna l'URL dell'endpoint nella dashboard quando succede, altrimenti le delivery inizieranno a fallire.

Approfondire

Want to…Do this
Gestire un alto volume di messaggiMetti il payload in una coda nella route ed elabora altrove
Eseguire più endpointOgnuno ha il proprio secret — scegli quello giusto per ogni route
Debuggare una delivery fallitaApri la delivery in Dashboard → Webhooks per vedere la richiesta esatta e la tua risposta
Reinviare un eventoUsa Retry Webhook nella pagina dei dettagli della delivery

Prossimi passi

How is this guide?