TicketWave Logo
Webhooks

Servidor de ejemplo

Un servidor Express completo y ejecutable que recibe y verifica webhooks de TicketWave.

Servidor de ejemplo de webhooks

Un receptor mínimo pero con forma de producción en Express: verifica firmas, protege contra reintentos, deduplica reintentos y responde antes de hacer cualquier trabajo.

Cópialo, apunta un endpoint hacia él y listo.

Configuración

Crear el proyecto

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

Abajo se usa Express 5, pero el código funciona sin cambios en Express 4.

Añadir el servidor

Guarda el archivo de la siguiente sección como server.js.

Configurar tus variables de entorno

Crea un archivo .env en la raíz de tu proyecto y añade las siguientes variables de entorno:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Copia el secreto de Dashboard → Webhooks → Endpoints y pásalo en TICKETWAVE_WEBHOOK_SECRET.
  2. Elige un puerto libre en tu máquina y configúralo en la variable PORT.

Nunca pongas el secreto directamente en server.js ni lo subas al repositorio. Cualquiera que lo tenga puede falsificar solicitudes que pasen tu comprobación de firma.

Ejecutarlo

node server.js

Luego pulsa Send test event en tu endpoint del dashboard y mira la consola.

El servidor

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

Por qué el código es así

Hay cuatro detalles fáciles de hacer mal, y los cuatro fallan en silencio.

express.raw en lugar de express.json

La firma cubre los bytes exactos que envió TicketWave. express.json() convierte el cuerpo en un objeto; volver a serializarlo puede cambiar el orden de las claves o los espacios en blanco y el digest ya no coincidirá.

Si tu app usa express.json() globalmente, móntalo después de la ruta del webhook, o limita el parser raw exactamente a la ruta del webhook como se muestra arriba. Si no, el parser JSON gana y req.body es un objeto, no un Buffer.

timingSafeEqual en lugar de ===

Comparar cadenas con === devuelve el resultado en cuanto dos bytes difieren. El tiempo que tarda filtra cuánto de la firma era correcto, y eso basta para probar byte por byte. crypto.timingSafeEqual siempre tarda lo mismo.

Además, lanza un error cuando los dos buffers tienen longitudes distintas, por eso primero se comprueba la longitud.

La comprobación de la marca de tiempo

Sin ella, alguien que capturó una solicitud válida podría reproducirla para siempre. Como la marca de tiempo forma parte de la carga firmada, no puede sustituirse por una nueva sin romper la firma.

Responder antes de trabajar

Tienes 10 segundos. Cualquier cosa más lenta se trata como un fallo y se reintenta, así que una escritura lenta en la base de datos convierte un evento en tres. Responde 200 primero y luego trabaja.

Deduplicar correctamente

El Set de arriba está bien para una demo, pero crece para siempre y vuelve a estar vacío tras un reinicio. En producción, guarda el id de entrega en un lugar donde sobreviva:

// 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 índice único sobre delivery_id hace que esto sea seguro incluso cuando llegan dos reintentos al mismo tiempo.

Desarrollo local

TicketWave rechaza endpoints en direcciones privadas o de loopback, así que http://localhost:3000 no puede usarse directamente. Pon un túnel delante de tu servidor local y registra la URL pública HTTPS que te proporcione:

# ngrok
ngrok http 3000

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

Registra la URL https://….ngrok-free.app/webhooks/ticketwave que se imprima como tu endpoint, y usa Send test event para comprobar que todo está bien antes de tocar un ticket real.

Las URLs gratuitas de túnel cambian en cada reinicio. Actualiza la URL del endpoint en el dashboard cuando ocurra, o las entregas empezarán a fallar.

Ir más allá

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

Siguientes pasos

How is this guide?