Példa szerver
Egy teljes, futtatható Express szerver, amely TicketWave webhookokat fogad és ellenőriz.
Példa webhook szerver
Egy minimális, de éles használatra formált fogadó Expressben: ellenőrzi az aláírásokat, véd az ismételt lejátszás ellen, kiszűri az ismételt próbálkozásokat, és még a munka megkezdése előtt válaszol.
Másold be, állíts rá egy végpontot, és kész.
Beállítás
A projekt létrehozása
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvAz alábbi példában az Express 5 szerepel, de a kód változtatás nélkül működik Express 4-en is.
A szerver hozzáadása
Mentsd el a következő szakasz fájlját server.js néven.
Környezeti változók beállítása
Hozz létre egy .env fájlt a projekt gyökerében, és add hozzá a következő környezeti változókat:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Másold ki a titkot a Dashboard → Webhooks → Endpoints részből, és add meg a
TICKETWAVE_WEBHOOK_SECRETértékeként. - Válassz egy szabad portot a gépeden, és állítsd be a
PORTváltozóban.
Soha ne írd be hardcode-olva a titkot a server.js fájlba, és ne committold. Bárki, aki megszerzi, hamis kéréseket tud küldeni, amelyek átmennek az aláírás-ellenőrzésen.
Futtatás
node server.jsEzután kattints az endpointodnál a dashboardon a Send test event gombra, és figyeld a konzolt.
A szerver
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}`);
});Miért néz ki így a kód
Négy rész könnyen elrontható, és mind a négy csendes hibát eredményez.
express.raw az express.json helyett
Az aláírás a TicketWave által küldött pontos bájtokra épül. Az express.json() objektummá alakítja a törzset; az újbóli sorosítás megváltoztathatja a kulcsok sorrendjét vagy a whitespace-t, és az összegzés már nem fog egyezni.
Ha az alkalmazásod globálisan használja az express.json()-t, akkor a webhook útvonal után csatold be, vagy korlátozd a raw parser használatát pontosan a webhook útvonalra, ahogy fent látható. Különben a JSON parser nyer, és a req.body egy objektum lesz, nem pedig egy Buffer.
timingSafeEqual az === helyett
A stringek ===-szel való összehasonlítása azonnal visszatér, amint két bájt eltér. Az így eltelt idő elárulja, hogy az aláírásból mennyi volt helyes, és ez elég ahhoz, hogy bájtonként brute force-szal feltörjék. A crypto.timingSafeEqual mindig ugyanannyi ideig fut.
Az is igaz, hogy hibát dob, ha a két buffer hossza eltér, ezért kell előbb a hosszát ellenőrizni.
Az időbélyeg-ellenőrzés
Enélkül bárki, aki elfogott egy érvényes kérést, örökké újrajátszhatná. Mivel az időbélyeg az aláírt payload része, nem lehet frissre cserélni az aláírás megsértése nélkül.
Válaszadás a munka előtt
10 másodperced van. Ami ennél lassabb, azt hibának tekintik és újrapróbálják, így egy lassú adatbázis-írás egy eseményből hármat csinál. Előbb válaszolj 200-zal, aztán dolgozz.
A duplikációk helyes kiszűrése
A fenti Set demóra megfelel, de örökké nő, és újraindítás után üres lesz. Éles környezetben tárold a delivery id-t olyan helyen, ahol megmarad:
// 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;
}A delivery_id-n lévő egyedi index akkor is biztonságossá teszi ezt, ha két újrapróbálkozás egyszerre érkezik.
Helyi fejlesztés
A TicketWave elutasítja a privát vagy loopback címeken lévő végpontokat, ezért a http://localhost:3000 közvetlenül nem használható. Tegyél egy tunnelt a helyi szervered elé, és regisztráld azt a nyilvános HTTPS URL-t, amit ad:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Regisztráld a kiírt https://….ngrok-free.app/webhooks/ticketwave URL-t végpontként, és használd a Send test event gombot a bekötés ellenőrzésére, mielőtt valódi tickethez nyúlnál.
Az ingyenes tunnel URL-ek minden újraindításkor megváltoznak. Ilyenkor frissítsd az endpoint URL-jét a dashboardon, különben a kézbesítések hibára futnak.
További lehetőségek
| 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 |
Következő lépések
- Event Reference (Minden esemény payloadja)
- Webhooks (Aláírás, újrapróbálkozások és kézbesítési előzmények)
How is this guide?
