Example Server
TicketWave 웹훅을 수신하고 검증하는 완전한 실행 가능한 Express 서버입니다.
예제 웹훅 서버
서명을 검증하고, 재전송을 방지하며, 중복 재시도를 제거하고, 어떤 작업을 하기 전에 먼저 응답하는, 최소하지만 프로덕션에 가까운 Express 수신기입니다.
복사해서 엔드포인트를 연결하면 끝입니다.
설정
프로젝트 만들기
mkdir ticketwave-webhooks
cd ticketwave-webhooks
npm init -y
npm install express dotenv아래에서는 Express 5를 사용하지만, 코드는 Express 4에서도 수정 없이 동작합니다.
환경 변수 설정하기
프로젝트 루트에 .env 파일을 만들고 다음 환경 변수를 추가하세요:
# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000- Dashboard → Webhooks → Endpoints에서 시크릿을 복사해
TICKETWAVE_WEBHOOK_SECRET에 넣으세요. - 컴퓨터에서 사용 가능한 포트를 선택해
PORT변수에 설정하세요.
시크릿을 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);
}
// 서명은 RAW 본문 전체를 기준으로 만들어지므로, 변경되지 않은 바이트를 그대로 유지해야 합니다.
app.use('/webhooks/ticketwave', express.raw({ type: 'application/json' }));
// 이보다 오래된 요청은 거부해서, 캡처된 요청이 나중에 재전송되는 것을 막습니다.
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. 타임스탬프는 최근 값이어야 합니다
const Age = Math.abs(Math.floor(Date.now() / 1000) - Number(Timestamp));
if (!Number.isFinite(Age) || Age > MAX_TIMESTAMP_AGE) return false;
// 2. `${timestamp}.${rawBody}`를 기준으로 HMAC을 다시 계산합니다
const Expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${Timestamp}.${req.body}`)
.digest('hex');
// 3. 상수 시간으로 비교합니다
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);
}
// 처리한 delivery id를 기억합니다. 재시도는 같은 이벤트를 다시 보내기 때문입니다.
const HandledDeliveries = new Set();
app.post('/webhooks/ticketwave', (req, res) => {
if (!verifySignature(req)) {
console.warn('유효하지 않은 서명이 있는 요청을 거부했습니다');
return res.status(401).json({ error: 'invalid signature' });
}
const DeliveryId = req.get('X-TicketWave-Delivery');
const Payload = JSON.parse(req.body);
// 즉시 응답하세요 - 10초 안에 처리해야 하며, 응답이 느리면 재시도됩니다.
res.status(200).json({ received: true });
// 이미 처리한 delivery는 무시합니다
if (HandledDeliveries.has(DeliveryId)) return;
HandledDeliveries.add(DeliveryId);
handleEvent(Payload).catch((err) => {
console.error(`${Payload.event} 처리에 실패했습니다:`, err);
});
});
async function handleEvent(payload) {
const { event, guild_id: guildId, data } = payload;
// 대시보드 테스트 버튼이 보내는 이벤트입니다
if (data.test) {
console.log(`테스트 이벤트 (${event})를 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:
// 새 이벤트 유형은 시간이 지나며 추가됩니다 - 모르는 이벤트라고 해서 절대 throw하지 마세요.
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`포트 ${PORT}에서 TicketWave 웹훅을 수신 중입니다`);
});코드가 이렇게 생긴 이유
놓치기 쉬운 세부 사항이 네 가지 있고, 모두 조용히 실패합니다.
express.json 대신 express.raw
서명은 TicketWave가 보낸 정확한 바이트를 기준으로 합니다. express.json()은 본문을 객체로 파싱하는데, 다시 직렬화하면 키 순서나 공백이 바뀔 수 있고 해시가 더 이상 일치하지 않습니다.
앱에서 전역으로 express.json()을 사용한다면, 웹훅 라우트 뒤에 마운트하거나 위에서 보인 것처럼 웹훅 경로에만 raw 파서를 적용하세요. 그렇지 않으면 JSON 파서가 우선 적용되고 req.body는 Buffer가 아니라 객체가 됩니다.
=== 대신 timingSafeEqual
문자열을 ===로 비교하면 두 바이트가 달라지는 즉시 반환합니다. 그 시간 차이는 서명의 어느 부분이 맞았는지 드러내며, 이는 한 바이트씩 브루트포스로 맞출 수 있을 정도입니다. crypto.timingSafeEqual은 항상 같은 시간이 걸립니다.
또한 두 버퍼의 길이가 다르면 예외를 던지므로, 먼저 길이를 확인해야 합니다.
타임스탬프 검사
이 검사가 없으면 유효한 요청을 캡처한 사람이 그것을 영원히 재전송할 수 있습니다. 타임스탬프도 서명된 페이로드의 일부이므로, 서명을 깨지 않고는 새 값으로 바꿀 수 없습니다.
작업 전에 먼저 응답하기
10초밖에 없습니다. 그보다 느리면 실패로 처리되어 재시도되므로, 느린 데이터베이스 쓰기 하나가 이벤트 하나를 세 번 처리하게 만들 수 있습니다. 먼저 200으로 응답하고, 그다음 작업하세요.
제대로 중복 제거하기
위의 Set은 데모용으로는 괜찮지만, 계속 커지고 재시작하면 비어 버립니다. 프로덕션에서는 delivery id가 유지되는 곳에 저장하세요:
// SQL 데이터베이스를 사용하는 예시
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;
}delivery_id에 고유 인덱스를 두면, 두 번의 재시도가 동시에 도착해도 안전합니다.
로컬에서 개발하기
TicketWave는 private 또는 loopback 주소의 엔드포인트를 허용하지 않으므로 http://localhost:3000을 직접 사용할 수 없습니다. 로컬 서버 앞에 터널을 두고, 그 터널이 제공하는 공개 HTTPS URL을 등록하세요:
# ngrok
ngrok http 3000
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000출력된 https://….ngrok-free.app/webhooks/ticketwave URL을 엔드포인트로 등록하고, 실제 티켓을 건드리기 전에 Send test event로 연결 상태를 확인하세요.
무료 터널 URL은 재시작할 때마다 바뀝니다. 바뀌면 대시보드의 엔드포인트 URL도 함께 업데이트하세요. 그렇지 않으면 delivery가 실패하기 시작합니다.
더 나아가기
| Want to… | Do this |
|---|---|
| Handle high message volume | 라우트에서 페이로드를 큐에 넣고 다른 곳에서 처리하세요 |
| Run several endpoints | 각 엔드포인트마다 자기만의 시크릿이 있습니다 — 라우트마다 올바른 것을 선택하세요 |
| Debug a failing delivery | Dashboard → Webhooks에서 delivery를 열어 정확한 요청과 응답을 확인하세요 |
| Re-send an event | delivery 상세 페이지에서 Retry Webhook을 사용하세요 |
다음 단계
- Event Reference (모든 이벤트의 페이로드)
- Webhooks (서명, 재시도 및 delivery 기록)
How is this guide?
