Korzen
Bot Protection

Der Aufruf von Ihrem Server

POST /v1/decide vor einer Registrierung, einer Anmeldung oder einem Checkout: was Sie senden, was zurückkommt, und Code für Next.js, Express, Go und Python.

Vor der geschützten Aktion fragt Ihr Server Bot Protection zu der Anfrage und wendet die action aus der Antwort an. Verwenden Sie einen kurzen Timeout und lassen Sie den Besucher bei jedem Fehler durch: Das ist fail-open, und es bedeutet, dass Bot Protection Ihre Registrierung nie lahmlegen kann.

Die Anfrage

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

Jedes Feld ist optional, und je mehr Sie senden, desto mehr sieht Bot Protection. Ein Feld, das Bot Protection nicht kennt, etwa requireToken statt require_token, wird mit 400 unknown_field abgelehnt, und die Antwort nennt es: Ein Tippfehler geht nie als Standardwert durch.

Senden Sie in headers diese, wie der Browser des Besuchers sie gesendet hat, unter ihren kleingeschriebenen Namen, und lassen Sie die weg, die die Anfrage nicht trägt: 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 und upgrade-insecure-requests. Jede Kopie eines Browser-Builds sendet dieselben Werte, daher fällt ein Skript auf, das den User-Agent eines Browsers kopiert und den Rest nicht. Die SDKs und der Code unten senden diese Liste.

Aus Node senden Sie auch header_order: die Namen der Header der Anfrage in der Reihenfolge, in der sie kamen, kleingeschrieben (jeder zweite Eintrag von req.rawHeaders), etwa ["host", "user-agent", "accept"]. Ein Skript kopiert die Header-Werte eines Browsers öfter als ihre Reihenfolge. Wo die Reihenfolge verloren geht (Gos net/http, die Headers der Fetch API, ein Cloudflare Worker), lassen Sie das Feld weg; ein Wert, der keine Liste von Header-Namen ist, wird ignoriert, nie abgelehnt.

Ein Wert, der länger ist als das, was Bot Protection behält, wird gekürzt, nie abgelehnt: 512 Bytes des User-Agent, des Pfads und jedes Headers, 32 Header (die ersten nach Namen), 256 Bytes des Tokens. Die SDKs kürzen sie, bevor sie den Aufruf senden; ein eigener Aufruf darf sie ganz senden. Der Body wird bis 256 KiB gelesen, in die ein Aufruf innerhalb dieser Grenzen immer passt; darüber hinaus werden die Felder beurteilt, die innerhalb von 256 KiB ganz ankommen, sodass ein übergroßer Header, den ein Besucher schreibt, keine Entscheidung kostet.

Die IP des Besuchers

Hinter einem Proxy oder CDN senden Sie die IP des Besuchers so, wie Ihr Proxy oder CDN sie meldet (CF-Connecting-IP bei Cloudflare, die Adresse, die Ihr eigener Load Balancer an X-Forwarded-For angehängt hat), nicht eine, die der Besucher in einen Header geschrieben haben könnte.

Eine Adresse, die nicht im Internet liegt (10.x, 172.16–31.x, 192.168.x, 100.64–127.x, fc00::/7, fe80::/10), gehört zu Ihrem eigenen Netz: zu einem Load Balancer, einem Proxy, einem Container. Sie ist nie die eines Besuchers, deshalb beurteilt Bot Protection eine solche Entscheidung wie eine ohne IP und sieht viel weniger. Ein Server sendet sie, wenn er hinter einem Load Balancer die Adresse der Verbindung liest. Tragen die meisten Ihrer Entscheidungen eine solche Adresse, weist die Seite Einrichtung darauf hin. 127.0.0.1 und ::1 sind in Ordnung, solange Sie auf Ihrem Rechner testen. Hinter einem Proxy auf demselben Host (nginx' proxy_pass auf localhost) sind sie für jeden Besucher die des Proxys, und auch das meldet die Seite Einrichtung, sobald der Tag zeigt, dass Ihre Seiten nicht auf localhost liegen.

Eine Adresse eines CDN-Edge (von Cloudflare, Fastly, CloudFront, wie jeder seine Bereiche veröffentlicht) gehört dem CDN und ist für jeden Besucher dahinter dieselbe: Ihr Server hat die Adresse gesendet, von der seine eigene Anfrage kam. Bot Protection beurteilt eine solche Entscheidung ebenfalls wie eine ohne IP, und wenn die meisten Ihrer Entscheidungen eine tragen, sagt es die Seite Einrichtung.

Die Antwort

{
  "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
}
FeldBedeutung
request_iddie ID dieser Entscheidung, höchstens 96 Zeichen: senden Sie sie mit Feedback, oder an uns, wenn Sie eine Frage zu einer Antwort haben
bot_score, human_score0 bis 100: wie viele Belege für Automatisierung und für einen Menschen hinter der Klasse stehen
actionwas zu tun ist: allow, challenge oder block. Im Monitor-Modus immer allow.
intended_actionwas der Block-Modus antworten würde
classhuman_high, human, unknown, bot oder bot_high
reasonsdie Arten von Belegen dahinter, nie die genauen Regeln: behaviour, inconsistency, environment, missing_signals, history
ruleseine leere Liste, [], außer wir haben die Regelnamen für Ihr Konto eingeschaltet, um einen Fall mit Ihnen zu untersuchen: dann die Regeln, die gegriffen haben, mit Namen
modemonitor, block oder paused (Sie haben den Schutz auf der Seite Einrichtung pausiert: action ist allow)
step_upstarke Belege in beide Richtungen: Fragen Sie nach einem Passkey oder 3-D Secure, nicht nach einem CAPTCHA
challengewenn intended_action challenge ist: url, unsere Challenge-Seite für diesen Besucher, wenn action challenge ist (siehe die Challenge-Seite), sonst null; und passed, true, wenn diese Session die Challenge-Seite in den letzten 30 Minuten gelöst hat, ihr challenge ist dann allow. Sonst null
token_requiredtrue, wenn Sie require_token gesendet haben und die Anfrage kein gültiges Token für diese Aktion trug, oder eines, das diese Session für sie schon benutzt hat: action ist dann challenge, die Klasse bleibt (siehe Eine Aktion, die den Tag braucht). Sonst false
crawler{"name", "kind"} für einen Crawler, der sich selbst nennt und von den Adressen kommt, die sein Anbieter veröffentlicht, etwa GPTBot, ClaudeBot oder Googlebot: weiterhin bot, aber einer, den Sie durchlassen können, wenn Sie ihn einladen. kind ist search (Suchmaschinen), assistant (KI-Assistenten und KI-Suche, die für ihre Antworten indexieren oder eine Seite abrufen, nach der eine Person gefragt hat, nicht fürs Training), scrape (KI-Training und Datensätze), seo (SEO-Tools), preview (Linkvorschauen), uptime (Uptime-Monitore) oder scan (Internet-Scanner wie Censys). Jede Art ist allow, solange Sie sie in Set-up nicht auf block setzen, außer scrape und scan, die block sind, solange Sie sie nicht auf allow setzen: Ein KI-Trainings-Crawler nimmt Ihre Seiten für ein Modell mit und schickt niemanden zurück, und ein Scanner kartiert nur, was Ihre Website preisgibt. Die Wahl einer Art ist die Vorgabe ihrer Crawler, und Sie können jeden Crawler einzeln durchlassen oder blockieren; im Block-Modus ist action diese Wahl. Sonst null
seen_beforewie viele frühere Sitzungen diesen Browser in den letzten 90 Tagen auf diesen Pfad gebracht haben, nach der Geräte-ID des Tags: auf einem Registrierungsformular dieselbe Person, die ein weiteres Konto eröffnet. 0 bei keiner, ohne Tag oder bei einem Seitenaufruf. Auf einem Pfad, den Bot Protection als Registrierung gelernt hat (die Seite Einrichtung listet sie), fügt ein schon gesehener Browser den Grund history hinzu, der allein nie bot ergibt

Jedes Feld ist immer vorhanden: ein Objekt ohne Aussage ist null, eine leere Liste ist [], nie null.

Fehler

Jeder andere Status als 200 (ein falscher Schlüssel, ein erreichtes Monatslimit, ein Ausfall) bedeutet: den Besucher durchlassen. Der Body eines Fehlers ist ein RFC 9457-Problem, application/problem+json, dessen code sagt, was schiefging:

{
  "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"
}
StatuscodeWann
400invalid_json, invalid_field, unknown_fieldder Body ist kein JSON, ein Feld hat den falschen Typ, oder Bot Protection kennt es nicht
401unauthorizedkein API-Schlüssel, oder einer, den Bot Protection nicht kennt
402over_limitdie kostenlose Testphase oder die Entscheidungen des Monats sind aufgebraucht
402, 403domain_refuseddie Domain der Seite ist keine, die der Site Key schützt (die Aufrufe des Tags)
404not_founddiese Route gibt es nicht
405method_not_alloweddie Route nimmt andere Methoden an, die Allow nennt
409conflict, idempotency_key_in_usewas der Aufruf anlegen würde, gibt es schon, oder der erste Aufruf mit diesem Idempotency-Key läuft noch
413body_too_largeder Body überschreitet die Grenze der Route
422idempotency_key_reusedder Idempotency-Key wurde innerhalb einer Stunde mit einem anderen Body verwendet
429rate_limitedzu viele Aufrufe aus einem Netz an die Routen des Tags, oder, bei events und feedback, die Idempotency-Key-Einträge der Stunde Ihres API-Schlüssels an ihrer Grenze; mit Retry-After
503over_capacity, deadline, unavailablezu viele Aufrufe gleichzeitig, keine Entscheidung innerhalb Ihrer Deadline, oder ein Dienst, von dem Bot Protection abhängt, ist ausgefallen, mit Retry-After
500internalBot Protection ist ausgefallen

Jede Antwort trägt Korzen-Request-Id, dasselbe wie die request_id des Problems: Nennen Sie sie, wenn Sie uns zu einem Aufruf fragen. Decide, Events und Feedback tragen die IETF-Header RateLimit-Policy und RateLimit für die Entscheidungen Ihres Kontos: "month";q=50000 und "month";r=41234;t=86400 sagen, dass 41.234 der 50.000 des Monats übrig sind und der Monat in einem Tag endet (in der kostenlosen Testphase "trial"). Protokollieren Sie sie, wenn Sie möchten, und warten Sie nie auf sie. Die SDKs geben beides nach jedem Aufruf an onAnswer (in Go OnAnswer).

Was Ihr Server weiß

Fakten, die Ihr Server selbst geprüft hat, machen die Antwort zuverlässiger. Senden Sie sie in claims, jede optional:

"claims": {"account_age_days": 420, "known_instrument": true, "device_attested": false, "contact_verified": true}
ClaimWann
account_age_dayswie alt das Konto ist, in Tagen, bei einer Anmeldung oder einem Checkout; ab 30 zählt es
known_instrumentdieselbe Karte oder derselbe Passkey wie bei früheren Bestellungen des Kontos
device_attestedeine Plattform-Attestierung wurde bestanden: ein Passkey, App Attest, Play Integrity, ein Private Access Token
contact_verifieddie E-Mail-Adresse oder Telefonnummer ist bestätigt

Zusammen mit Feedback, dass der Besucher ein guter Kunde ist, heben known_instrument oder device_attested eine Antwort auf human_high. Senden Sie nur, was Sie geprüft haben: Ein Claim ist ein Beleg für einen Menschen.

Latenz und Ihr Timeout

Bot Protection läuft in Falkenstein, Deutschland. Eine Entscheidung dauert den Round Trip des Netzwerks von Ihrem Server plus wenige Millisekunden, auf einer Verbindung, die Ihr Server offen hält; eine neue Verbindung fügt den TLS-Handshake hinzu, ein oder zwei weitere Round Trips. Verwenden Sie einen einzigen HTTP-Client für den ganzen Prozess, wie die Beispiele unten, und wählen Sie Ihren Timeout danach, wo Ihr Server läuft. Senden Sie ihn als Korzen-Deadline-Ms, wie lange Ihr Aufruf insgesamt wartet: Bot Protection nimmt die Hälfte davon für die Entscheidung, zwischen 10 und 1.000 ms (200 ms ohne den Header), und lässt die andere Hälfte dem Netzwerk. Eine Entscheidung, die nicht in ihrer Hälfte fertig wird, wird sofort mit 503 deadline beantwortet und nicht gezählt. Die SDKs senden ihren Timeout für Sie.

Ihr Server inRound TripTimeout
Europa10–40 ms100–200 ms
USA Ost~85 ms200 ms
USA West~145 ms300 ms
Südamerika~190 ms500 ms

Ein Timeout, der für die Entfernung zu kurz ist, lässt jeden Besucher durch, und diese Aufrufe zählen trotzdem als Entscheidungen.

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