Example Server
TicketWave の Webhook を受信して検証する、完全に動作する Express サーバーのサンプルです。
Webhook サーバーのサンプル
最小限ながら 本番向けの形 をした 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 な body に対して作られるので、加工されていないバイト列を保持しておく。
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('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);
// すぐに応答する - 10 秒しかなく、遅い応答は再試行される。
res.status(200).json({ received: true });
// すでに処理済みの delivery は無視する
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;
// ダッシュボードのテストボタンが送るイベント
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:
// 新しいイベントタイプは随時追加される - 知らないものでも絶対に throw しない。
console.log(`[${guildId}] Unhandled event ${event}`);
}
}
app.listen(PORT, () => {
console.log(`Listening for TicketWave webhooks on port ${PORT}`);
});なぜこのコードはこうなっているのか
4 つの細かい点は間違えやすく、しかも 4 つとも失敗しても静かです。
express.json ではなく express.raw
署名は TicketWave が送った 正確なバイト列 に対して計算されます。express.json() は body をオブジェクトに変換しますが、再シリアライズするとキーの順序や空白が変わることがあり、ダイジェストが一致しなくなります。
アプリ全体で express.json() を使っている場合は、Webhook ルートの後にマウントするか、上で示したように Webhook パスにだけ raw パーサーを適用してください。そうしないと JSON パーサーが優先され、req.body は Buffer ではなくオブジェクトになります。
=== ではなく timingSafeEqual
文字列を === で比較すると、2 バイトが異なった時点ですぐに返ります。その時間差から署名のどこまでが正しかったかが漏れ、1 バイトずつ総当たりするのに十分です。crypto.timingSafeEqual は常に同じ時間で比較します。
また、2 つのバッファの長さが違うと 例外を投げる ため、先に長さを確認しています。
タイムスタンプのチェック
これがないと、有効なリクエストを捕まえた人が永遠に再生できてしまいます。タイムスタンプも署名対象のペイロードに含まれているため、署名を壊さずに新しいものへ差し替えることはできません。
処理の前に応答する
10 秒 しかありません。それより遅いと失敗扱いで再試行されるため、遅い DB 書き込み 1 回がイベント 3 回分になってしまいます。先に 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 にユニークインデックスを付けておけば、2 つのリトライが同時に届いても安全です。
ローカルで開発する
TicketWave はプライベートアドレスやループバックアドレスのエンドポイントを拒否するため、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 を更新してください。更新しないと配信が失敗し始めます。
さらに進めるには
| Want to… | Do this |
|---|---|
| 大量のメッセージを処理する | ルートでペイロードをキューに入れ、別の場所で処理する |
| 複数のエンドポイントを運用する | それぞれに専用のシークレットがあるので、ルートごとに正しいものを選ぶ |
| 失敗した配信をデバッグする | Dashboard → Webhooks で配信を開き、実際のリクエストと応答を確認する |
| イベントを再送する | 配信詳細ページの Retry Webhook を使う |
次のステップ
- Event Reference (すべてのイベントのペイロード)
- Webhooks (署名、リトライ、配信履歴)
How is this guide?
