Vóór de beschermde actie vraagt je server Bot Protection naar de request en voert de action uit het antwoord uit. Gebruik een korte timeout en laat de bezoeker bij elke fout door: dat is fail-open, en het betekent dat Bot Protection je registratie nooit plat kan leggen.
De request
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"}'
Elk veld is optioneel, en hoe meer je stuurt, hoe meer Bot Protection ziet. Een veld dat Bot Protection niet kent, zoals requireToken voor require_token, wordt geweigerd met 400 unknown_field, dat het noemt: een tikfout gaat nooit door als standaardwaarde.
Stuur in headers deze mee zoals de browser van de bezoeker ze stuurde, onder hun naam in kleine letters, en laat weg wat het verzoek niet bevat: 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 en upgrade-insecure-requests. Elke kopie van één browserbuild stuurt dezelfde waarden, dus een script dat de User-Agent van een browser kopieert en de rest niet, valt op. De SDK's en de code hieronder sturen deze lijst.
Stuur vanuit Node ook header_order: de namen van de headers van het verzoek in de volgorde waarin ze binnenkwamen, in kleine letters (elke tweede waarde van req.rawHeaders), zoals ["host", "user-agent", "accept"]. Een script kopieert de headerwaarden van een browser vaker dan hun volgorde. Waar de volgorde verloren gaat (Go's net/http, de Headers van de Fetch API, een Cloudflare Worker), laat je het weg; een waarde die geen lijst van headernamen is, wordt genegeerd, nooit geweigerd.
Een waarde die langer is dan wat Bot Protection bewaart, wordt ingekort, nooit geweigerd: 512 bytes van de User-Agent, het pad en elke header, 32 headers (de eerste op naam), 256 bytes van het token. De SDKs korten ze in voordat ze de aanroep versturen; een eigen aanroep mag ze heel versturen. De body wordt tot 256 KiB gelezen, waar een aanroep binnen die grenzen altijd in past; daarboven worden de velden beoordeeld die heel binnen 256 KiB aankomen, zodat één te grote header die een bezoeker schrijft geen beslissing kost.
Het IP-adres van de bezoeker
Achter een proxy of een CDN stuur je het IP-adres van de bezoeker zoals je proxy of CDN het doorgeeft (CF-Connecting-IP bij Cloudflare, het adres dat je eigen load balancer aan X-Forwarded-For toevoegde), niet een adres dat de bezoeker zelf in een header kan hebben gezet.
Een adres dat niet op het internet ligt (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10) is van je eigen netwerk: van een load balancer, een proxy, een container. Het is nooit dat van een bezoeker, dus beoordeelt Bot Protection zo'n beslissing als een zonder IP-adres, en ziet het veel minder. Dit stuurt een server die achter een load balancer het adres van de verbinding leest. Als de meeste van je beslissingen zo'n adres dragen, meldt de pagina Set-up dat. 127.0.0.1 en ::1 zijn prima zolang je op je eigen machine test. Achter een proxy op dezelfde host (de proxy_pass van nginx naar localhost) zijn ze voor elke bezoeker die van de proxy, en ook dat meldt de pagina Set-up zodra de tag laat zien dat je pagina's niet op localhost staan.
Een adres van een CDN-edge (van Cloudflare, Fastly, CloudFront, zoals elk zijn bereiken publiceert) is van het CDN, hetzelfde voor elke bezoeker erachter: je server stuurde het adres waar zijn eigen request vandaan kwam. Bot Protection beoordeelt zo'n beslissing ook als een zonder IP-adres, en als de meeste van je beslissingen er een dragen, zegt de pagina Set-up het.
Het antwoord
{
"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
}
| Veld | Betekenis |
|---|---|
request_id | de ID van deze beslissing, hoogstens 96 tekens: stuur hem mee met feedback, of naar ons als je een vraag hebt over één antwoord |
bot_score, human_score | 0 tot 100: hoeveel bewijs van automatisering, en van een mens, onder de klasse ligt |
action | wat je moet doen: allow, challenge of block. In monitor mode altijd allow. |
intended_action | wat block mode zou antwoorden |
class | human_high, human, unknown, bot of bot_high |
reasons | de soorten bewijs erachter, nooit de precieze regels: behaviour, inconsistency, environment, missing_signals, history |
rules | een lege lijst, [], tenzij we de namen van de regels voor je account aanzetten om samen met je een geval te bekijken: dan de regels die afgingen, bij naam |
mode | monitor, block of paused (je pauzeerde de bescherming op de pagina Set-up: action is allow) |
step_up | sterk bewijs in beide richtingen: vraag om een passkey of 3-D Secure, niet om een CAPTCHA |
challenge | als intended_action challenge is: url, onze challengepagina voor deze bezoeker als action challenge is (zie de challengepagina), anders null; en passed, true als deze sessie de challengepagina in de laatste 30 minuten heeft opgelost, waarna zijn challenge allow is. Anders null |
token_required | true als je require_token stuurde en het verzoek geen geldige token voor deze actie had, of een die deze sessie er al voor gebruikte: action is dan challenge, de klasse blijft gelijk (zie een actie die de tag nodig heeft). Anders false |
crawler | {"name", "kind"} voor een crawler die zichzelf noemt en komt van de adressen die zijn leverancier publiceert, zoals GPTBot, ClaudeBot of Googlebot: nog steeds bot, maar een die u kunt doorlaten als u hem uitnodigt. kind is search (zoekmachines), assistant (AI-assistenten en AI-zoekmachines, die indexeren voor hun antwoorden of een pagina ophalen waar iemand om vroeg, niet voor training), scrape (AI-training en datasets), seo (SEO-tools), preview (linkvoorbeelden), uptime (uptime-monitors) of scan (internetscanners zoals Censys). Elke soort is allow, tenzij u hem in Set-up op block zet, behalve scrape en scan, die block zijn tenzij u ze op allow zet: een AI-trainingscrawler neemt uw pagina's mee voor een model en stuurt niemand terug, en een scanner brengt alleen in kaart wat uw site blootgeeft. De keuze van een soort is de standaard van zijn crawlers, en u kunt elke crawler apart doorlaten of blokkeren; in block mode is action die keuze. Anders null |
seen_before | hoeveel eerdere sessies deze browser de afgelopen 90 dagen naar dit pad brachten, volgens de apparaat-id van de tag: op een aanmeldformulier dezelfde persoon die nog een account opent. 0 bij geen, zonder de tag of bij een paginaweergave. Op een pad dat Bot Protection als aanmelding heeft geleerd (de pagina Set-up toont ze), voegt een eerder geziene browser de reden history toe, die op zichzelf nooit bot geeft |
Elk veld is er altijd: een object zonder waarde is null, een lege lijst is [], nooit null.
Fouten
Elke andere status dan 200 (een verkeerde key, de maandlimiet bereikt, een storing) betekent: laat de bezoeker door. De body van een fout is een RFC 9457-problem, application/problem+json, waarvan code zegt wat er misging:
{
"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"
}
| Status | code | Wanneer |
|---|---|---|
| 400 | invalid_json, invalid_field, unknown_field | de body is geen JSON, een veld heeft het verkeerde type, of Bot Protection kent het niet |
| 401 | unauthorized | geen API key, of een die Bot Protection niet kent |
| 402 | over_limit | de gratis proefperiode of de beslissingen van de maand zijn op |
| 402, 403 | domain_refused | het domein van de pagina is er geen die de site key beschermt (de calls van de tag) |
| 404 | not_found | die route bestaat niet |
| 405 | method_not_allowed | de route neemt andere methoden aan, die Allow noemt |
| 409 | conflict, idempotency_key_in_use | wat de call zou maken bestaat al, of de eerste call met deze Idempotency-Key loopt nog |
| 413 | body_too_large | de body is groter dan de grens van de route |
| 422 | idempotency_key_reused | de Idempotency-Key is binnen een uur gebruikt met een andere body |
| 429 | rate_limited | te veel calls vanuit één netwerk naar de routes van de tag, of, bij events en feedback, de Idempotency-Key-records van het uur van je API key op hun grens; met Retry-After |
| 503 | over_capacity, deadline, unavailable | te veel calls tegelijk, geen beslissing binnen je deadline, of een dienst waar Bot Protection van afhangt is onbereikbaar, met Retry-After |
| 500 | internal | Bot Protection faalde |
Elk antwoord draagt Korzen-Request-Id, gelijk aan de request_id van het problem: noem hem als je ons naar een call vraagt. Decide, events en feedback dragen de headers RateLimit-Policy en RateLimit van de IETF voor de beslissingen van je account: "month";q=50000 en "month";r=41234;t=86400 zeggen dat er nog 41.234 van de 50.000 van de maand over zijn en dat de maand over een dag eindigt (in de gratis proefperiode "trial"). Log ze als je wilt, en wacht er nooit op. De SDK's geven ze allebei na elke call aan onAnswer (in Go OnAnswer).
Wat je server weet
Feiten die je server zelf heeft gecontroleerd, maken het antwoord zekerder. Stuur ze mee in claims, allemaal optioneel:
"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
| Claim | Wanneer |
|---|---|
account_age_days | hoe oud het account is, in dagen, bij een inlog of een checkout; vanaf 30 telt het mee |
known_instrument | dezelfde kaart of passkey als bij eerdere bestellingen van het account |
device_attested | een platformattestatie is geslaagd: een passkey, App Attest, Play Integrity, een Private Access Token |
contact_verified | het e-mailadres of telefoonnummer is geverifieerd |
Samen met feedback dat de bezoeker een goede klant is, is known_instrument of device_attested wat een antwoord optilt naar human_high. Stuur alleen wat je hebt gecontroleerd: een claim is bewijs van een mens.
Latency en je timeout
Bot Protection draait in Falkenstein, Duitsland. Een beslissing kost de round trip over het netwerk vanaf je server plus een paar milliseconden, over een verbinding die je server openhoudt; een nieuwe verbinding voegt de TLS-handshake toe, een of twee round trips extra. Gebruik één HTTP-client voor het hele proces, zoals de voorbeelden hieronder doen, en kies je timeout op basis van waar je server draait. Stuur hem mee als Korzen-Deadline-Ms, hoe lang je call in totaal wacht: Bot Protection neemt de helft voor de beslissing, tussen 10 en 1.000 ms (200 ms zonder de header), en laat de andere helft voor het netwerk. Een beslissing die niet binnen haar helft klaar is, krijgt meteen 503 deadline en telt niet mee. De SDK's sturen hun timeout voor je mee.
| Je server in | Round trip | Timeout |
|---|---|---|
| Europa | 10–40 ms | 100–200 ms |
| VS oost | ~85 ms | 200 ms |
| VS west | ~145 ms | 300 ms |
| Zuid-Amerika | ~190 ms | 500 ms |
Een timeout die te kort is voor de afstand laat elke bezoeker door, en die calls tellen nog steeds als beslissingen.
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