Prima dell'azione protetta, il tuo server chiede a Bot Protection un parere sulla richiesta e applica l'action della risposta. Usa un timeout breve e lascia passare il visitatore in caso di qualsiasi errore: questo è il fail-open, e significa che Bot Protection non potrà mai mandare giù la tua registrazione.
La richiesta
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"}'
Ogni campo è facoltativo, e più ne invii più Bot Protection vede. Un campo che Bot Protection non conosce, come requireToken al posto di require_token, viene rifiutato con 400 unknown_field, che lo nomina: un errore di battitura non passa mai per un valore predefinito.
In headers, invia questi come li ha inviati il browser del visitatore, con il nome in minuscolo, e tralascia quelli che la richiesta non porta: 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 e upgrade-insecure-requests. Ogni copia di una stessa build di browser invia gli stessi valori, quindi uno script che copia lo User-Agent di un browser ma non il resto si nota. Gli SDK e il codice qui sotto inviano questa lista.
Da Node, invia anche header_order: i nomi degli header della richiesta nell'ordine in cui sono arrivati, in minuscolo (una voce sì e una no di req.rawHeaders), come ["host", "user-agent", "accept"]. Uno script copia i valori degli header di un browser più spesso del loro ordine. Dove l'ordine si perde (il net/http di Go, gli Headers della Fetch API, un Cloudflare Worker), lascialo fuori; un valore che non è una lista di nomi di header viene ignorato, mai rifiutato.
Un valore più lungo di quanto Bot Protection conserva viene tagliato, mai rifiutato: 512 byte dello User-Agent, del path e di ogni header, 32 header (i primi per nome), 256 byte del token. Gli SDK li tagliano prima di inviare la chiamata; una chiamata tua può inviarli interi. Il corpo viene letto fino a 256 KiB, in cui una chiamata entro quei limiti sta sempre; oltre, vengono giudicati i campi arrivati interi entro 256 KiB, quindi un header enorme scritto da un visitatore non costa una decisione.
L'IP del visitatore
Dietro un proxy o una CDN, invia l'IP del visitatore così come lo riporta il tuo proxy o la tua CDN (CF-Connecting-IP su Cloudflare, l'indirizzo che il tuo load balancer ha aggiunto a X-Forwarded-For), non uno che il visitatore potrebbe aver scritto in un header.
Un indirizzo che non è su Internet (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10) è della tua rete: di un load balancer, di un proxy, di un container. Non è mai quello di un visitatore, quindi Bot Protection giudica quella decisione come una inviata senza IP, e vede molto meno. È ciò che invia un server che legge l'indirizzo della connessione dietro un load balancer. Quando la maggior parte delle tue decisioni ne porta uno, la pagina Configurazione lo segnala. 127.0.0.1 e ::1, mentre provi sulla tua macchina, vanno bene. Dietro un proxy sullo stesso host (il proxy_pass di nginx verso localhost) sono quelli del proxy per ogni visitatore, e anche questo la pagina Configurazione lo segnala appena il tag mostra che le tue pagine non sono su localhost.
Un indirizzo di un edge di una CDN (di Cloudflare, di Fastly, di CloudFront, secondo gli intervalli che ognuno pubblica) è della CDN, lo stesso per ogni visitatore dietro di essa: il tuo server ha inviato l'indirizzo da cui veniva la sua stessa richiesta. Anche questa decisione Bot Protection la giudica come una inviata senza IP e, quando la maggior parte delle tue decisioni ne porta uno, la pagina Configurazione lo segnala.
La risposta
{
"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
}
| Campo | Significato |
|---|---|
request_id | l'ID di questa decisione, al massimo 96 caratteri: invialo con il feedback, o a noi quando ci chiedi di una risposta |
bot_score, human_score | da 0 a 100: quanti indizi di automazione, e di una persona, sostengono la classe |
action | cosa fare: allow, challenge o block. Sempre allow quando sei in monitor mode. |
intended_action | cosa risponderebbe il block mode |
class | human_high, human, unknown, bot o bot_high |
reasons | i tipi di indizi alla base, mai le regole esatte: behaviour, inconsistency, environment, missing_signals, history |
rules | una lista vuota, [], a meno che non abbiamo attivato i nomi delle regole sul tuo account per esaminare un caso con te: allora le regole scattate, per nome |
mode | monitor, block o paused (hai messo in pausa la protezione nella pagina Configurazione: action è allow) |
step_up | indizi forti in entrambe le direzioni: chiedi una passkey o il 3-D Secure, non un CAPTCHA |
challenge | quando intended_action è challenge: url, la nostra pagina di challenge per questo visitatore quando action è challenge (vedi la pagina di challenge), altrimenti null; e passed, true quando questa sessione ha risolto la pagina di challenge negli ultimi 30 minuti, e il suo challenge è allora allow. null altrimenti |
token_required | true quando hai inviato require_token e la richiesta non portava un token valido per questa azione, o ne portava uno che questa sessione ha già usato su di essa: action è allora challenge, e la classe non cambia (vedi un'azione che richiede il tag). false negli altri casi |
crawler | {"name", "kind"} per un crawler che si dichiara e arriva dagli indirizzi pubblicati dal suo fornitore, come GPTBot, ClaudeBot o Googlebot: resta bot, ma puoi lasciarlo passare se lo inviti. kind è search (motori di ricerca), assistant (assistenti IA e ricerca con IA, che indicizzano per le loro risposte o prendono una pagina che una persona ha chiesto, non per l'addestramento), scrape (addestramento IA e dataset), seo (strumenti SEO), preview (anteprime dei link), uptime (monitor di disponibilità) o scan (scanner di Internet come Censys). Ogni tipo è allow a meno che tu non lo imposti su block in Set-up, tranne scrape e scan, che sono block a meno che tu non li imposti su allow: un crawler di addestramento IA prende le tue pagine per un modello e non ti rimanda nessuno, e uno scanner mappa solo ciò che il tuo sito espone. La scelta di un tipo è il predefinito dei suoi crawler, e puoi lasciar passare o bloccare ogni crawler da solo; quando il block mode è attivo, action è quella scelta. null altrimenti |
seen_before | quante sessioni precedenti hanno portato questo browser su questo percorso negli ultimi 90 giorni, dall'identificativo di dispositivo del tag: su un modulo di registrazione, la stessa persona che apre un altro account. 0 se nessuna, senza il tag o su una visualizzazione di pagina. Su un percorso che Bot Protection ha imparato come registrazione (la pagina Configurazione li elenca), un browser già visto aggiunge il motivo history, che da solo non dà mai bot |
Ogni membro è sempre presente: un oggetto che non ha nulla da dire è null, una lista vuota è [], mai null.
Errori
Qualsiasi status diverso da 200 (una chiave sbagliata, il limite del mese raggiunto, un disservizio) significa: lascia passare il visitatore. Il body di un errore è un problem RFC 9457, application/problem+json, il cui code dice cosa è andato storto:
{
"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 | Quando |
|---|---|---|
| 400 | invalid_json, invalid_field, unknown_field | il body non è JSON, un campo ha il tipo sbagliato, o Bot Protection non lo conosce |
| 401 | unauthorized | nessuna API key, o una che Bot Protection non conosce |
| 402 | over_limit | la prova gratuita o le decisioni del mese sono esaurite |
| 402, 403 | domain_refused | il dominio della pagina non è tra quelli che la site key protegge (le chiamate della tag) |
| 404 | not_found | questa route non esiste |
| 405 | method_not_allowed | la route accetta altri metodi, che Allow elenca |
| 409 | conflict, idempotency_key_in_use | ciò che la chiamata creerebbe esiste già, o la prima chiamata con questa Idempotency-Key è ancora in corso |
| 413 | body_too_large | il body supera il limite della route |
| 422 | idempotency_key_reused | la Idempotency-Key è stata usata meno di un'ora fa con un altro body |
| 429 | rate_limited | troppe chiamate da una rete alle route della tag, oppure, su events e feedback, i record di Idempotency-Key dell'ora della tua API key al loro limite; con Retry-After |
| 503 | over_capacity, deadline, unavailable | troppe chiamate insieme, nessuna decisione entro la tua deadline, o un servizio da cui dipende Bot Protection non è disponibile, con Retry-After |
| 500 | internal | Bot Protection ha fallito |
Ogni risposta porta Korzen-Request-Id, lo stesso del request_id del problem: indicalo quando ci chiedi di una chiamata. Decide, events e feedback portano gli header RateLimit-Policy e RateLimit dell'IETF per le decisioni del tuo account: "month";q=50000 e "month";r=41234;t=86400 dicono che restano 41.234 delle 50.000 del mese e che il mese finisce tra un giorno (nella prova gratuita, "trial"). Registrali se vuoi, e non aspettarli mai. Gli SDK li passano entrambi a onAnswer (in Go, OnAnswer) dopo ogni chiamata.
Cosa sa il tuo server
I fatti che il tuo server ha verificato da sé rendono la risposta più sicura. Inviali in claims, tutti facoltativi:
"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
| Claim | Quando |
|---|---|
account_age_days | l'età dell'account, in giorni, su un login o un checkout; conta da 30 in su |
known_instrument | la stessa carta o passkey degli ordini precedenti dell'account |
device_attested | un'attestazione di piattaforma superata: una passkey, App Attest, Play Integrity, un Private Access Token |
contact_verified | l'email o il numero di telefono è verificato |
Insieme a un feedback che dice che il visitatore è un buon cliente, known_instrument o device_attested è ciò che alza una risposta a human_high. Invia solo quello che hai verificato: un claim è un indizio di una persona.
Latenza e timeout
Bot Protection gira a Falkenstein, in Germania. Una decisione richiede il round trip di rete dal tuo server più qualche millisecondo, su una connessione che il tuo server tiene aperta; una nuova connessione aggiunge l'handshake TLS, uno o due round trip in più. Usa un solo client HTTP per tutto il processo, come negli esempi qui sotto, e scegli il timeout in base a dove gira il tuo server. Invialo come Korzen-Deadline-Ms, quanto attende in tutto la tua chiamata: Bot Protection ne prende metà per la decisione, tra 10 e 1.000 ms (200 ms senza l'header), e lascia l'altra metà alla rete. Una decisione non presa entro la sua metà riceve subito 503 deadline, e non viene contata. Gli SDK inviano il loro timeout per te.
| Il tuo server in | Round trip | Timeout |
|---|---|---|
| Europa | 10–40 ms | 100–200 ms |
| USA est | ~85 ms | 200 ms |
| USA ovest | ~145 ms | 300 ms |
| Sud America | ~190 ms | 500 ms |
Un timeout troppo breve per la distanza lascia passare ogni visitatore, e quelle chiamate contano comunque come decisioni.
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