Antes da ação protegida, o seu servidor consulta o Bot Protection sobre a requisição e segue a action da resposta. Use um timeout curto e deixe o visitante passar em qualquer erro: isso é fail open, e significa que o Bot Protection nunca derruba o seu cadastro.
A requisição
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"}'
Todo campo é opcional, e quanto mais você envia, mais o Bot Protection vê. Um campo que o Bot Protection não conhece, como requireToken no lugar de require_token, é recusado com 400 unknown_field, que o nomeia: um erro de digitação nunca passa como valor padrão.
Em headers, envie estes como o navegador do visitante os enviou, com o nome em minúsculas, e deixe de fora os que a requisição não traz: 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. Toda cópia de uma mesma build de navegador envia os mesmos valores, então um script que copia o User-Agent de um navegador e não o resto se destaca. Os SDKs e o código abaixo enviam esta lista.
No Node, envie também header_order: os nomes dos headers da requisição na ordem em que chegaram, em minúsculas (uma a cada duas entradas de req.rawHeaders), como ["host", "user-agent", "accept"]. Um script copia os valores dos headers de um navegador com mais frequência do que a ordem deles. Onde a ordem se perde (o net/http do Go, os Headers da Fetch API, um Cloudflare Worker), deixe-o de fora; um valor que não é uma lista de nomes de headers é ignorado, nunca recusado.
Um valor mais longo do que o Bot Protection guarda é cortado, nunca recusado: 512 bytes do User-Agent, do path e de cada header, 32 headers (os primeiros por nome), 256 bytes do token. Os SDKs cortam antes de enviar a chamada; uma chamada sua pode enviá-los inteiros. O corpo é lido até 256 KiB, o que uma chamada dentro desses limites sempre cabe; além disso, são julgados os campos que chegam inteiros dentro de 256 KiB, então um header enorme escrito por um visitante não custa uma decisão.
O IP do visitante
Atrás de um proxy ou CDN, envie o IP do visitante como o seu proxy ou CDN o informa (CF-Connecting-IP na Cloudflare, o endereço que o seu próprio load balancer acrescentou ao X-Forwarded-For), não um que o visitante possa ter escrito num header.
Um endereço que não está na internet (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10) é da sua própria rede: de um load balancer, de um proxy, de um container. Nunca é de um visitante, por isso o Bot Protection julga essa decisão como uma enviada sem IP, e vê bem menos. É o que um servidor envia quando lê o endereço da conexão atrás de um load balancer. Quando a maioria das suas decisões traz um endereço assim, a página Configuração avisa. 127.0.0.1 e ::1, enquanto você testa na sua máquina, não são problema. Atrás de um proxy no mesmo host (o proxy_pass do nginx para localhost) eles são do proxy para todo visitante, e a página Configuração também avisa assim que a tag mostra que as suas páginas não estão em localhost.
Um endereço de uma borda de CDN (da Cloudflare, da Fastly, do CloudFront, conforme as faixas que cada um publica) é da CDN, o mesmo para todos os visitantes atrás dela: o seu servidor enviou o endereço de onde veio a própria requisição. O Bot Protection também julga essa decisão como uma enviada sem IP, e, quando a maioria das suas decisões traz um, a página Configuração avisa.
A resposta
{
"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 | Significado |
|---|---|
request_id | o ID desta decisão, de até 96 caracteres: envie-o com o feedback, ou para a gente quando tiver dúvida sobre uma resposta |
bot_score, human_score | de 0 a 100: quanta evidência de automação, e de pessoa, sustenta a classe |
action | o que fazer: allow, challenge ou block. Sempre allow em monitor mode. |
intended_action | o que o block mode responderia |
class | human_high, human, unknown, bot ou bot_high |
reasons | os tipos de evidência por trás, nunca as regras exatas: behaviour, inconsistency, environment, missing_signals, history |
rules | uma lista vazia, [], a menos que a gente tenha ligado os nomes das regras na sua conta para investigar um caso com você: aí, as regras que dispararam, pelo nome |
mode | monitor, block ou paused (você pausou a proteção na página Configuração: action é allow) |
step_up | evidência forte para os dois lados: peça uma passkey ou 3-D Secure, não um CAPTCHA |
challenge | quando intended_action é challenge: url, a nossa página de desafio para este visitante quando action é challenge (veja a página de desafio), senão null; e passed, true quando esta sessão resolveu a página de desafio nos últimos 30 minutos, e o seu challenge passa a ser allow. null nos outros casos |
token_required | true quando você enviou require_token e a requisição não trazia um token válido para esta ação, ou trazia um que esta sessão já usou nela: o action é então challenge, e a classe não muda (veja uma ação que precisa do tag). false nos outros casos |
crawler | {"name", "kind"} para um crawler que se identifica e vem dos endereços que seu fornecedor publica, como GPTBot, ClaudeBot ou Googlebot: continua bot, mas você pode deixá-lo passar se o convidar. kind é search (buscadores), assistant (assistentes de IA e busca com IA, que indexam para as suas respostas ou buscam uma página que uma pessoa pediu, não para treino), scrape (treino de IA e datasets), seo (ferramentas de SEO), preview (prévias de links), uptime (monitores de disponibilidade) ou scan (scanners da internet como o Censys). Todo tipo é allow, a menos que você o mude para block no Set-up, exceto scrape e scan, que são block, a menos que você os mude para allow: um crawler de treino de IA leva suas páginas para um modelo e não traz ninguém de volta, e um scanner só mapeia o que o seu site expõe. A escolha de um tipo é o padrão dos seus crawlers, e você pode deixar passar ou bloquear cada crawler por si; no block mode, action é essa escolha. null nos outros casos |
seen_before | quantas sessões anteriores trouxeram este navegador a este caminho nos últimos 90 dias, pelo identificador de dispositivo da tag: num formulário de cadastro, a mesma pessoa abrindo outra conta. 0 quando nenhuma, sem a tag, ou numa visualização de página. Num caminho que o Bot Protection aprendeu como cadastro (a página Configuração os lista), um navegador já visto acrescenta o motivo history, que sozinho nunca dá bot |
Todo membro está sempre presente: um objeto que não tem nada a dizer é null, uma lista sem nada é [], nunca null.
Erros
Com qualquer status diferente de 200 (uma chave errada, o limite do mês atingido, uma queda), deixe o visitante passar. O corpo de um erro é um problem RFC 9457, application/problem+json, cujo code diz o que deu errado:
{
"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 | o corpo não é JSON, um campo tem o tipo errado, ou o Bot Protection não o conhece |
| 401 | unauthorized | nenhuma API key, ou uma que o Bot Protection não conhece |
| 402 | over_limit | o teste grátis ou as decisões do mês acabaram |
| 402, 403 | domain_refused | o domínio da página não é um dos que a site key protege (as chamadas da tag) |
| 404 | not_found | essa rota não existe |
| 405 | method_not_allowed | a rota aceita outros métodos, que o Allow lista |
| 409 | conflict, idempotency_key_in_use | o que a chamada criaria já existe, ou a primeira chamada com essa Idempotency-Key ainda está em andamento |
| 413 | body_too_large | o corpo passa do limite da rota |
| 422 | idempotency_key_reused | a Idempotency-Key foi usada há menos de uma hora com outro corpo |
| 429 | rate_limited | chamadas demais de uma rede às rotas da tag, ou, em events e feedback, os registros de Idempotency-Key da hora da sua API key no limite; com Retry-After |
| 503 | over_capacity, deadline, unavailable | chamadas demais ao mesmo tempo, nenhuma decisão dentro do seu deadline, ou um serviço do qual o Bot Protection depende está fora do ar, com Retry-After |
| 500 | internal | o Bot Protection falhou |
Toda resposta traz Korzen-Request-Id, igual ao request_id do problem: informe-o quando falar com a gente sobre uma chamada. Decide, events e feedback trazem os headers RateLimit-Policy e RateLimit do IETF para as decisões da sua conta: "month";q=50000 e "month";r=41234;t=86400 dizem que restam 41.234 das 50.000 do mês e que o mês acaba em um dia (no teste grátis, "trial"). Registre-os se quiser, e nunca espere por eles. Os SDKs passam os dois para onAnswer (em Go, OnAnswer) depois de cada chamada.
O que o seu servidor sabe
Fatos que o seu servidor verificou por conta própria deixam a resposta mais confiável. Envie-os em claims, todos opcionais:
"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
| Claim | Quando |
|---|---|
account_age_days | a idade da conta, em dias, num login ou checkout; só pesa a partir de 30 |
known_instrument | o mesmo cartão ou passkey dos pedidos anteriores da conta |
device_attested | passou numa atestação da plataforma: uma passkey, App Attest, Play Integrity, um Private Access Token |
contact_verified | o email ou o telefone está verificado |
Com feedback de que o visitante é um bom cliente, known_instrument ou device_attested é o que eleva uma resposta a human_high. Envie só o que você verificou: um claim é evidência de uma pessoa.
Latência e o seu timeout
O Bot Protection roda em Falkenstein, na Alemanha. Com a conexão já aberta, uma decisão leva o tempo de ida e volta entre o seu servidor e a Alemanha, mais alguns milissegundos; uma conexão nova acrescenta o handshake TLS, ou seja, uma ou duas idas e voltas. Use um único cliente HTTP por processo, como os exemplos abaixo, e escolha o seu timeout conforme onde o seu servidor roda. Envie-o como Korzen-Deadline-Ms, quanto a sua chamada espera ao todo: o Bot Protection usa metade para a decisão, entre 10 e 1.000 ms (200 ms sem o header), e deixa a outra metade para a rede. Uma decisão que não sai dentro da sua metade recebe 503 deadline na hora, e não é contada. Os SDKs enviam o timeout deles por você.
| Seu servidor na | Ida e volta | Timeout |
|---|---|---|
| Europa | 10–40 ms | 100–200 ms |
| Costa leste dos EUA | ~85 ms | 200 ms |
| Costa oeste dos EUA | ~145 ms | 300 ms |
| América do Sul | ~190 ms | 500 ms |
Um timeout curto demais para a distância deixa todo visitante passar, e essas chamadas contam como decisões mesmo assim.
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