Example Server
A complete, runnable Express server that receives and verifies TicketWave webhooks.
Example Webhook Server
A minimal but production-shaped receiver in Express: it verifies signatures, protects against replays, deduplicates retries and answers before doing any work.
Copy it, point an endpoint at it, done.
Setup
Create the project
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvExpress 5 is used below, but the code works unchanged on Express 4.
Add the server
Save the file from the next section as server.js.
Set your environment variables
Create a .env file in the root of your project and add the following environment variables:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Copy the secret from Dashboard → Webhooks → Endpoints and pass it in
TICKETWAVE_WEBHOOK_SECRET. - Choose an open port on your machine and set it in the
PORTvariable.
Never hardcode the secret in server.js or commit it. Anyone holding it can forge requests that pass your signature check.
Run it
node server.jsThen hit Send test event on your endpoint in the dashboard and watch the console.
The 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}`);
});Why the code looks like this
Four details are easy to get wrong, and all four are silent failures.
express.raw instead of express.json
The signature covers the exact bytes TicketWave sent. express.json() parses the body into an object; re-serialising it can change key order or whitespace and the digest will no longer match.
If your app uses express.json() globally, mount it after the webhook route, or scope the raw parser to the webhook path exactly as shown above. Otherwise the JSON parser wins and req.body is an object, not a Buffer.
timingSafeEqual instead of ===
Comparing strings with === returns as soon as two bytes differ. The time that takes leaks how much of the signature was correct, which is enough to brute-force one byte at a time. crypto.timingSafeEqual always takes the same time.
It also throws when the two buffers have different lengths, which is why the length is checked first.
The timestamp check
Without it, someone who captured a valid request could replay it forever. Because the timestamp is part of the signed payload, it cannot be swapped for a fresh one without breaking the signature.
Replying before working
You have 10 seconds. Anything slower is treated as a failure and retried, so a slow database write turns one event into three. Reply 200 first, then work.
Deduplicating properly
The Set above is fine for a demo but grows forever and is empty again after a restart. In production, store the delivery id where it survives:
// 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 unique index on delivery_id makes this safe even when two retries arrive at the same time.
Developing locally
TicketWave refuses endpoints on private or loopback addresses, so http://localhost:3000 cannot be used directly. Put a tunnel in front of your local server and register the public HTTPS URL it gives you:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Register the printed https://….ngrok-free.app/webhooks/ticketwave URL as your endpoint, and use Send test event to check the wiring before touching a real ticket.
Free tunnel URLs change every restart. Update the endpoint URL in the dashboard when it does, or the deliveries will start failing.
Going Further
| 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 |
Next Steps
- Event Reference (The payload of every event)
- Webhooks (Signing, retries and delivery history)
How is this guide?
