Korzen
Bot Protection

De call vanaf je server

POST /v1/decide vóór een registratie, een inlog of een checkout: wat je stuurt, wat er terugkomt, en code voor Next.js, Express, Go en Python.

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
}
VeldBetekenis
request_idde 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_score0 tot 100: hoeveel bewijs van automatisering, en van een mens, onder de klasse ligt
actionwat je moet doen: allow, challenge of block. In monitor mode altijd allow.
intended_actionwat block mode zou antwoorden
classhuman_high, human, unknown, bot of bot_high
reasonsde soorten bewijs erachter, nooit de precieze regels: behaviour, inconsistency, environment, missing_signals, history
ruleseen 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
modemonitor, block of paused (je pauzeerde de bescherming op de pagina Set-up: action is allow)
step_upsterk bewijs in beide richtingen: vraag om een passkey of 3-D Secure, niet om een CAPTCHA
challengeals 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_requiredtrue 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_beforehoeveel 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"
}
StatuscodeWanneer
400invalid_json, invalid_field, unknown_fieldde body is geen JSON, een veld heeft het verkeerde type, of Bot Protection kent het niet
401unauthorizedgeen API key, of een die Bot Protection niet kent
402over_limitde gratis proefperiode of de beslissingen van de maand zijn op
402, 403domain_refusedhet domein van de pagina is er geen die de site key beschermt (de calls van de tag)
404not_founddie route bestaat niet
405method_not_allowedde route neemt andere methoden aan, die Allow noemt
409conflict, idempotency_key_in_usewat de call zou maken bestaat al, of de eerste call met deze Idempotency-Key loopt nog
413body_too_largede body is groter dan de grens van de route
422idempotency_key_reusedde Idempotency-Key is binnen een uur gebruikt met een andere body
429rate_limitedte 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
503over_capacity, deadline, unavailablete veel calls tegelijk, geen beslissing binnen je deadline, of een dienst waar Bot Protection van afhangt is onbereikbaar, met Retry-After
500internalBot 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}
ClaimWanneer
account_age_dayshoe oud het account is, in dagen, bij een inlog of een checkout; vanaf 30 telt het mee
known_instrumentdezelfde kaart of passkey als bij eerdere bestellingen van het account
device_attestedeen platformattestatie is geslaagd: een passkey, App Attest, Play Integrity, een Private Access Token
contact_verifiedhet 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 inRound tripTimeout
Europa10–40 ms100–200 ms
VS oost~85 ms200 ms
VS west~145 ms300 ms
Zuid-Amerika~190 ms500 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