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 dotenvQui 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- Copia il secret da Dashboard → Webhooks → Endpoints e inseriscilo in
TICKETWAVE_WEBHOOK_SECRET. - 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.jsPoi premi Send test event sul tuo endpoint nella dashboard e osserva la console.
Il server
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:3000Registra 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 messaggi | Metti il payload in una coda nella route ed elabora altrove |
| Eseguire più endpoint | Ognuno ha il proprio secret — scegli quello giusto per ogni route |
| Debuggare una delivery fallita | Apri la delivery in Dashboard → Webhooks per vedere la richiesta esatta e la tua risposta |
| Reinviare un evento | Usa Retry Webhook nella pagina dei dettagli della delivery |
Prossimi passi
- Event Reference (Il payload di ogni evento)
- Webhooks (Firma, retry e cronologia delle delivery)
How is this guide?
