Beispiel-Server
Ein vollständiger, ausführbarer Express-Server, der TicketWave-Webhooks empfängt und verifiziert.
Beispiel-Webhooks-Server
Ein minimaler, aber produktionsnaher Empfänger in Express: Er prüft Signaturen, schützt vor Replays, dedupliziert Retries und antwortet, bevor er irgendeine Arbeit erledigt.
Kopier ihn, zeig einen Endpoint darauf, fertig.
Einrichtung
Das Projekt erstellen
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvUnten wird Express 5 verwendet, aber der Code funktioniert unverändert auch mit Express 4.
Den Server hinzufügen
Speichere die Datei aus dem nächsten Abschnitt als server.js.
Deine Umgebungsvariablen setzen
Erstelle im Stammverzeichnis Deines Projekts eine .env-Datei und füge die folgenden Umgebungsvariablen hinzu:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Kopiere das Secret aus Dashboard → Webhooks → Endpoints und trage es in
TICKETWAVE_WEBHOOK_SECRETein. - Wähle einen freien Port auf Deinem Rechner und setze ihn in der
PORT-Variable.
Hardcode das Secret niemals in server.js und committe es nicht. Wer es besitzt, kann Requests fälschen, die Deine Signaturprüfung bestehen.
Starte es
node server.jsKlicke dann im Dashboard auf Deinem Endpoint auf Send test event und beobachte die Konsole.
Der 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}`);
});Warum der Code so aussieht
Vier Details sind leicht falsch zu machen, und alle vier scheitern still.
express.raw statt express.json
Die Signatur deckt die exakten Bytes ab, die TicketWave gesendet hat. express.json() parst den Body in ein Objekt; beim erneuten Serialisieren können sich die Reihenfolge der Keys oder Leerzeichen ändern, und der Digest stimmt dann nicht mehr.
Wenn Deine App express.json() global verwendet, mounte es nach der Webhook-Route oder scoping den Raw-Parser exakt auf den Webhook-Pfad, wie oben gezeigt. Sonst gewinnt der JSON-Parser und req.body ist ein Objekt, kein Buffer.
timingSafeEqual statt ===
Der Vergleich von Strings mit === endet, sobald sich zwei Bytes unterscheiden. Die dafür benötigte Zeit verrät, wie viel von der Signatur korrekt war, und das reicht aus, um Byte für Byte zu brute-forcen. crypto.timingSafeEqual braucht immer gleich lang.
Außerdem wirft es, wenn die beiden Buffer unterschiedliche Längen haben, deshalb wird die Länge zuerst geprüft.
Die Zeitstempelprüfung
Ohne sie könnte jemand, der einen gültigen Request abgefangen hat, ihn endlos erneut senden. Da der Zeitstempel Teil der signierten Nutzlast ist, kann er nicht gegen einen frischen ausgetauscht werden, ohne die Signatur zu brechen.
Vor der Arbeit antworten
Du hast 10 Sekunden. Alles, was langsamer ist, wird als Fehler behandelt und erneut versucht, also macht ein langsamer Datenbank-Write aus einem Event drei. Antworte zuerst mit 200, dann arbeite weiter.
Richtig deduplizieren
Das Set oben ist für ein Demo völlig okay, wächst aber für immer und ist nach einem Neustart wieder leer. In der Produktion solltest Du die Delivery-ID dort speichern, wo sie erhalten bleibt:
// 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;
}Ein eindeutiger Index auf delivery_id macht das selbst dann sicher, wenn zwei Retries gleichzeitig ankommen.
Lokal entwickeln
TicketWave lehnt Endpoints auf privaten oder Loopback-Adressen ab, daher kann http://localhost:3000 nicht direkt verwendet werden. Setz einen Tunnel vor Deinen lokalen Server und registriere die öffentliche HTTPS-URL, die er Dir gibt:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Registriere die ausgegebene URL https://….ngrok-free.app/webhooks/ticketwave als Deinen Endpoint und nutze Send test event, um die Verbindung zu prüfen, bevor Du einen echten Ticket berührst.
Kostenlose Tunnel-URLs ändern sich bei jedem Neustart. Aktualisiere die Endpoint-URL im Dashboard, wenn das passiert, sonst schlagen die Deliveries fehl.
Weiter geht's
| Möchtest Du… | Dann mach das |
|---|---|
| Hohes Nachrichtenaufkommen verarbeiten | Schieb die Nutzlast in der Route in eine Queue und verarbeite sie woanders |
| Mehrere Endpoints betreiben | Jeder hat sein eigenes Secret — wähle pro Route das richtige |
| Eine fehlgeschlagene Delivery debuggen | Öffne die Delivery in Dashboard → Webhooks, um den exakten Request und Deine Antwort zu sehen |
| Ein Event erneut senden | Nutze Retry Webhook auf der Detailseite der Delivery |
Nächste Schritte
- Event Reference (Die Nutzlast jedes Events)
- Webhooks (Signierung, Retries und Delivery-Historie)
How is this guide?
