Voorbeeldserver
Een complete, uitvoerbare Express-server die TicketWave-webhooks ontvangt en verifieert.
Voorbeeld-webhookserver
Een minimale maar productie-achtige ontvanger in Express: hij verifieert handtekeningen, beschermt tegen herhalingen, dedupliceert retries en antwoordt voordat hij iets gaat doen.
Kopieer hem, wijs er een endpoint naar toe, klaar.
Setup
Maak het project aan
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvHieronder wordt Express 5 gebruikt, maar de code werkt ongewijzigd op Express 4.
Voeg de server toe
Sla het bestand uit de volgende sectie op als server.js.
Stel je omgevingsvariabelen in
Maak een .env-bestand in de root van je project en voeg de volgende omgevingsvariabelen toe:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Kopieer het geheim uit Dashboard → Webhooks → Endpoints en vul het in bij
TICKETWAVE_WEBHOOK_SECRET. - Kies een open poort op je machine en zet die in de variabele
PORT.
Hardcode het geheim nooit in server.js en commit het ook niet. Iedereen die het heeft, kan verzoeken vervalsen die je handtekeningcontrole doorstaan.
Start het
node server.jsKlik daarna op Send test event bij je endpoint in het dashboard en kijk in de console.
De 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}`);
});Waarom de code er zo uitziet
Vier details zijn makkelijk fout te doen, en alle vier falen stilletjes.
express.raw in plaats van express.json
De handtekening wordt berekend over de exacte bytes die TicketWave heeft verzonden. express.json() parseert de body naar een object; opnieuw serialiseren kan de volgorde van sleutels of witruimte veranderen en dan komt de digest niet meer overeen.
Als je app overal express.json() gebruikt, mount het dan na de webhookroute, of beperk de raw parser precies tot het webhookpad zoals hierboven getoond. Anders wint de JSON-parser en is req.body een object, geen Buffer.
timingSafeEqual in plaats van ===
Strings vergelijken met === stopt zodra twee bytes verschillen. De tijd die dat kost lekt hoeveel van de handtekening correct was, en dat is genoeg om byte voor byte te brute-forcen. crypto.timingSafeEqual duurt altijd even lang.
Het gooit ook een fout wanneer de twee buffers verschillende lengtes hebben, daarom wordt eerst op lengte gecontroleerd.
De timestampcontrole
Zonder die controle zou iemand die een geldig verzoek heeft onderschept het eindeloos kunnen herhalen. Omdat de timestamp deel uitmaakt van de ondertekende payload, kan die niet worden vervangen door een verse zonder de handtekening te breken.
Antwoorden vóór het werk doen
Je hebt 10 seconden. Alles wat trager is, wordt als fout gezien en opnieuw geprobeerd, dus een trage database-write verandert één event in drie. Antwoord eerst met 200, en ga daarna pas aan het werk.
Goed dedupliceren
De Set hierboven is prima voor een demo, maar groeit voor altijd en is na een herstart weer leeg. Sla in productie de delivery-id op op een plek waar die blijft bestaan:
// 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;
}Een unieke index op delivery_id maakt dit veilig, zelfs wanneer twee retries tegelijk binnenkomen.
Lokaal ontwikkelen
TicketWave weigert endpoints op private of loopback-adressen, dus http://localhost:3000 kan niet rechtstreeks worden gebruikt. Zet een tunnel voor je lokale server en registreer de publieke HTTPS-URL die je krijgt:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Registreer de geprinte https://….ngrok-free.app/webhooks/ticketwave-URL als je endpoint, en gebruik Send test event om de koppeling te testen voordat je een echt ticket aanraakt.
Gratis tunnel-URL's veranderen bij elke herstart. Werk de endpoint-URL in het dashboard bij wanneer dat gebeurt, anders zullen de deliveries gaan mislukken.
Verder gaan
| Wil je… | Doe dan dit |
|---|---|
| Hoge berichtvolumes verwerken | Zet de payload in de route op een queue en verwerk hem elders |
| Meerdere endpoints draaien | Elk heeft zijn eigen geheim — kies per route de juiste |
| Een mislukte delivery debuggen | Open de delivery in Dashboard → Webhooks om het exacte verzoek en je antwoord te zien |
| Een event opnieuw verzenden | Gebruik Retry Webhook op de detailpagina van de delivery |
Volgende stappen
- Event Reference (De payload van elk event)
- Webhooks (Ondertekening, retries en deliverygeschiedenis)
How is this guide?
