Example Server
Server Express lengkap yang bisa dijalankan untuk menerima dan memverifikasi webhook TicketWave.
Contoh Server Webhook
Receiver minimal namun siap produksi di Express: memverifikasi signature, melindungi dari replay, mendeduplikasi retry, dan membalas sebelum melakukan pekerjaan apa pun.
Salin, arahkan endpoint ke sini, selesai.
Persiapan
Buat proyeknya
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenvDi bawah ini digunakan Express 5, tetapi kodenya tetap berjalan tanpa perubahan di Express 4.
Tambahkan server
Simpan file dari bagian berikutnya sebagai server.js.
Atur variabel lingkungan Anda
Buat file .env di root proyek Anda dan tambahkan variabel lingkungan berikut:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Salin secret dari Dashboard → Webhooks → Endpoints dan masukkan ke
TICKETWAVE_WEBHOOK_SECRET. - Pilih port yang kosong di mesin Anda dan setel di variabel
PORT.
Jangan pernah menulis secret secara hardcode di server.js atau meng-commit-nya. Siapa pun yang memilikinya bisa memalsukan request yang lolos pemeriksaan signature Anda.
Jalankan
node server.jsLalu klik Send test event pada endpoint Anda di dashboard dan pantau konsolnya.
Server
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);
}
// Signature dibangun dari body mentah, jadi simpan byte aslinya apa adanya.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));
// Tolak apa pun yang lebih lama dari ini, supaya request yang tertangkap tidak bisa di-replay nanti.
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. Timestamp harus masih baru
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. Hitung ulang HMAC atas `${timestamp}.${rawBody}`
const Expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${Timestamp}.${req.body}`)
.digest('hex');
// 3. Bandingkan dalam 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);
}
// Ingat delivery id yang sudah diproses, karena retry mengirim event yang sama lagi.
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);
// Balas segera - Anda punya 10 detik, dan respons yang lambat akan di-retry.
res.status(200).json({ received: true });
// Abaikan delivery yang sudah pernah diproses
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;
// Tombol test di dashboard mengirim ini
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:
// Tipe event baru ditambahkan dari waktu ke waktu - jangan pernah throw untuk event yang belum Anda kenal.
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});Mengapa kodenya terlihat seperti ini
Ada empat detail yang mudah salah, dan keempatnya adalah kegagalan yang diam-diam.
express.raw вместо express.json
Signature mencakup byte persis yang dikirim TicketWave. express.json() mengubah body menjadi objek; saat di-serialisasi ulang, urutan key atau whitespace bisa berubah dan digest tidak lagi cocok.
Jika aplikasi Anda memakai express.json() secara global, pasang middleware itu setelah route webhook, atau batasi parser raw hanya ke path webhook seperti contoh di atas. Kalau tidak, parser JSON akan menang dan req.body menjadi objek, bukan Buffer.
timingSafeEqual вместо ===
Membandingkan string dengan === akan berhenti begitu dua byte berbeda. Waktu yang dibutuhkan membocorkan seberapa banyak signature yang benar, dan itu cukup untuk brute-force satu byte demi satu byte. crypto.timingSafeEqual selalu memakan waktu yang sama.
Fungsi ini juga melempar error saat dua buffer memiliki panjang berbeda, itulah sebabnya panjangnya dicek terlebih dahulu.
Pemeriksaan timestamp
Tanpa ini, seseorang yang menangkap request valid bisa me-replay-nya selamanya. Karena timestamp adalah bagian dari payload yang ditandatangani, timestamp itu tidak bisa diganti dengan yang baru tanpa merusak signature.
Membalas sebelum bekerja
Anda punya 10 detik. Apa pun yang lebih lambat dianggap gagal dan akan di-retry, jadi penulisan database yang lambat bisa mengubah satu event menjadi tiga. Balas 200 dulu, lalu kerjakan.
Mendeduplikasi dengan benar
Set di atas cukup untuk demo, tetapi akan terus membesar dan kosong lagi setelah restart. Di produksi, simpan delivery id di tempat yang tetap ada:
// Contoh dengan database SQL apa pun
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;
}Unique index pada delivery_id membuat ini aman bahkan saat dua retry datang bersamaan.
Pengembangan lokal
TicketWave menolak endpoint pada alamat private atau loopback, jadi http://localhost:3000 tidak bisa digunakan langsung. Pasang tunnel di depan server lokal Anda dan daftarkan URL HTTPS publik yang diberikannya:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Daftarkan URL https://….ngrok-free.app/webhooks/ticketwave yang tercetak sebagai endpoint Anda, lalu gunakan Send test event untuk mengecek koneksinya sebelum menyentuh tiket sungguhan.
URL tunnel gratis berubah setiap restart. Perbarui URL endpoint di dashboard saat itu terjadi, atau delivery akan mulai gagal.
Langkah Selanjutnya
| Ingin… | Lakukan ini |
|---|---|
| Menangani volume pesan tinggi | Dorong payload ke queue di route lalu proses di tempat lain |
| Menjalankan beberapa endpoint | Masing-masing punya secret sendiri — pilih yang tepat untuk tiap route |
| Men-debug delivery yang gagal | Buka delivery di Dashboard → Webhooks untuk melihat request persisnya dan respons Anda |
| Mengirim ulang event | Gunakan Retry Webhook di halaman detail delivery |
Langkah Berikutnya
- Event Reference (Payload dari setiap event)
- Webhooks (Signing, retries, dan riwayat delivery)
How is this guide?
