Exempelserver
En komplett, körbar Express-server som tar emot och verifierar TicketWave-webhooks.
Exempel på webhook-server
En minimal men produktionslik mottagare i Express: den verifierar signaturer, skyddar mot återuppspelningar, deduplicerar omförsök och svarar innan den gör något arbete.
Kopiera den, peka en endpoint mot den, klart.
Setup
Skapa projektet
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvExpress 5 används nedan, men koden fungerar oförändrat i Express 4.
Lägg till servern
Spara filen från nästa avsnitt som server.js.
Ställ in dina miljövariabler
Skapa en .env-fil i roten av ditt projekt och lägg till följande miljövariabler:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Kopiera hemligheten från Dashboard → Webhooks → Endpoints och ange den i
TICKETWAVE_WEBHOOK_SECRET. - Välj en ledig port på din dator och ange den i variabeln
PORT.
Skriv aldrig in hemligheten direkt i server.js och checka inte in den. Alla som har den kan förfalska förfrågningar som passerar din signaturkontroll.
Kör den
node server.jsTryck sedan på Send test event på din endpoint i dashboarden och håll koll på konsolen.
The 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}`);
});Varför koden ser ut så här
Fyra detaljer är lätta att göra fel, och alla fyra ger tysta fel.
express.raw istället för express.json
Signaturen täcker de exakta bytes som TicketWave skickade. express.json() tolkar kroppen till ett objekt; om du serialiserar om det kan nyckelordning eller whitespace ändras och digesten kommer inte längre att matcha.
Om din app använder express.json() globalt, montera den efter webhook-routen, eller begränsa raw-parsern till exakt webhook-sökvägen som visas ovan. Annars vinner JSON-parsern och req.body är ett objekt, inte en Buffer.
timingSafeEqual istället för ===
Att jämföra strängar med === returnerar så fort två bytes skiljer sig. Tiden det tar avslöjar hur mycket av signaturen som var korrekt, vilket räcker för att brute-forca en byte i taget. crypto.timingSafeEqual tar alltid samma tid.
Den kastar också när de två buffrarna har olika längd, vilket är varför längden kontrolleras först.
Tidsstämpelkontrollen
Utan den skulle någon som fångat en giltig förfrågan kunna spela upp den om och om igen för alltid. Eftersom tidsstämpeln ingår i den signerade nyttolasten kan den inte bytas ut mot en färsk utan att signaturen bryts.
Svara innan du arbetar
Du har 10 sekunder. Allt långsammare räknas som ett fel och skickas om, så en långsam databasinskrivning gör ett event till tre. Svara 200 först, och gör sedan arbetet.
Deduplicera på rätt sätt
Set-objektet ovan fungerar för en demo men växer för alltid och är tomt igen efter en omstart. I produktion ska du lagra delivery-id:t där det överlever:
// 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;
}Ett unikt index på delivery_id gör detta säkert även när två omförsök kommer samtidigt.
Utveckla lokalt
TicketWave vägrar endpoints på privata eller loopback-adresser, så http://localhost:3000 kan inte användas direkt. Lägg en tunnel framför din lokala server och registrera den publika HTTPS-URL den ger dig:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Registrera den utskrivna URL:en https://….ngrok-free.app/webhooks/ticketwave som din endpoint, och använd Send test event för att kontrollera kopplingen innan du rör ett riktigt ticket.
Gratis tunnel-URL:er ändras vid varje omstart. Uppdatera endpoint-URL:en i dashboarden när det händer, annars börjar leveranserna misslyckas.
Vidare
| Want to… | Do this |
|---|---|
| Hantera hög meddelandevolym | Lägg nyttolasten i en kö i routen och bearbeta den någon annanstans |
| Köra flera endpoints | Var och en har sin egen hemlighet — välj rätt för varje route |
| Felsöka en misslyckad leverans | Öppna leveransen i Dashboard → Webhooks för att se exakt förfrågan och ditt svar |
| Skicka ett event igen | Använd Retry Webhook på detaljsidan för leveransen |
Nästa steg
- Event Reference (Nyttolasten för varje event)
- Webhooks (Signering, omförsök och leveranshistorik)
How is this guide?
