Korzen
Bot Protection
In these docs

Webhooks

Each bot decision, or a summary every few minutes, pushed to your endpoint and signed with HMAC-SHA256: the payload, the signature, retries and limits.

A webhook sends Bot Protection's bot and bot_high decisions to an endpoint of yours: a chat channel's relay, a ticket queue, your own fraud review. Your server still gets each decision from /v1/decide as before; the webhook is a copy, sent after the answer, and never makes a decision wait.

Set your endpoint

On the dashboard's Set-up page, under Webhook, an owner writes the endpoint's address and chooses what it gets:

  • Each decision of the classes chosen, bot, bot_high or both, in batches of up to 100, within seconds of the decision.
  • A summary every 5, 15 or 60 minutes: how many bot and bot_high decisions there were.

The address is https://, on port 443, with a name such as hooks.example.com, never an IP address. Korzen sends nothing to an address of a private network, whatever the name resolves to, and checks it at each delivery.

The first time an endpoint is saved, Set-up shows its signing secret, kz_whs_ and 64 characters, once: copy it to your endpoint's configuration. Make a new secret replaces it, shown once too; for 24 hours the old one still signs each delivery beside it, so your endpoint keeps verifying while you update its copy. Send a test sends a signed test delivery now and shows what your endpoint answered.

The payload

Each delivery is a POST with a JSON body. A batch of decisions:

{
  "type": "decisions",
  "id": "0193a5f0-7c1e-7d2a-9b4e-3f1a2c9d8e01",
  "decisions": [
    {
      "at": "2026-10-28T12:00:00.123Z",
      "request_id": "…",
      "class": "bot_high",
      "action": "block",
      "intended_action": "block",
      "bot_score": 97,
      "mode": "block",
      "reasons": ["behaviour", "inconsistency"]
    }
  ],
  "dropped": 0
}

Each decision is at, the time it was made, and these members of the answer /v1/decide gave your server: request_id, class, bot_score, human_score, reasons, action, intended_action, mode, step_up, token_required, identity_limited, seen_before, crawler, decoy and own_server. Nothing else: never a rule's name, the context you sent or the challenge page's link. dropped counts the decisions this batch had no room for.

A summary:

{"type": "summary", "id": "…", "from": "2026-10-28T12:00:00Z", "to": "2026-10-28T12:15:00Z", "bot": 412, "bot_high": 37}

A test: {"type": "test", "id": "…", "at": "…"}. Read type first: a new type may come, and a field may be added to any of them.

id is the same on every attempt of a delivery: if you already handled it, answer 2xx and drop it. Deliveries may arrive out of order.

What a delivery promises

  • At least once, once queued. A delivery queued is tried until your endpoint answers 2xx or it is paused, so the same delivery may come twice: drop a repeat by its id.
  • Best effort before that. Each Korzen server holds a decision up to 10 seconds before it queues it, and queues all it holds when it stops for a deploy. A server that crashes loses what it held, and the decisions past a batch's room or your plan's hourly limit are counted in dropped, never sent.
  • The webhook is a copy, not the record. Every decision is kept on the dashboard's Decisions page, which the MCP server's list_decisions and get_decision also read: look a decision up there by its request_id.

Check the signature

Each delivery carries Korzen-Signature: t=<Unix seconds>,v1=<hex>: the HMAC-SHA256, under your signing secret, of t, a dot, and the body exactly as received. Compute it over the raw bytes, before any JSON parsing, compare in constant time, and refuse a t more than 5 minutes from your clock, so a delivery recorded once cannot be sent again later. For 24 hours after a new secret, a second v1 follows, under the old one: accept a delivery when any v1 matches.

import { createHmac, timingSafeEqual } from "node:crypto";

// raw: the body as received (a Buffer); header: Korzen-Signature
function verify(raw, header, secret) {
  const parts = header.split(",").map((p) => p.split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const mac = createHmac("sha256", secret).update(`${t}.`).update(raw).digest();
  return parts.some(([k, v]) => {
    const got = Buffer.from(k === "v1" ? v ?? "" : "", "hex");
    return got.length === mac.length && timingSafeEqual(got, mac);
  });
}
import hashlib, hmac, time

def verify(raw: bytes, header: str, secret: str) -> bool:
    parts = [p.partition("=")[::2] for p in header.split(",")]
    t = next((v for k, v in parts if k == "t"), "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(mac, v) for k, v in parts)

Answer, and retries

Answer any 2xx within 5 seconds: the body is not read. Anything else, a redirect too, or no answer in time, is a failure, and the delivery is tried again 30 seconds later, then after twice as long each time, 8 tries in about an hour. Do the slow work after you answer.

While your endpoint fails, new decisions wait, up to 100, and the rest are counted in dropped. An endpoint that fails for an hour is paused: nothing is sent to it, the decisions meanwhile are not sent, and every owner is emailed. Set-up shows the last delivery's status or error; Resume sends again from the next decision.

Limits

Each plan sends up to a number of decisions an hour: the free trial 1,000, Starter 10,000, Pro 50,000, Scale 200,000. Past it, the decisions are counted in the next batch's dropped, and the next hour starts afresh. A summary is always sent. A webhook does not count toward your monthly decisions.