Example Server
En komplett, kjørbar Express-server som mottar og verifiserer TicketWave-webhooks.
Eksempel på webhook-server
En minimal, men produksjonsaktig mottaker i Express: den verifiserer signaturer, beskytter mot replay-angrep, dedupliserer retries og svarer før den gjør noe arbeid.
Kopier den, pek et endepunkt mot den, ferdig.
Oppsett
Opprett prosjektet
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvExpress 5 brukes nedenfor, men koden fungerer uendret på Express 4.
Legg til serveren
Lagre filen fra neste seksjon som server.js.
Sett miljøvariablene dine
Opprett en .env-fil i roten av prosjektet ditt og legg til følgende miljøvariabler:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Kopier hemmeligheten fra Dashboard → Webhooks → Endpoints og bruk den i
TICKETWAVE_WEBHOOK_SECRET. - Velg en åpen port på maskinen din og sett den i
PORT-variabelen.
Aldri hardkod hemmeligheten i server.js eller sjekk den inn i repoet. Alle som har den kan forfalske forespørsler som passerer signaturkontrollen din.
Kjør den
node server.jsTrykk deretter på Send test event på endepunktet ditt i dashboardet og følg med i konsollen.
Serveren
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);
}
// Signaturen bygges over den RÅ kroppen, så behold de urørte bytesene.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));
// Avvis alt som er eldre enn dette, slik at en fanget forespørsel ikke kan spilles av senere.
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. Tidsstempelet må være nylig
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. Beregn HMAC-en på nytt over `${timestamp}.${rawBody}`
const Expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${Timestamp}.${req.body}`)
.digest('hex');
// 3. Sammenlign i konstant tid
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);
}
// Husk håndterte leverings-ID-er, fordi et retry sender samme hendelse igjen.
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);
// Svar umiddelbart - du har 10 sekunder, og trege svar blir forsøkt på nytt.
res.status(200).json({ received: true });
// Ignorer en levering vi allerede har håndtert
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;
// Testknappen i dashboardet sender dette
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:
// Nye hendelsestyper legges til over tid - kast aldri på noe du ikke kjenner igjen.
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});Hvorfor koden ser slik ut
Fire detaljer er lette å gjøre feil, og alle fire er stille feil.
express.raw i stedet for express.json
Signaturen dekker de eksakte bytesene TicketWave sendte. express.json() parser kroppen til et objekt; hvis du serialiserer den på nytt, kan nøkkelrekkefølgen eller mellomrom endres, og digesten vil ikke lenger matche.
Hvis appen din bruker express.json() globalt, monter den etter webhook-ruten, eller avgrens raw-parseren til webhook-stien nøyaktig som vist ovenfor. Ellers vinner JSON-parseren, og req.body er et objekt, ikke en Buffer.
timingSafeEqual i stedet for ===
Å sammenligne strenger med === returnerer så snart to bytes er forskjellige. Tiden det tar avslører hvor mye av signaturen som var riktig, og det er nok til å brute-force én byte om gangen. crypto.timingSafeEqual bruker alltid like lang tid.
Den kaster også når de to bufferne har ulik lengde, og derfor sjekkes lengden først.
Tidsstempelsjekken
Uten den kunne noen som fanget opp en gyldig forespørsel spille den av igjen og igjen. Fordi tidsstempelet er en del av den signerte nyttelasten, kan det ikke byttes ut med et ferskt uten å ødelegge signaturen.
Svar før du jobber
Du har 10 sekunder. Alt som tar lengre tid blir behandlet som en feil og forsøkt på nytt, så en treg database-skriving gjør én hendelse om til tre. Svar 200 først, og jobb deretter.
Deduplisering på riktig måte
Set-en ovenfor er grei for en demo, men vokser for alltid og er tom igjen etter en omstart. I produksjon bør du lagre leverings-ID-en et sted den overlever:
// 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;
}En unik indeks på delivery_id gjør dette trygt selv når to retries kommer samtidig.
Lokal utvikling
TicketWave avviser endepunkter på private eller loopback-adresser, så http://localhost:3000 kan ikke brukes direkte. Sett en tunnel foran den lokale serveren din og registrer den offentlige HTTPS-URL-en den gir deg:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Registrer den utskrevne https://….ngrok-free.app/webhooks/ticketwave-URL-en som endepunktet ditt, og bruk Send test event for å sjekke koblingen før du rører en ekte ticket.
Gratis tunnel-URL-er endres ved hver omstart. Oppdater endepunkt-URL-en i dashboardet når det skjer, ellers vil leveringene begynne å feile.
Videre
| Want to… | Do this |
|---|---|
| Håndtere høy meldingsmengde | Legg nyttelasten i en kø i ruten og prosesser den et annet sted |
| Kjøre flere endepunkter | Hvert har sin egen hemmelighet — velg riktig én per rute |
| Feilsøke en mislykket levering | Åpne leveringen i Dashboard → Webhooks for å se den eksakte forespørselen og svaret ditt |
| Sende en hendelse på nytt | Bruk Retry Webhook på detaljsiden for leveringen |
Neste steg
- Event Reference (Nyttelasten for hver hendelse)
- Webhooks (Signering, retries og leveringshistorikk)
How is this guide?
