Korzen
Bot Protection

La llamada desde tu servidor

POST /v1/decide antes de un registro, un inicio de sesión o un checkout: qué enviar, qué vuelve y código para Next.js, Express, Go y Python.

Antes de la acción protegida, tu servidor le pregunta a Bot Protection por la solicitud y actúa según la action de la respuesta. Usa un timeout corto y deja pasar al visitante ante cualquier error: eso es fail open, y significa que Bot Protection nunca puede tumbar tu registro.

La solicitud

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"}'

Todos los campos son opcionales, y cuanto más envíes, más ve Bot Protection. Un campo que Bot Protection no conoce, como requireToken en vez de require_token, se rechaza con 400 unknown_field, que lo nombra: un error de tipeo nunca pasa como un valor por defecto.

En headers, envía estos tal como los envió el navegador del visitante, con el nombre en minúsculas, y omite los que la petición no trae: 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 y upgrade-insecure-requests. Todas las copias de una misma build de navegador envían los mismos valores, así que un script que copia el User-Agent de un navegador y no el resto se nota. Los SDKs y el código de abajo envían esta lista.

Desde Node, envía también header_order: los nombres de los headers de la solicitud en el orden en que llegaron, en minúsculas (una de cada dos entradas de req.rawHeaders), como ["host", "user-agent", "accept"]. Un script copia los valores de los headers de un navegador más a menudo que su orden. Donde el orden se pierde (el net/http de Go, los Headers de la Fetch API, un Cloudflare Worker), déjalo fuera; un valor que no es una lista de nombres de headers se ignora, nunca se rechaza.

Un valor más largo de lo que Bot Protection guarda se corta, nunca se rechaza: 512 bytes del User-Agent, del path y de cada header, 32 headers (los primeros por nombre), 256 bytes del token. Los SDKs los cortan antes de enviar la llamada; una llamada propia puede enviarlos enteros. El cuerpo se lee hasta 256 KiB, donde una llamada dentro de esos límites siempre cabe; más allá, se juzgan los campos que llegan enteros dentro de 256 KiB, así que un header enorme escrito por un visitante no cuesta una decisión.

La IP del visitante

Detrás de un proxy o CDN, envía la IP del visitante como la informa tu proxy o CDN (CF-Connecting-IP en Cloudflare, la dirección que tu propio balanceador añadió a X-Forwarded-For), no una que el visitante haya podido escribir en una cabecera.

Una dirección que no está en Internet (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10) es de tu propia red: de un balanceador, de un proxy, de un contenedor. Nunca es de un visitante, así que Bot Protection juzga esa decisión como una enviada sin IP, y ve mucho menos. Es lo que envía un servidor que lee la dirección de la conexión detrás de un balanceador. Cuando la mayoría de tus decisiones trae una, la página Configuración lo avisa. 127.0.0.1 y ::1, mientras pruebas en tu máquina, están bien. Detrás de un proxy en el mismo host (el proxy_pass de nginx a localhost) son los del proxy para cada visitante, y la página Configuración también lo dice en cuanto el tag muestra que tus páginas no están en localhost.

Una dirección de un borde de CDN (de Cloudflare, de Fastly, de CloudFront, según los rangos que publica cada uno) es de la CDN, la misma para todos los visitantes detrás de ella: tu servidor envió la dirección de la que vino su propia petición. Bot Protection también juzga esa decisión como una enviada sin IP y, cuando la mayoría de tus decisiones trae una, la página Configuración lo indica.

La respuesta

{
  "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_idel ID de esta decisión, de 96 caracteres como mucho: envíalo con el feedback, o a nosotros cuando preguntes por una respuesta
bot_score, human_scorede 0 a 100: cuánta evidencia de automatización, y de presencia humana, respalda la clase
actionqué hacer: allow, challenge o block. Siempre allow en monitor mode.
intended_actionlo que respondería el block mode
classhuman_high, human, unknown, bot o bot_high
reasonslos tipos de evidencia que la respaldan, nunca las reglas exactas: behaviour, inconsistency, environment, missing_signals, history
rulesuna lista vacía, [], salvo que activemos los nombres de las reglas en tu cuenta para estudiar un caso contigo: entonces las reglas que se activaron, por nombre
modemonitor, block o paused (pausaste la protección en la página Configuración: action es allow)
step_upevidencia fuerte en ambos sentidos: pide una passkey o 3-D Secure, no un CAPTCHA
challengecuando intended_action es challenge: url, nuestra página de desafío para este visitante cuando action es challenge (consulta la página de desafío), si no null; y passed, true cuando esta sesión resolvió la página de desafío en los últimos 30 minutos, y su challenge es entonces allow. null en los demás casos
token_requiredtrue cuando enviaste require_token y la petición no traía un token válido para esta acción, o traía uno que esta sesión ya usó en ella: el action es entonces challenge, y la clase no cambia (ver una acción que necesita el tag). false en los demás casos
crawler{"name", "kind"} para un crawler que se nombra a sí mismo y llega desde las direcciones que publica su proveedor, como GPTBot, ClaudeBot o Googlebot: sigue siendo bot, pero puede dejarlo pasar si lo invita. kind es search (buscadores), assistant (asistentes de IA y búsqueda con IA, que indexan para sus respuestas o traen una página que una persona pidió, no para entrenar), scrape (entrenamiento de IA y datasets), seo (herramientas de SEO), preview (vistas previas de enlaces), uptime (monitores de disponibilidad) o scan (escáneres de internet como Censys). Todo tipo es allow salvo que lo ponga en block en Set-up, excepto scrape y scan, que son block salvo que los ponga en allow: un crawler de entrenamiento de IA se lleva sus páginas para un modelo y no le devuelve a nadie, y un escáner solo mapea lo que su sitio expone. La elección de un tipo es la predeterminada de sus crawlers, y puede dejar pasar o bloquear cada crawler por separado; en block mode, action es esa elección. null en los demás casos
seen_beforecuántas sesiones anteriores trajeron este navegador a esta ruta en los últimos 90 días, por el identificador de dispositivo de la etiqueta: en un formulario de registro, la misma persona abriendo otra cuenta. 0 si ninguna, sin la etiqueta o en una vista de página. En una ruta que Bot Protection aprendió como registro (la página Configuración las lista), un navegador ya visto añade el motivo history, que por sí solo nunca da bot

Todos los campos están siempre: un objeto sin nada que decir es null, una lista vacía es [], nunca null.

Errores

Cualquier estado distinto de 200 (una clave incorrecta, el límite del mes alcanzado, una caída) significa: deja pasar al visitante. El cuerpo de un error es un problem RFC 9457, application/problem+json, cuyo code dice qué falló:

{
  "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"
}
EstadocodeCuándo
400invalid_json, invalid_field, unknown_fieldel cuerpo no es JSON, un campo tiene el tipo equivocado, o Bot Protection no lo conoce
401unauthorizedsin API key, o con una que Bot Protection no conoce
402over_limitla prueba gratuita o las decisiones del mes están agotadas
402, 403domain_refusedel dominio de la página no es uno de los que protege la site key (las llamadas de la tag)
404not_foundesa ruta no existe
405method_not_allowedla ruta acepta otros métodos, que Allow nombra
409conflict, idempotency_key_in_uselo que la llamada crearía ya existe, o la primera llamada con esta Idempotency-Key sigue en curso
413body_too_largeel cuerpo pasa del límite de la ruta
422idempotency_key_reusedla Idempotency-Key se usó hace menos de una hora con otro cuerpo
429rate_limiteddemasiadas llamadas desde una red a las rutas de la tag, o, en events y feedback, los registros de Idempotency-Key de la hora de tu API key en su límite; con Retry-After
503over_capacity, deadline, unavailabledemasiadas llamadas a la vez, ninguna decisión dentro de tu deadline, o un servicio del que depende Bot Protection está caído, con Retry-After
500internalBot Protection falló

Toda respuesta lleva Korzen-Request-Id, el mismo que el request_id del problem: nómbralo cuando nos preguntes por una llamada. Decide, events y feedback llevan los headers RateLimit-Policy y RateLimit del IETF para las decisiones de tu cuenta: "month";q=50000 y "month";r=41234;t=86400 dicen que quedan 41.234 de las 50.000 del mes y que el mes termina en un día (en la prueba gratuita, "trial"). Regístralos si quieres, y nunca esperes por ellos. Los SDKs entregan ambos a onAnswer (en Go, OnAnswer) después de cada llamada.

Lo que sabe tu servidor

Los hechos que tu servidor comprobó por su cuenta hacen que la respuesta sea más fiable. Envíalos en claims, todos opcionales:

"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
ClaimCuándo
account_age_daysla antigüedad de la cuenta, en días, en un inicio de sesión o un checkout; cuenta a partir de 30
known_instrumentla misma tarjeta o passkey que los pedidos anteriores de la cuenta
device_attestedpasó una atestación de la plataforma: una passkey, App Attest, Play Integrity, un Private Access Token
contact_verifiedel email o el teléfono está verificado

Con feedback de que el visitante es un buen cliente, known_instrument o device_attested es lo que eleva una respuesta a human_high. Envía solo lo que comprobaste: un claim es evidencia de una persona.

Latencia y tu timeout

Bot Protection está alojado en Falkenstein (Alemania). Una decisión tarda el viaje de ida y vuelta de la red desde tu servidor más unos milisegundos, sobre una conexión que tu servidor mantiene abierta; una conexión nueva suma el handshake TLS, uno o dos viajes más. Mantén un solo cliente HTTP para todo el proceso, como en los ejemplos de abajo, y elige tu timeout según dónde esté tu servidor. Envíalo como Korzen-Deadline-Ms, cuánto espera tu llamada en total: Bot Protection toma la mitad para la decisión, entre 10 y 1.000 ms (200 ms sin el header), y deja la otra mitad para la red. Una decisión que no se toma dentro de su mitad se responde 503 deadline enseguida, y no se cuenta. Los SDKs envían su timeout por ti.

Tu servidor enIda y vueltaTimeout
Europa10–40 ms100–200 ms
Este de EE. UU.~85 ms200 ms
Oeste de EE. UU.~145 ms300 ms
Sudamérica~190 ms500 ms

Un timeout demasiado corto para la distancia deja pasar a todos los visitantes, y esas llamadas cuentan como decisiones igualmente.

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