Korzen
Bot Protection

La chiamata dal tuo server

POST /v1/decide prima di una registrazione, un login o un checkout: cosa inviare, cosa torna indietro, e il codice per Next.js, Express, Go e Python.

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
}
CampoSignificato
request_idl'ID di questa decisione, al massimo 96 caratteri: invialo con il feedback, o a noi quando ci chiedi di una risposta
bot_score, human_scoreda 0 a 100: quanti indizi di automazione, e di una persona, sostengono la classe
actioncosa fare: allow, challenge o block. Sempre allow quando sei in monitor mode.
intended_actioncosa risponderebbe il block mode
classhuman_high, human, unknown, bot o bot_high
reasonsi tipi di indizi alla base, mai le regole esatte: behaviour, inconsistency, environment, missing_signals, history
rulesuna 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
modemonitor, block o paused (hai messo in pausa la protezione nella pagina Configurazione: action è allow)
step_upindizi forti in entrambe le direzioni: chiedi una passkey o il 3-D Secure, non un CAPTCHA
challengequando 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_requiredtrue 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_beforequante 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"
}
StatuscodeQuando
400invalid_json, invalid_field, unknown_fieldil body non è JSON, un campo ha il tipo sbagliato, o Bot Protection non lo conosce
401unauthorizednessuna API key, o una che Bot Protection non conosce
402over_limitla prova gratuita o le decisioni del mese sono esaurite
402, 403domain_refusedil dominio della pagina non è tra quelli che la site key protegge (le chiamate della tag)
404not_foundquesta route non esiste
405method_not_allowedla route accetta altri metodi, che Allow elenca
409conflict, idempotency_key_in_useciò che la chiamata creerebbe esiste già, o la prima chiamata con questa Idempotency-Key è ancora in corso
413body_too_largeil body supera il limite della route
422idempotency_key_reusedla Idempotency-Key è stata usata meno di un'ora fa con un altro body
429rate_limitedtroppe 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
503over_capacity, deadline, unavailabletroppe chiamate insieme, nessuna decisione entro la tua deadline, o un servizio da cui dipende Bot Protection non è disponibile, con Retry-After
500internalBot 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}
ClaimQuando
account_age_daysl'età dell'account, in giorni, su un login o un checkout; conta da 30 in su
known_instrumentla stessa carta o passkey degli ordini precedenti dell'account
device_attestedun'attestazione di piattaforma superata: una passkey, App Attest, Play Integrity, un Private Access Token
contact_verifiedl'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 inRound tripTimeout
Europa10–40 ms100–200 ms
USA est~85 ms200 ms
USA ovest~145 ms300 ms
Sud America~190 ms500 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