TicketWave Logo
Webhooks

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 dotenv

Unten 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
  1. Kopiere das Secret aus Dashboard → Webhooks → Endpoints und trage es in TICKETWAVE_WEBHOOK_SECRET ein.
  2. 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.js

Klicke dann im Dashboard auf Deinem Endpoint auf Send test event und beobachte die Konsole.

Der Server

server.js
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:3000

Registriere 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 verarbeitenSchieb die Nutzlast in der Route in eine Queue und verarbeite sie woanders
Mehrere Endpoints betreibenJeder 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 sendenNutze Retry Webhook auf der Detailseite der Delivery

Nächste Schritte

How is this guide?