Korzen
Bot Protection
このドキュメントの目次

Webhook

bot の判定ごと、または数分ごとの要約を、HMAC-SHA256 で署名してエンドポイントに送ります。ペイロード、署名、再試行、上限。

Webhook は、Bot Protection の bot と bot_high の判定を、お客様のエンドポイントに送ります。チャットチャンネルへの中継、チケットのキュー、自社の不正審査などに使えます。サーバーはこれまでどおり各判定を /v1/decide から受け取ります。Webhook はその写しで、応答のあとに送られ、判定を待たせることはありません。

エンドポイントを設定する

ダッシュボードのセットアップページの Webhook で、オーナーがエンドポイントのアドレスを書き、届く内容を選びます。

  • 選んだクラス(bot、bot_high、または両方)の判定ごと。最大 100 件のバッチで、判定から数秒以内に届きます。
  • 5、15、60 分ごとの要約。bot と bot_high の判定がそれぞれ何件あったか。

アドレスは https:// でポート 443、hooks.example.com のような名前で書き、IP アドレスは使えません。名前がどのアドレスを指しても、Korzen はプライベートネットワークのアドレスには何も送らず、配信のたびに確認します。

エンドポイントを初めて保存すると、セットアップにその署名シークレット(kz_whs_ と 64 文字)が一度だけ表示されます。エンドポイントの設定にコピーしてください。新しいシークレットを作るで置き換わり、新しいものも一度だけ表示されます。24 時間は古いシークレットも並行して各配信に署名するので、エンドポイント側のコピーを更新する間も検証が続きます。テストを送るは署名付きの test 配信を今すぐ送り、エンドポイントの応答を表示します。

ペイロード

各配信は JSON ボディの POST です。判定のバッチ:

{
  "type": "decisions",
  "id": "0193a5f0-7c1e-7d2a-9b4e-3f1a2c9d8e01",
  "decisions": [
    {
      "at": "2026-10-28T12:00:00.123Z",
      "request_id": "…",
      "class": "bot_high",
      "action": "block",
      "intended_action": "block",
      "bot_score": 97,
      "mode": "block",
      "reasons": ["behaviour", "inconsistency"]
    }
  ],
  "dropped": 0
}

各判定は、判定の時刻 at と、/v1/decide がお客様のサーバーに返した応答の次のメンバーです: request_id、class、bot_score、human_score、reasons、action、intended_action、mode、step_up、token_required、identity_limited、seen_before、crawler、decoy、own_server。それ以外は送りません。ルール名、お客様が送った context、チャレンジページのリンクは含みません。dropped は、このバッチに入りきらなかった判定の数です。

要約:

{"type": "summary", "id": "…", "from": "2026-10-28T12:00:00Z", "to": "2026-10-28T12:15:00Z", "bot": 412, "bot_high": 37}

テスト: {"type": "test", "id": "…", "at": "…"}。最初に type を読んでください。新しい種類が加わることも、どの種類にもフィールドが加わることもあります。

id は同じ配信の再試行ではすべて同じです。処理済みなら 2xx を返して捨ててください。配信の順序は保証されません。

配信が約束すること

  • キューに入った後は少なくとも 1 回。キューに入った配信は、エンドポイントが 2xx を返すか一時停止されるまで試行します。同じ配信が 2 回届くことがあるので、id で重複を捨ててください。
  • その前はベストエフォート。Korzen の各サーバーは判定を最大 10 秒保持してからキューに入れ、deploy で停止するときは保持しているものをすべてキューに入れます。クラッシュしたサーバーは保持していた分を失い、バッチの枠やプランの 1 時間あたりの上限を超えた判定は dropped に数え、送りません。
  • Webhook はコピーであり、記録ではありません。すべての判定はダッシュボードの判定ページに残り、MCP サーバーの list_decisions と get_decision も同じものを読みます。判定は request_id でそこから探せます。

署名を確認する

各配信には Korzen-Signature: t=<Unix 秒>,v1=<hex> が付きます。署名シークレットによる、t、ドット、受け取ったままのボディの HMAC-SHA256 です。JSON をパースする前の生のバイト列で計算し、定数時間で比較し、時計から 5 分以上ずれた t は拒否してください。一度記録された配信を後から再送されても受け付けません。新しいシークレットを作ってから 24 時間は、古いシークレットによる 2 つ目の v1 が続きます。いずれかの v1 が一致すれば配信を受け付けてください。

import { createHmac, timingSafeEqual } from "node:crypto";

// raw: the body as received (a Buffer); header: Korzen-Signature
function verify(raw, header, secret) {
  const parts = header.split(",").map((p) => p.split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const mac = createHmac("sha256", secret).update(`${t}.`).update(raw).digest();
  return parts.some(([k, v]) => {
    const got = Buffer.from(k === "v1" ? v ?? "" : "", "hex");
    return got.length === mac.length && timingSafeEqual(got, mac);
  });
}
import hashlib, hmac, time

def verify(raw: bytes, header: str, secret: str) -> bool:
    parts = [p.partition("=")[::2] for p in header.split(",")]
    t = next((v for k, v in parts if k == "t"), "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(mac, v) for k, v in parts)

応答と再試行

5 秒以内に任意の 2xx を返してください。ボディは読みません。それ以外(リダイレクトも含む)や時間内に応答がない場合は失敗で、30 秒後に再試行し、その後は毎回 2 倍の間隔をあけ、約 1 時間で 8 回試みます。時間のかかる処理は応答のあとに行ってください。

エンドポイントが失敗している間、新しい判定は最大 100 件まで待ち、残りは dropped に数えます。1 時間失敗し続けたエンドポイントは一時停止されます。何も送らず、その間の判定も送らず、各オーナーにメールが届きます。セットアップに最後の配信のステータスかエラーが表示され、再開で次の判定から再び送ります。

上限

各プランが 1 時間に送れる判定の数: 無料トライアル 1,000、Starter 10,000、Pro 50,000、Scale 200,000。超えた判定は次のバッチの dropped に数え、次の 1 時間はゼロから始まります。要約は常に送ります。Webhook は月間の判定数に含まれません。