TicketWave Logo
Webhooks

Exemplo de Servidor

Um servidor Express completo e executável que recebe e verifica webhooks do TicketWave.

Exemplo de Servidor de Webhook

Um receptor mínimo, mas com cara de produção, em Express: ele verifica assinaturas, protege contra replays, deduplica tentativas e responde antes de fazer qualquer trabalho.

Copie, aponte um endpoint para ele e pronto.

Configuração

Criar o projeto

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

O Express 5 é usado abaixo, mas o código funciona sem alterações no Express 4.

Adicionar o servidor

Salve o arquivo da próxima seção como server.js.

Definir suas variáveis de ambiente

Crie um arquivo .env na raiz do seu projeto e adicione as seguintes variáveis de ambiente:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Copie o segredo de Dashboard → Webhooks → Endpoints e passe-o em TICKETWAVE_WEBHOOK_SECRET.
  2. Escolha uma porta aberta na sua máquina e defina-a na variável PORT.

Nunca coloque o segredo diretamente em server.js nem faça commit dele. Qualquer pessoa que o tenha pode forjar requisições que passam na sua verificação de assinatura.

Executar

node server.js

Depois clique em Send test event no endpoint no dashboard e acompanhe o console.

O 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 que o código é assim

Quatro detalhes são fáceis de errar, e os quatro falham silenciosamente.

express.raw em vez de express.json

A assinatura cobre os bytes exatos que o TicketWave enviou. express.json() faz o parse do corpo em um objeto; reserializá-lo pode alterar a ordem das chaves ou os espaços em branco, e o digest deixará de bater.

Se o seu app usa express.json() globalmente, monte-o depois da rota do webhook, ou limite o parser raw exatamente ao caminho do webhook, como mostrado acima. Caso contrário, o parser JSON vence e req.body é um objeto, não um Buffer.

timingSafeEqual em vez de ===

Comparar strings com === retorna assim que dois bytes diferem. O tempo que isso leva revela quanto da assinatura estava correta, o que basta para forçar um byte por vez. crypto.timingSafeEqual sempre leva o mesmo tempo.

Ele também lança erro quando os dois buffers têm comprimentos diferentes, por isso o comprimento é verificado primeiro.

A verificação do timestamp

Sem ela, alguém que capturou uma requisição válida poderia reproduzi-la para sempre. Como o timestamp faz parte do payload assinado, ele não pode ser trocado por um novo sem quebrar a assinatura.

Responder antes de processar

Você tem 10 segundos. Qualquer coisa mais lenta é tratada como falha e reenviada, então uma gravação lenta no banco transforma um evento em três. Responda 200 primeiro e depois processe.

Fazendo deduplicação corretamente

O Set acima serve para uma demo, mas cresce para sempre e fica vazio de novo após uma reinicialização. Em produção, armazene o id da entrega em um lugar que persista:

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

Um índice único em delivery_id torna isso seguro mesmo quando duas tentativas chegam ao mesmo tempo.

Desenvolvendo localmente

O TicketWave recusa endpoints em endereços privados ou de loopback, então http://localhost:3000 não pode ser usado diretamente. Coloque um túnel na frente do seu servidor local e registre a URL HTTPS pública que ele fornecer:

# ngrok
ngrok http 3000

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

Registre a URL https://….ngrok-free.app/webhooks/ticketwave exibida como seu endpoint e use Send test event para verificar a integração antes de tocar em um ticket real.

URLs gratuitas de túnel mudam a cada reinicialização. Atualize a URL do endpoint no dashboard quando isso acontecer, ou as entregas começarão a falhar.

Indo Além

Quer…Faça isto
Lidar com alto volume de mensagensEnvie o payload para uma fila na rota e processe-o em outro lugar
Executar vários endpointsCada um tem seu próprio segredo — escolha o correto por rota
Depurar uma entrega com falhaAbra a entrega em Dashboard → Webhooks para ver a requisição exata e sua resposta
Reenviar um eventoUse Retry Webhook na página de detalhes da entrega

Próximos Passos

How is this guide?