TicketWave Logo
Webhooks

Exemple de serveur

Un serveur Express complet et exécutable qui reçoit et vérifie les webhooks TicketWave.

Exemple de serveur de webhooks

Un récepteur Express minimal mais de niveau production : il vérifie les signatures, protège contre les relectures, déduplique les tentatives et répond avant d’effectuer le moindre traitement.

Copiez-le, pointez un endpoint dessus, et c’est terminé.

Configuration

Créer le projet

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

Express 5 est utilisé ci-dessous, mais le code fonctionne sans modification sur Express 4.

Ajouter le serveur

Enregistrez le fichier de la section suivante sous le nom server.js.

Définir vos variables d’environnement

Créez un fichier .env à la racine de votre projet et ajoutez les variables d’environnement suivantes :

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Copiez le secret depuis Dashboard → Webhooks → Endpoints et renseignez-le dans TICKETWAVE_WEBHOOK_SECRET.
  2. Choisissez un port libre sur votre machine et définissez-le dans la variable PORT.

Ne mettez jamais le secret en dur dans server.js et ne le commitez pas. Toute personne qui le possède peut forger des requêtes qui passeront votre vérification de signature.

Lancer le serveur

node server.js

Ensuite, cliquez sur Send test event sur votre endpoint dans le dashboard et surveillez la console.

Le serveur

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

Pourquoi le code est écrit comme ça

Quatre détails sont faciles à rater, et les quatre échouent sans message d’erreur.

express.raw au lieu de express.json

La signature couvre les octets exacts envoyés par TicketWave. express.json() transforme le corps en objet ; le re-sérialiser peut modifier l’ordre des clés ou les espaces, et le digest ne correspondra plus.

Si votre application utilise express.json() globalement, montez-le après la route du webhook, ou limitez le parseur brut au chemin du webhook exactement comme montré ci-dessus. Sinon, le parseur JSON prend le dessus et req.body est un objet, pas un Buffer.

timingSafeEqual au lieu de ===

Comparer des chaînes avec === s’arrête dès que deux octets diffèrent. Le temps que cela prend révèle quelle partie de la signature était correcte, ce qui suffit pour brute-forcer un octet à la fois. crypto.timingSafeEqual prend toujours le même temps.

Il lève aussi une exception lorsque les deux buffers ont des longueurs différentes, d’où la vérification préalable de la longueur.

La vérification du timestamp

Sans elle, quelqu’un qui aurait capturé une requête valide pourrait la rejouer indéfiniment. Comme le timestamp fait partie de la charge utile signée, il ne peut pas être remplacé par un plus récent sans casser la signature.

Répondre avant de traiter

Vous avez 10 secondes. Tout ce qui est plus lent est considéré comme un échec et relancé, donc une écriture lente en base de données transforme un événement en trois. Répondez d’abord 200, puis traitez.

Déduplication correcte

Le Set ci-dessus convient pour une démo, mais il grossit sans fin et est à nouveau vide après un redémarrage. En production, stockez l’identifiant de livraison dans un endroit où il survit :

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

Un index unique sur delivery_id rend cela sûr même lorsque deux tentatives arrivent en même temps.

Développement en local

TicketWave refuse les endpoints sur des adresses privées ou de loopback, donc http://localhost:3000 ne peut pas être utilisé directement. Placez un tunnel devant votre serveur local et enregistrez l’URL HTTPS publique qu’il vous fournit :

# ngrok
ngrok http 3000

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

Enregistrez l’URL https://….ngrok-free.app/webhooks/ticketwave affichée comme endpoint, et utilisez Send test event pour vérifier le câblage avant de toucher à un vrai ticket.

Les URL de tunnel gratuites changent à chaque redémarrage. Mettez à jour l’URL de l’endpoint dans le dashboard quand cela arrive, sinon les livraisons commenceront à échouer.

Aller plus loin

Want to…Do this
Gérer un volume élevé de messagesEnvoyer la charge utile dans une file d’attente dans la route et la traiter ailleurs
Exécuter plusieurs endpointsChacun a son propre secret — choisissez le bon pour chaque route
Déboguer une livraison en échecOuvrez la livraison dans Dashboard → Webhooks pour voir la requête exacte et votre réponse
Renvoyer un événementUtilisez Retry Webhook sur la page de détail de la livraison

Étapes suivantes

  • Event Reference (La charge utile de chaque événement)
  • Webhooks (Signature, tentatives et historique des livraisons)

How is this guide?