Korzen
Bot Protection

L'appel depuis votre serveur

POST /v1/decide avant une inscription, une connexion ou un checkout : ce qu'il faut envoyer, ce qui revient, et du code pour Next.js, Express, Go et Python.

Avant l'action protégée, votre serveur interroge Bot Protection sur la requête et applique l'action de la réponse. Utilisez un timeout court, et laissez passer le visiteur en cas d'erreur : c'est le fail-open, et cela signifie que Bot Protection ne peut jamais faire tomber votre inscription.

La requête

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

Chaque champ est facultatif, et plus vous en envoyez, plus Bot Protection en voit. Un champ que Bot Protection ne connaît pas, comme requireToken pour require_token, est refusé avec 400 unknown_field, qui le nomme : une faute de frappe ne passe jamais pour une valeur par défaut.

Dans headers, envoyez ceux-ci tels que le navigateur du visiteur les a envoyés, sous leur nom en minuscules, et omettez ceux que la requête ne porte pas : 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 et upgrade-insecure-requests. Toutes les copies d'un même build de navigateur envoient les mêmes valeurs : un script qui copie le User-Agent d'un navigateur sans le reste se remarque. Les SDK et le code ci-dessous envoient cette liste.

Depuis Node, envoyez aussi header_order : les noms des headers de la requête dans l'ordre où ils sont arrivés, en minuscules (une entrée sur deux de req.rawHeaders), comme ["host", "user-agent", "accept"]. Un script copie les valeurs des headers d'un navigateur plus souvent que leur ordre. Là où l'ordre est perdu (le net/http de Go, les Headers de la Fetch API, un Cloudflare Worker), omettez-le ; une valeur qui n'est pas une liste de noms de headers est ignorée, jamais refusée.

Une valeur plus longue que ce que Bot Protection garde est coupée, jamais refusée : 512 octets du User-Agent, du chemin et de chaque header, 32 headers (les premiers par nom), 256 octets du token. Les SDKs les coupent avant d'envoyer l'appel ; un appel de votre main peut les envoyer entiers. Le corps est lu jusqu'à 256 KiB, où un appel dans ces limites tient toujours ; au-delà, les champs arrivés entiers dans les 256 KiB sont jugés, si bien qu'un header démesuré écrit par un visiteur ne coûte pas de décision.

L'IP du visiteur

Derrière un proxy ou un CDN, envoyez l'IP du visiteur telle que votre proxy ou CDN la rapporte (CF-Connecting-IP chez Cloudflare, l'adresse que votre propre load balancer a ajoutée à X-Forwarded-For), et non une IP que le visiteur aurait pu écrire dans un en-tête.

Une adresse qui n'est pas sur Internet (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10) appartient à votre propre réseau : un load balancer, un proxy, un conteneur. Ce n'est jamais celle d'un visiteur : Bot Protection juge donc cette décision comme une décision envoyée sans IP, et en voit bien moins. C'est ce qu'envoie un serveur qui lit l'adresse de la connexion derrière un load balancer. Quand la plupart de vos décisions en portent une, la page Configuration le signale. 127.0.0.1 et ::1, pendant vos tests sur votre machine, ne posent pas de problème. Derrière un proxy sur le même hôte (le proxy_pass de nginx vers localhost), ce sont celles du proxy pour chaque visiteur, et la page Configuration le signale aussi dès que le tag montre que vos pages ne sont pas sur localhost.

Une adresse d'un edge de CDN (de Cloudflare, de Fastly, de CloudFront, selon les plages que chacun publie) appartient au CDN, la même pour chaque visiteur derrière lui : votre serveur a envoyé l'adresse d'où venait sa propre requête. Bot Protection juge aussi cette décision comme une décision envoyée sans IP, et quand la plupart de vos décisions en portent une, la page Configuration le signale.

La réponse

{
  "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
}
ChampSignification
request_idl'identifiant de cette décision, 96 caractères au plus : envoyez-le avec le feedback, ou à nous quand vous posez une question sur une réponse
bot_score, human_scorede 0 à 100 : la quantité de preuves d'automatisation, et d'une personne, sur laquelle repose la classe
actionce qu'il faut faire : allow, challenge ou block. Toujours allow en mode monitor.
intended_actionce que répondrait le mode block
classhuman_high, human, unknown, bot ou bot_high
reasonsles types de preuves derrière, jamais les règles exactes : behaviour, inconsistency, environment, missing_signals, history
rulesune liste vide, [], sauf si nous avons activé les noms des règles sur votre compte pour examiner un cas avec vous : alors les règles qui ont joué, par leur nom
modemonitor, block ou paused (vous avez mis la protection en pause sur la page Configuration : action vaut allow)
step_upde fortes preuves dans les deux sens : demandez une passkey ou 3-D Secure, pas un CAPTCHA
challengequand intended_action vaut challenge : url, notre page de challenge pour ce visiteur quand action vaut challenge (voir la page de challenge), sinon null ; et passed, true quand cette session a résolu la page de challenge dans les 30 dernières minutes, son challenge devenant alors allow. null sinon
token_requiredtrue quand vous avez envoyé require_token et que la requête ne portait aucun token valide pour cette action, ou un token que cette session y a déjà utilisé : action est alors challenge, la classe inchangée (voir une action qui demande le tag). false sinon
crawler{"name", "kind"} pour un crawler qui se nomme lui-même et vient des adresses que publie son éditeur, comme GPTBot, ClaudeBot ou Googlebot : toujours bot, mais vous pouvez le laisser passer si vous l'invitez. kind vaut search (moteurs de recherche), assistant (assistants d'IA et recherche par IA, qui indexent pour leurs réponses ou vont chercher une page qu'une personne a demandée, pas pour l'entraînement), scrape (entraînement d'IA et jeux de données), seo (outils SEO), preview (aperçus de liens), uptime (moniteurs de disponibilité) ou scan (scanners d'Internet comme Censys). Chaque type est allow sauf si vous le mettez à block dans Set-up, à l'exception de scrape et scan, qui sont block sauf si vous les mettez à allow : un crawler d'entraînement d'IA emporte vos pages pour un modèle et ne vous renvoie personne, et un scanner cartographie seulement ce que votre site expose. Le choix d'un type est celui de ses crawlers par défaut, et vous pouvez laisser passer ou bloquer chaque crawler à part ; en mode block, action est ce choix. null sinon
seen_beforecombien de sessions précédentes ont amené ce navigateur sur ce chemin ces 90 derniers jours, d'après l'identifiant d'appareil du tag : sur un formulaire d'inscription, la même personne qui ouvre un autre compte. 0 si aucune, sans le tag ou sur une vue de page. Sur un chemin que Bot Protection a appris comme une inscription (la page Configuration les liste), un navigateur déjà vu ajoute la raison history, qui ne donne jamais bot à elle seule

Chaque champ est toujours présent : un objet sans rien à dire vaut null, une liste vide vaut [], jamais null.

Erreurs

Tout statut autre que 200 (une mauvaise clé, la limite du mois atteinte, une panne) signifie : laissez passer le visiteur. Le corps d'une erreur est un problem RFC 9457, application/problem+json, dont le code dit ce qui n'a pas marché :

{
  "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"
}
StatutcodeQuand
400invalid_json, invalid_field, unknown_fieldle corps n'est pas du JSON, un champ a le mauvais type, ou Bot Protection ne le connaît pas
401unauthorizedaucune clé API, ou une que Bot Protection ne connaît pas
402over_limitl'essai gratuit ou les décisions du mois sont épuisés
402, 403domain_refusedle domaine de la page n'est pas l'un de ceux que la site key protège (les appels de la tag)
404not_foundcette route n'existe pas
405method_not_allowedla route accepte d'autres méthodes, que Allow nomme
409conflict, idempotency_key_in_usece que l'appel créerait existe déjà, ou le premier appel avec cette Idempotency-Key est encore en cours
413body_too_largele corps dépasse la limite de la route
422idempotency_key_reusedl'Idempotency-Key a servi il y a moins d'une heure avec un autre corps
429rate_limitedtrop d'appels depuis un réseau vers les routes de la tag, ou, sur events et feedback, les enregistrements d'Idempotency-Key de l'heure de votre clé API à leur limite ; avec Retry-After
503over_capacity, deadline, unavailabletrop d'appels à la fois, aucune décision dans votre deadline, ou un service dont dépend Bot Protection est indisponible, avec Retry-After
500internalBot Protection a échoué

Chaque réponse porte Korzen-Request-Id, le même que le request_id du problem : citez-le quand vous nous posez une question sur un appel. Decide, events et feedback portent les headers RateLimit-Policy et RateLimit de l'IETF pour les décisions de votre compte : "month";q=50000 et "month";r=41234;t=86400 disent qu'il reste 41 234 des 50 000 du mois et que le mois finit dans un jour (pendant l'essai gratuit, "trial"). Journalisez-les si vous voulez, et ne les attendez jamais. Les SDK passent les deux à onAnswer (en Go, OnAnswer) après chaque appel.

Ce que sait votre serveur

Les faits que votre serveur a vérifiés lui-même rendent la réponse plus sûre. Envoyez-les dans claims, tous facultatifs :

"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
ClaimQuand
account_age_daysl'âge du compte, en jours, lors d'une connexion ou d'un checkout ; il compte à partir de 30
known_instrumentla même carte ou passkey que pour les commandes passées du compte
device_attestedune attestation de plateforme réussie : une passkey, App Attest, Play Integrity, un Private Access Token
contact_verifiedl'e-mail ou le numéro de téléphone est vérifié

Avec un feedback indiquant que le visiteur est un bon client, known_instrument ou device_attested fait passer une réponse à human_high. N'envoyez que ce que vous avez vérifié : un claim compte comme une preuve qu'il s'agit d'une personne.

La latence et votre timeout

Bot Protection tourne à Falkenstein, en Allemagne. Une décision prend l'aller-retour réseau depuis votre serveur plus quelques millisecondes, sur une connexion que votre serveur garde ouverte ; une nouvelle connexion ajoute la poignée de main TLS, un ou deux allers-retours de plus. Gardez un seul client HTTP pour tout le processus, comme les exemples ci-dessous, et choisissez votre timeout selon l'endroit où tourne votre serveur. Envoyez-le comme Korzen-Deadline-Ms, le temps que votre appel attend en tout : Bot Protection en prend la moitié pour la décision, entre 10 et 1 000 ms (200 ms sans le header), et laisse l'autre moitié au réseau. Une décision qui n'est pas prise dans sa moitié est répondue 503 deadline aussitôt, et n'est pas comptée. Les SDK envoient leur timeout pour vous.

Emplacement de votre serveurAller-retourTimeout
Europe10–40 ms100–200 ms
Est des États-Unis~85 ms200 ms
Ouest des États-Unis~145 ms300 ms
Amérique du Sud~190 ms500 ms

Un timeout trop court pour la distance laisse passer chaque visiteur, et ces appels comptent quand même comme des décisions.

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