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 dotenvO 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- Copie o segredo de Dashboard → Webhooks → Endpoints e passe-o em
TICKETWAVE_WEBHOOK_SECRET. - 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.jsDepois clique em Send test event no endpoint no dashboard e acompanhe o console.
O 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 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:3000Registre 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 mensagens | Envie o payload para uma fila na rota e processe-o em outro lugar |
| Executar vários endpoints | Cada um tem seu próprio segredo — escolha o correto por rota |
| Depurar uma entrega com falha | Abra a entrega em Dashboard → Webhooks para ver a requisição exata e sua resposta |
| Reenviar um evento | Use Retry Webhook na página de detalhes da entrega |
Próximos Passos
- Event Reference (O payload de cada evento)
- Webhooks (Assinatura, tentativas e histórico de entregas)
How is this guide?
