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 dotenvAbajo 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- Copia el secreto de Dashboard → Webhooks → Endpoints y pásalo en
TICKETWAVE_WEBHOOK_SECRET. - 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.
El servidor
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:3000Registra 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 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 |
Siguientes pasos
- Event Reference (La carga útil de cada evento)
- Webhooks (Firma, reintentos e historial de entregas)
How is this guide?
