TicketWave Logo
Webhooks

Παράδειγμα διακομιστή

Ένας πλήρης, εκτελέσιμος Express server που λαμβάνει και επαληθεύει TicketWave webhooks.

Παράδειγμα Webhook Server

Ένας ελάχιστος αλλά έτοιμος για παραγωγή receiver σε Express: επαληθεύει υπογραφές, προστατεύει από επαναλήψεις, αποδιπλοποιεί τα retries και απαντά πριν κάνει οποιαδήποτε εργασία.

Αντέγραψέ το, δείξε ένα endpoint προς αυτό, και τέλος.

Ρύθμιση

Δημιουργήστε το project

mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenv

Παρακάτω χρησιμοποιείται το Express 5, αλλά ο κώδικας λειτουργεί χωρίς αλλαγές και στο Express 4.

Προσθέστε τον server

Αποθηκεύστε το αρχείο από την επόμενη ενότητα ως server.js.

Ορίστε τις μεταβλητές περιβάλλοντος

Δημιουργήστε ένα αρχείο .env στη ρίζα του project σας και προσθέστε τις ακόλουθες μεταβλητές περιβάλλοντος:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Αντιγράψτε το secret από το Dashboard → Webhooks → Endpoints και περάστε το στο TICKETWAVE_WEBHOOK_SECRET.
  2. Επιλέξτε μια ανοιχτή θύρα στον υπολογιστή σας και ορίστε την στη μεταβλητή PORT.

Ποτέ μην κάνετε hardcode το secret στο server.js ούτε να το κάνετε commit. Όποιος το έχει μπορεί να πλαστογραφήσει αιτήματα που περνούν τον έλεγχο υπογραφής σας.

Εκτελέστε το

node server.js

Έπειτα πατήστε Send test event στο endpoint σας στο dashboard και παρακολουθήστε την κονσόλα.

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

Γιατί ο κώδικας μοιάζει έτσι

Τέσσερις λεπτομέρειες είναι εύκολο να γίνουν λάθος, και και οι τέσσερις αποτυγχάνουν σιωπηλά.

express.raw αντί για express.json

Η υπογραφή καλύπτει τα ακριβή bytes που έστειλε το TicketWave. Το express.json() κάνει parse το body σε αντικείμενο· αν το ξανα-serialise, μπορεί να αλλάξει η σειρά των keys ή τα κενά και το digest δεν θα ταιριάζει πλέον.

Αν η εφαρμογή σας χρησιμοποιεί express.json() καθολικά, κάντε mount το μετά από το webhook route, ή περιορίστε τον raw parser ακριβώς στο webhook path όπως φαίνεται παραπάνω. Αλλιώς ο JSON parser υπερισχύει και το req.body είναι αντικείμενο, όχι Buffer.

timingSafeEqual αντί για ===

Η σύγκριση strings με === επιστρέφει μόλις διαφέρει δύο bytes. Ο χρόνος που χρειάζεται αποκαλύπτει πόσο σωστό ήταν το signature, κάτι που αρκεί για brute-force ένα byte τη φορά. Το crypto.timingSafeEqual χρειάζεται πάντα τον ίδιο χρόνο.

Επίσης πετάει εξαίρεση όταν τα δύο buffers έχουν διαφορετικό μήκος, γι’ αυτό ελέγχεται πρώτα το μήκος.

Ο έλεγχος timestamp

Χωρίς αυτόν, κάποιος που κατέγραψε ένα έγκυρο αίτημα θα μπορούσε να το κάνει replay για πάντα. Επειδή το timestamp είναι μέρος του signed payload, δεν μπορεί να αντικατασταθεί με ένα φρέσκο χωρίς να σπάσει η υπογραφή.

Απάντηση πριν από την επεξεργασία

Έχετε 10 δευτερόλεπτα. Οτιδήποτε πιο αργό θεωρείται αποτυχία και γίνεται retry, οπότε ένα αργό database write μετατρέπει ένα event σε τρία. Απαντήστε πρώτα 200 και μετά δουλέψτε.

Σωστή αποδιπλοποίηση

Το Set παραπάνω είναι εντάξει για demo, αλλά μεγαλώνει για πάντα και αδειάζει ξανά μετά από restart. Σε production, αποθηκεύστε το delivery id σε μέρος όπου επιβιώνει:

// 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;
}

Ένα unique index στο delivery_id το κάνει ασφαλές ακόμη κι όταν δύο retries φτάσουν την ίδια στιγμή.

Τοπική ανάπτυξη

Το TicketWave απορρίπτει endpoints σε ιδιωτικές ή loopback διευθύνσεις, οπότε το http://localhost:3000 δεν μπορεί να χρησιμοποιηθεί απευθείας. Βάλτε ένα tunnel μπροστά από τον τοπικό server σας και καταχωρίστε το δημόσιο HTTPS URL που σας δίνει:

# ngrok
ngrok http 3000

# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000

Καταχωρίστε το εμφανιζόμενο https://….ngrok-free.app/webhooks/ticketwave URL ως endpoint σας και χρησιμοποιήστε το Send test event για να ελέγξετε τη σύνδεση πριν αγγίξετε πραγματικό ticket.

Τα δωρεάν tunnel URLs αλλάζουν σε κάθε επανεκκίνηση. Ενημερώστε το endpoint URL στο dashboard όταν συμβεί αυτό, αλλιώς οι παραδόσεις θα αρχίσουν να αποτυγχάνουν.

Περαιτέρω βήματα

Want to…Do this
Handle high message volumePush the payload onto a queue in the route and process it elsewhere
Run several endpointsEach has its own secret — pick the right one per route
Debug a failing deliveryOpen the delivery in Dashboard → Webhooks to see the exact request and your response
Re-send an eventUse Retry Webhook on the delivery detail page

Επόμενα βήματα

How is this guide?