TicketWave Logo

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 dotenv

Express 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
  1. Copy the secret from Dashboard → Webhooks → Endpoints and pass it in TICKETWAVE_WEBHOOK_SECRET.
  2. Choose an open port on your machine and set it in the PORT variable.

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.js

Then hit Send test event on your endpoint in the dashboard and watch the console.

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

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

Register 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 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

Next Steps

How is this guide?