TicketWave Logo
Webhooks

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 dotenv

Express 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
  1. Kopiera hemligheten från Dashboard → Webhooks → Endpoints och ange den i TICKETWAVE_WEBHOOK_SECRET.
  2. 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.js

Tryck sedan på Send test event på din endpoint i dashboarden och håll koll på konsolen.

The 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}`);
});

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:3000

Registrera 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 meddelandevolymLägg nyttolasten i en kö i routen och bearbeta den någon annanstans
Köra flera endpointsVar 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 igenAnvänd Retry Webhook på detaljsidan för leveransen

Nästa steg

How is this guide?