TicketWave Logo
Webhooks

Example Server

TicketWave の Webhook を受信して検証する、完全に動作する Express サーバーのサンプルです。

Webhook サーバーのサンプル

最小限ながら 本番向けの形 をした Express の受信サーバーです。署名を検証し、リプレイ攻撃を防ぎ、リトライを重複排除し、処理の前に応答します。

これをコピーして、エンドポイントを向ければ完了です。

セットアップ

プロジェクトを作成する

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

以下では Express 5 を使っていますが、コードは Express 4 でも変更なしで動作します。

サーバーを追加する

次のセクションのファイルを server.js として保存してください。

環境変数を設定する

プロジェクトのルートに .env ファイルを作成し、次の環境変数を追加してください:

# .env
TICKETWAVE_WEBHOOK_SECRET="whsec_your_secret_here"
PORT=3000
  1. Dashboard → Webhooks → Endpoints からシークレットをコピーし、TICKETWAVE_WEBHOOK_SECRET に設定します。
  2. マシン上で空いているポートを選び、PORT 変数に設定します。

server.js にシークレットを直書きしたり、コミットしたりしないでください。それを持っている人なら、署名チェックを通るリクエストを偽造できます。

実行する

node server.js

そのあと、ダッシュボードのエンドポイントで Send test event を押して、コンソールを確認してください。

サーバー

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 を使う

次のステップ

How is this guide?