保護したい操作の前に、サーバーがリクエストについて Bot Protection に問い合わせ、応答の action を適用します。タイムアウトは短くし、どんなエラーでも訪問者を通してください。これが fail-open で、Bot Protection が原因で新規登録が止まることは決してありません。
リクエスト
curl -s -X POST https://korzen.dev/v1/decide \ -H "Authorization: Bearer $KORZEN_API_KEY" \ -H "Content-Type: application/json" \ -H "Korzen-Deadline-Ms: 200" \ -d '{"ip":"203.0.113.10","user_agent":"Mozilla/5.0 …", "headers":{"accept":"text/html,…","accept-language":"en-US","sec-ch-ua":"…","sec-fetch-site":"same-origin"}, "token":"<the _kz cookie>","path":"/signup","method":"POST"}'
すべてのフィールドは任意ですが、送る情報が多いほど Bot Protection に見えるものも増えます。Bot Protection が知らないフィールド(require_token のつもりの requireToken など)は、その名前を示す 400 unknown_field で拒否されます。タイプミスが既定値として通ることはありません。
headers には、次のヘッダーを訪問者のブラウザーが送ったとおりに小文字の名前で入れ、リクエストにないものは省いてください: accept、accept-encoding、accept-language、sec-ch-ua、sec-ch-ua-mobile、sec-ch-ua-platform、sec-fetch-dest、sec-fetch-mode、sec-fetch-site、upgrade-insecure-requests。同じブラウザービルドのコピーはどれも同じ値を送るため、ブラウザーの User-Agent だけをまねて残りをまねないスクリプトは目立ちます。SDK と下のコードはこのリストを送ります。
Node からは header_order も送ってください。リクエストのヘッダー名を届いた順に小文字で並べたもの(req.rawHeaders の一つおきの要素)で、たとえば ["host", "user-agent", "accept"] です。スクリプトはブラウザのヘッダーの値を、その順序よりもよくまねます。順序が失われる環境(Go の net/http、Fetch API の Headers、Cloudflare Worker)では省いてください。ヘッダー名のリストでない値は無視され、拒否されることはありません。
Bot Protection が保持する長さを超える値は、拒否されずに切り詰められます。User-Agent、パス、各ヘッダーは 512 バイト、ヘッダーは 32 個まで(名前順で先頭から)、トークンは 256 バイトです。SDK は呼び出しを送る前に切り詰めます。独自の呼び出しはそのまま送ってもかまいません。ボディは 256 KiB まで読まれ、この範囲内の呼び出しは必ず収まります。それを超える場合は 256 KiB 以内に完全に届いたフィールドで判定されるので、訪問者が書いた巨大なヘッダー一つで判定が失われることはありません。
訪問者の IP
プロキシや CDN の背後にある場合は、訪問者がヘッダーに書き込めた値ではなく、プロキシや CDN が報告する訪問者の IP を送ってください(Cloudflare なら CF-Connecting-IP、自前のロードバランサーなら X-Forwarded-For に追加したアドレス)。
インターネット上にないアドレス(10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10)は、あなた自身のネットワークのもの、つまりロードバランサー、プロキシ、コンテナのアドレスです。訪問者のアドレスになることはないため、Bot Protection はその判定を IP なしで送られたものとして扱い、見えるものが大きく減ります。ロードバランサーの背後で接続元のアドレスを読むサーバーは、このアドレスを送ってしまいます。判定の大半がこうしたアドレスを含むときは、セットアップページでお知らせします。自分のマシンでテストしている間の 127.0.0.1 と ::1 は問題ありません。同じホスト上のプロキシ(nginx の localhost への proxy_pass)の後ろでは、これらはすべての訪問者についてプロキシのアドレスであり、ページが localhost 上にないことを タグが示した時点で、これもセットアップページでお知らせします。
CDN のエッジのアドレス(Cloudflare、Fastly、CloudFront がそれぞれ公開する範囲のもの)は CDN のもので、その背後のすべての訪問者で同じです。サーバーが、自分のリクエストの送信元アドレスを送っています。Bot Protection はその判定も IP なしで送られたものとして扱い、判定の大半がこうしたアドレスを含むときは、セットアップページでお知らせします。
応答
{
"request_id": "AQEAo3k9Zx4VbQ…",
"class": "bot",
"bot_score": 72,
"human_score": 0,
"reasons": ["behaviour", "inconsistency"],
"rules": [],
"step_up": false,
"action": "allow",
"intended_action": "challenge",
"mode": "monitor",
"challenge": { "url": null, "passed": false },
"token_required": false,
"seen_before": 0,
"crawler": null
}
| フィールド | 意味 |
|---|---|
request_id | この判定の ID(最大 96 文字)。フィードバックと一緒に送るか、個別の応答についてお問い合わせいただく際にお知らせください |
bot_score、human_score | 0〜100。クラスの根拠となった、自動化の証拠と人間の証拠の量 |
action | すべきこと:allow、challenge、block のいずれか。monitor mode では常に allow |
intended_action | block mode だった場合の応答 |
class | human_high、human、unknown、bot、bot_high のいずれか |
reasons | 根拠となった証拠の種類。具体的なルールは含みません:behaviour、inconsistency、environment、missing_signals、history |
rules | 通常は空のリスト []。お客様と一緒にケースを調べるために、私たちがアカウントでルール名を有効にした場合のみ、発火したルールの名前の一覧になります |
mode | monitor、block、または paused(セットアップページで保護を一時停止したとき。action は allow です) |
step_up | 両方向に強い証拠がある状態。CAPTCHA ではなく、パスキーや 3-D Secure を求めてください |
challenge | intended_action が challenge のとき:url は、action が challenge のときのこの訪問者用のチャレンジページ(チャレンジページを参照)、それ以外では null。passed は、このセッションが直近 30 分以内にチャレンジページを解いたときに true で、そのとき challenge は allow になります。それ以外では null |
token_required | require_token を送り、リクエストがこのアクションに有効な token を持っていなかったとき、またはこのセッションがすでにこのアクションで使った token だったときに true。そのとき action は challenge で、クラスは変わりません(タグを必要とするアクションを参照)。それ以外では false |
crawler | {"name", "kind"}:自ら名乗り、その提供元が公開しているアドレスから来たクローラー (GPTBot、ClaudeBot、Googlebot など)。クラスは bot のままですが、招いているクローラーなら通すことができます。kind は次のいずれかです:search (検索エンジン)、assistant (AI アシスタントと AI 検索。回答のためにインデックスする、または人が頼んだページを取りに来るもので、学習のためではありません)、scrape (AI の学習とデータセット)、seo (SEO ツール)、preview (リンクプレビュー)、uptime (死活監視サービス)、scan (Censys などのインターネットスキャナー)。どの種類も、Set-up で block にしない限り allow です。ただし scrape と scan は、allow にしない限り block です。AI 学習用クローラーはページをモデルのために持ち去り誰も連れてこず、スキャナーはサイトが公開しているものを調べ上げるだけだからです。種類の選択がそのクローラーの既定となり、クローラーごとに通すかブロックするかも選べます。block mode では、action はその選択です。それ以外では null |
seen_before | 過去 90 日間に、タグのデバイス ID で見て、このブラウザをこのパスに運んだ以前のセッションの数。登録フォームなら、同じ人が別のアカウントを作っているということです。該当なし、タグなし、またはページ表示では 0。Bot Protection が登録として学習したパス(セットアップページに一覧があります)では、以前に見たブラウザは理由 history を加えますが、それだけで bot になることはありません |
すべてのメンバーは常に含まれます。値のないオブジェクトは null、空のリストは [] で、null にはなりません。
エラー
200 以外のステータス(誤ったキー、月の上限到達、障害)はすべて、訪問者を通すという意味です。エラーのボディは RFC 9457 の problem(application/problem+json)で、その code が何が起きたかを示します。
{
"type": "https://korzen.dev/bot-protection/docs/server-call#unknown_field",
"title": "A field is not known",
"status": 400,
"code": "unknown_field",
"detail": "unknown field `requireToken`, expected one of `ip`, `user_agent`, …",
"request_id": "rq_01a15441820577808ce489c291023c16"
}
| ステータス | code | いつ |
|---|---|---|
| 400 | invalid_json, invalid_field, unknown_field | ボディが JSON でない、フィールドの型が違う、または Bot Protection が知らないフィールドがある |
| 401 | unauthorized | API キーがない、または Bot Protection が知らないキー |
| 402 | over_limit | 無料トライアルか、その月の判定数を使い切った |
| 402, 403 | domain_refused | ページのドメインが site key で保護されているドメインではない(タグからの呼び出し) |
| 404 | not_found | そのルートは存在しない |
| 405 | method_not_allowed | そのルートは別のメソッドを受け付ける。Allow がそれを示す |
| 409 | conflict, idempotency_key_in_use | 呼び出しが作るものがすでにある、またはこの Idempotency-Key での最初の呼び出しがまだ処理中 |
| 413 | body_too_large | ボディがルートの上限を超えている |
| 422 | idempotency_key_reused | この Idempotency-Key が 1 時間以内に別のボディで使われた |
| 429 | rate_limited | 1 つのネットワークからタグのルートへの呼び出しが多すぎる、または events と feedback で API キーの 1 時間分の Idempotency-Key の記録が上限に達した。Retry-After 付き |
| 503 | over_capacity, deadline, unavailable | 同時の呼び出しが多すぎる、deadline 内に判定が出なかった、または Bot Protection が依存するサービスが停止している。Retry-After 付き |
| 500 | internal | Bot Protection の障害 |
すべての応答には Korzen-Request-Id が含まれ、problem の request_id と同じです。呼び出しについてお問い合わせの際はこれをお知らせください。decide、events、feedback には、アカウントの判定数についての IETF の RateLimit-Policy と RateLimit ヘッダーが付きます。"month";q=50000 と "month";r=41234;t=86400 は、その月の 50,000 件のうち 41,234 件が残り、月が 1 日後に終わることを示します(無料トライアルでは "trial")。必要ならログに残し、決してこれを待たないでください。SDK は各呼び出しの後、両方を onAnswer(Go では OnAnswer)に渡します。
サーバーが知っていること
サーバーが自ら確認した事実を送ると、応答の確度が上がります。claims に入れて送ってください。いずれも任意です。
"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
| クレーム | 送るタイミング |
|---|---|
account_age_days | ログインやチェックアウト時のアカウントの経過日数。30 日以上で考慮されます |
known_instrument | アカウントの過去の注文と同じカードやパスキー |
device_attested | プラットフォームの構成証明に合格した(パスキー、App Attest、Play Integrity、Private Access Token) |
contact_verified | メールアドレスまたは電話番号が確認済み |
訪問者が優良顧客であるというフィードバックがあるとき、known_instrument または device_attested が応答を human_high に引き上げます。送るのは確認したことだけにしてください。クレームは人間であることの証拠として扱われます。
レイテンシーとタイムアウト
Bot Protection はドイツのファルケンシュタインで稼働しています。サーバーが接続を維持している場合、判定にかかる時間はサーバーからのネットワーク往復時間に数ミリ秒を加えた程度です。新しい接続では TLS ハンドシェイクの分、往復が 1〜2 回増えます。以下の例のように、プロセス全体で HTTP クライアントを 1 つだけ使い、サーバーの所在地に応じてタイムアウトを選んでください。それを Korzen-Deadline-Ms(呼び出し全体で待つ時間)として送ります。Bot Protection はその半分を判定に使い(10〜1,000 ms、ヘッダーがなければ 200 ms)、残りの半分をネットワークに残します。半分の時間内に出なかった判定は、すぐに 503 deadline で応答され、カウントされません。SDK は自身のタイムアウトを代わりに送ります。
| サーバーの所在地 | 往復時間 | タイムアウト |
|---|---|---|
| ヨーロッパ | 10–40 ms | 100–200 ms |
| 米国東部 | ~85 ms | 200 ms |
| 米国西部 | ~145 ms | 300 ms |
| 南米 | ~190 ms | 500 ms |
距離に対してタイムアウトが短すぎると、すべての訪問者が通ってしまいますが、その呼び出しも判定としてカウントされます。
Next.js
// lib/korzen.js: call before creating the account, in a route handler or server action. import { cookies, headers } from "next/headers"; // The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. const FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"]; export async function decide(path) { const h = await headers(); try { const res = await fetch("https://korzen.dev/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.KORZEN_API_KEY}`, "Content-Type": "application/json", "Korzen-Deadline-Ms": "200", // how long this call waits: the decision takes half }, body: JSON.stringify({ ip: h.get("x-forwarded-for")?.split(",")[0].trim(), // Vercel sets it; elsewhere, your proxy's header user_agent: h.get("user-agent"), headers: Object.fromEntries(FORWARDED.filter((n) => h.has(n)).map((n) => [n, h.get(n)])), token: (await cookies()).get("_kz")?.value, path, method: "POST", }), signal: AbortSignal.timeout(200), }); return res.ok ? (await res.json()).action : "allow"; } catch { return "allow"; // fail open } }
Express
// The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. const FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"]; app.post("/signup", async (req, res) => { let action = "allow"; try { const r = await fetch("https://korzen.dev/v1/decide", { method: "POST", headers: { Authorization: `Bearer ${process.env.KORZEN_API_KEY}`, "Content-Type": "application/json", "Korzen-Deadline-Ms": "200", // how long this call waits: the decision takes half }, body: JSON.stringify({ ip: req.ip, user_agent: req.get("user-agent"), token: req.cookies?._kz, path: "/signup", method: "POST", headers: Object.fromEntries(FORWARDED.filter((n) => req.get(n) !== undefined).map((n) => [n, req.get(n)])), header_order: req.rawHeaders.filter((_, i) => i % 2 === 0).map((n) => n.toLowerCase()), // the order they came in }), signal: AbortSignal.timeout(200), }); if (r.ok) action = (await r.json()).action; } catch {} // fail open if (action === "block") return res.status(400).send("We could not create your account."); // "challenge": your CAPTCHA or email check here. Then create the account. });
Go
// forwarded are the headers Bot Protection reads: those one browser build sends alike, and // Accept-Language. net/http keeps no header order, so header_order is left out. var forwarded = []string{"accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"} func decide(r *http.Request, path string) string { var token string if c, err := r.Cookie("_kz"); err == nil { token = c.Value } headers := map[string]string{} for _, name := range forwarded { if v := r.Header.Get(name); v != "" { headers[name] = v } } body, _ := json.Marshal(map[string]any{ "ip": clientIP(r), "user_agent": r.UserAgent(), "headers": headers, "token": token, "path": path, "method": r.Method, }) ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond) defer cancel() req, _ := http.NewRequestWithContext(ctx, "POST", "https://korzen.dev/v1/decide", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("KORZEN_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Korzen-Deadline-Ms", "200") // how long this call waits: the decision takes half res, err := http.DefaultClient.Do(req) if err != nil { return "allow" // fail open } defer res.Body.Close() var ans struct{ Action string `json:"action"` } if res.StatusCode != 200 || json.NewDecoder(res.Body).Decode(&ans) != nil { return "allow" } return ans.Action } // clientIP is the visitor's address as your edge reports it. Trust only a header your own proxy // or CDN sets: CF-Connecting-IP behind Cloudflare, the last X-Forwarded-For entry your load // balancer appended; with none, the connection's address. func clientIP(r *http.Request) string { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { return ip } host, _, _ := net.SplitHostPort(r.RemoteAddr) return host }
Python
import os, requests korzen = requests.Session() # one per process: it keeps the connection open # The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. FORWARDED = ["accept", "accept-encoding", "accept-language", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests"] def decide(request, path): try: r = korzen.post("https://korzen.dev/v1/decide", timeout=0.2, headers={"Authorization": f"Bearer {os.environ['KORZEN_API_KEY']}", "Korzen-Deadline-Ms": "200"}, # how long this call waits: the decision takes half json={"ip": request.remote_addr, "user_agent": request.headers.get("User-Agent"), "headers": {h: request.headers[h] for h in FORWARDED if h in request.headers}, "token": request.cookies.get("_kz"), "path": path, "method": "POST"}) return r.json()["action"] if r.status_code == 200 else "allow" except Exception: return "allow" # fail open