Παράδειγμα διακομιστή
Ένας πλήρης, εκτελέσιμος 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- Αντιγράψτε το secret από το Dashboard → Webhooks → Endpoints και περάστε το στο
TICKETWAVE_WEBHOOK_SECRET. - Επιλέξτε μια ανοιχτή θύρα στον υπολογιστή σας και ορίστε την στη μεταβλητή
PORT.
Ποτέ μην κάνετε hardcode το secret στο server.js ούτε να το κάνετε commit. Όποιος το έχει μπορεί να πλαστογραφήσει αιτήματα που περνούν τον έλεγχο υπογραφής σας.
Εκτελέστε το
node server.jsΈπειτα πατήστε Send test event στο endpoint σας στο dashboard και παρακολουθήστε την κονσόλα.
Ο 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}`);
});Γιατί ο κώδικας μοιάζει έτσι
Τέσσερις λεπτομέρειες είναι εύκολο να γίνουν λάθος, και και οι τέσσερις αποτυγχάνουν σιωπηλά.
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 volume | Push the payload onto a queue in the route and process it elsewhere |
| Run several endpoints | Each has its own secret — pick the right one per route |
| Debug a failing delivery | Open the delivery in Dashboard → Webhooks to see the exact request and your response |
| Re-send an event | Use Retry Webhook on the delivery detail page |
Επόμενα βήματα
- Event Reference (Το payload κάθε event)
- Webhooks (Signing, retries and delivery history)
How is this guide?
