Korzen
Bot Protection

A chamada do seu servidor

POST /v1/decide antes de um cadastro, um login ou um checkout: o que enviar, o que volta e código para Next.js, Express, Go e Python.

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
}
CampoSignificado
request_ido 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_scorede 0 a 100: quanta evidência de automação, e de pessoa, sustenta a classe
actiono que fazer: allow, challenge ou block. Sempre allow em monitor mode.
intended_actiono que o block mode responderia
classhuman_high, human, unknown, bot ou bot_high
reasonsos tipos de evidência por trás, nunca as regras exatas: behaviour, inconsistency, environment, missing_signals, history
rulesuma 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
modemonitor, block ou paused (você pausou a proteção na página Configuração: action é allow)
step_upevidência forte para os dois lados: peça uma passkey ou 3-D Secure, não um CAPTCHA
challengequando 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_requiredtrue 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_beforequantas 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"
}
StatuscodeQuando
400invalid_json, invalid_field, unknown_fieldo corpo não é JSON, um campo tem o tipo errado, ou o Bot Protection não o conhece
401unauthorizednenhuma API key, ou uma que o Bot Protection não conhece
402over_limito teste grátis ou as decisões do mês acabaram
402, 403domain_refusedo domínio da página não é um dos que a site key protege (as chamadas da tag)
404not_foundessa rota não existe
405method_not_alloweda rota aceita outros métodos, que o Allow lista
409conflict, idempotency_key_in_useo que a chamada criaria já existe, ou a primeira chamada com essa Idempotency-Key ainda está em andamento
413body_too_largeo corpo passa do limite da rota
422idempotency_key_reuseda Idempotency-Key foi usada há menos de uma hora com outro corpo
429rate_limitedchamadas 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
503over_capacity, deadline, unavailablechamadas 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
500internalo 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}
ClaimQuando
account_age_daysa idade da conta, em dias, num login ou checkout; só pesa a partir de 30
known_instrumento mesmo cartão ou passkey dos pedidos anteriores da conta
device_attestedpassou numa atestação da plataforma: uma passkey, App Attest, Play Integrity, um Private Access Token
contact_verifiedo 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 naIda e voltaTimeout
Europa10–40 ms100–200 ms
Costa leste dos EUA~85 ms200 ms
Costa oeste dos EUA~145 ms300 ms
América do Sul~190 ms500 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