← All docs

Server SDKs

Seven languages, one surface. Each is driven against a live server in CI rather than only compiled, and each opens a sealed payload written by a different language — the envelope is a binary format shared across runtimes, so sealing and unsealing in one would pass while being wrong.

Language Package Dependencies
Node @antibot/node-sdk (and @antibot/node-sdk/middleware) none
Python antibot none (antibot[sealed] for sealed results)
Go github.com/antibot/antibot-go none
Java dev.antibot none
PHP antibot/antibot-php none beyond bundled extensions
.NET Antibot none
Ruby antibot (gem; Antibot::Middleware for Rack) none

Zero runtime dependencies is deliberate: this sits in the request path of a checkout, where a dependency tree is a liability.

Every client has

getEvent(id) One identification, with every signal
searchEvents(filters) Search
iterateEvents(filters) Auto-paginating walk
updateEvent(id, patch) Attach linked id, tags, suspect label
deleteEvent(id)
getVisitor(id) Visit history
deleteVisitor(id) Right-to-erasure
relatedVisitors(id) Devices sharing an IP or linked id
assess(request) A verdict on a request that never ran the agent — see assessing a request
reportOutcome(ip, account, outcome, sessionToken?) How a sign-in turned out, and the receipt its attempt carried ("" for none): the device it is counted to — see protecting sign-in
reportPaymentOutcome(ip, card, outcome, sessionToken?) How a payment turned out, and the receipt its request carried — see protecting checkout
reportPhoneOutcome(ip, phone, outcome) Whether a texted code was entered — see protecting text messages
reportContentOutcome(ip, content, outcome) A moderator's verdict on a posted message — see protecting forms
reportCodeOutcome(ip, code, outcome, sessionToken?) Whether a gift card, voucher or coupon was accepted, and the receipt its attempt carried — see protecting gift cards
reportHoldOutcome(ip, item, outcome, quantity?) Whether held stock was bought or lapsed unpaid — see protecting stock from being held
reportTrap(ip, headers) A request for a trap link your own server answered (the edge reports its own)

Beside the client

These call no API — a key or a secret checks each where it is — so they are functions of the package, not methods of a client: imported beside it in Node (import { verifySessionToken } from "@antibot/node-sdk") and Python (from antibot import verify_session_token), the package's own in Go (antibot.VerifySessionToken) and Ruby (Antibot.verify_session_token), and static methods in Java, PHP and .NET, on SessionTokens, Webhooks and Sealed (.NET's receipt check is SessionTokens.Verify).

unsealEventResponse(sealed, keys) Open a sealed result
verifyWebhookSignature(...) Webhooks
verifySessionToken(token, keys, { userAgent? }) Check a receipt offline: signature, expiry, the client it was minted for (the third argument is options; the user agent is named in it)
verifyAdmission(token, keys, { room?, clientIp? }) Check a waiting-room admission offline: signed with the admission key the session keys derive, unexpired, naming the room, and — given the client's address — from the network that queued where the room binds it (ipMismatch) — then bind it to the receipt beside it (waiting rooms). A receipt is never an admission, nor an admission a receipt
verifyRequestProof(claims, proof, method, path) A key-bound mobile receipt's per-request proof (mobile)

A request id the browser sends is one identification's: read with getEvent, hold it to moments ago, and to once — its timestamp and a claim on its id — or one kept from a real browser and sent again re-reads as that browser on every request.

The arguments are named here, not placed. Node takes them as one object (reportOutcome({ ip, account, outcome, sessionToken }), verifyRequestProof(claims, { proofHeader, method, pathAndQuery })); Python and Ruby as keywords (session_token=, session_token:); Go as options after the context (ReportOutcome(ctx, ip, account, outcome, antibot.WithSessionToken(token))). For a sign-in's and a code's outcome, Java, PHP, .NET and Python take an inferred flag before the receipt — reportOutcome(ip, account, outcome, false, sessionToken) in Java, a named sessionToken: in PHP, inferred: false, sessionToken: token in .NET; a payment's takes the receipt fourth.

Names follow each language's conventions (verify_session_token in Python, VerifySessionToken in Go, SessionTokens.verifySessionToken in Java and PHP, SessionTokens.Verify in .NET, Antibot.verify_session_token in Ruby; and for admissions verify_admission in Python and Ruby, VerifyAdmission in Go, SessionTokens.verifyAdmission in Java and PHP, SessionTokens.VerifyAdmission in .NET — the client's address as client_ip: in Python and Ruby, a fourth argument in Java, PHP and .NET, and VerifyAdmissionFrom in Go).

Repeating a filter value ORs it; different filters AND together. Errors are typed and carry the API's own code — branch on that, not on the message.

Node

npm install @antibot/node-sdk
import { AntibotClient } from "@antibot/node-sdk"

const antibot = new AntibotClient({
  apiKey: process.env.ANTIBOT_SECRET_KEY,
  endpoint: "https://api.antibot.dev",
})

const event = await antibot.getEvent(requestId)
if (event.products?.bot.result === "bad") deny()

for await (const e of antibot.iterateEvents({ linkedId: "user_42" })) {
  console.log(e.requestId)
}

Middleware: the edge enforcer, in your Node server

With no edge in front of the site, antibotMiddleware makes the edge enforcer's decisions in Express (or plain node:http): mount it after your body parsers and it assesses what it protects and acts on the answer.

A browser's CORS preflight — an OPTIONS with no body and no credentials (no Cookie, no Authorization), naming its Origin and the Access-Control-Request-Method it asks leave for — goes on to your application unjudged, in this guard and every language's below, as at the edge: it can carry no receipt, and the request it asks for is assessed when it comes. No assessment is made, or billed, for a preflight (sdk/preflight-cases.json is what each reads as one).

import express from "express"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotMiddleware } from "@antibot/node-sdk/middleware"

const app = express()
app.use(express.urlencoded({ extended: false }), express.json())
app.use(antibotMiddleware({
  // A key allowed to assess and nothing else.
  client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
  sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
  // The header your load balancer or CDN sets — never one a client can write.
  clientIp: (req) => req.headers["cf-connecting-ip"],
  protect: ["/shop", "/api"],
  logins: [{ path: "/login", field: "email" }],
  codes: [{ path: "/gift-card/redeem", field: "card" }],
  contents: [{ path: "/reviews", field: "review.body" }],
  challenge: { publicKey: process.env.ANTIBOT_PUBLIC_KEY, agentUrl: "/_ab/agent", endpoint: "https://shop.example" },
}))

app.post("/login", async (req, res) => {
  if (req.antibot?.verdict?.action === "challenge") return askForSecondFactor(req, res)
  const ok = await checkPassword(req.body.email, req.body.password)
  await req.antibot?.outcome(ok ? "success" : "failure")
  // …
})

The clientIp above is right only when the origin accepts connections from your CDN alone: a client that reaches the origin directly can write cf-connecting-ip itself. Otherwise read the last hop your own proxy appended — handed a list (req.headers["x-forwarded-for"] whole), the Express and Fastify middleware take its last entry.

A multipart sign-in must be parsed before the guard runs, or the guard cannot read it and reports it unread. Mount multer ahead of the middleware for that path: app.use("/login", multer().none()), then app.use(antibotMiddleware(…)).

A middleware inside an app or router mounted under a prefix (app.use("/shop", shop)) reads the shop's own paths as well as the whole one: a configured /login is the request for /shop/login, and so is /shop/login written whole. The assessed URL keeps the prefix.

If your pages load the agent, say so — agentOnPages: true (agent_on_pages=True in Python): a client that keeps asking for pages without the receipt the agent writes is then challenged at its fifth, which is what stops an HTTP client imitating Chrome on the wire. Where receipts are verified in the middleware (every one of them, with session keys), it also counts which of a receipt's requests were page navigations: a receipt let through to eight with its agent never heard from — a browser's cookie carried into an HTTP library — is revoked by the API, and the answer to the middleware's report says so, so the next request is no pass.

In TypeScript, importing the middleware types req.antibot on Node's request, and so on Express's — optional, because only the requests it protects carry it. The package is an ES module: from CommonJS, require("@antibot/node-sdk") works on Node 20.19+ and 22.12+, and its types resolve under every moduleResolution (node, node16/nodenext, bundler).

Request by request, as the edge does:

  • A valid receipt (_abid, verified here against sessionKeys — the keys you pass, not the ones the service still holds: a key deleted in the dashboard after a leak verifies here until you take it out of them, as at the edge) passes without a call; a condemned one (blocked, scored at maxScore — 70 — or above, or minted for a browser the agent convicted of automation: a signal in blockSignals, ["bot"] by default — block_signals in Python) is a 403. A configured form is assessed whatever it carries. A receipt bound to a phone's key (cnf, from the mobile SDKs) passes only with a fresh proof by that key in the request's antibot-pop header, checked here; without one it is assessed like a request without a receipt, with the token and the proof sent for the API to judge, as the edge does. (Python checks the proof with cryptography; without it installed, every bound receipt goes to the API.)

  • boundPaths: API calls only your pages can make. Path prefixes only your own pages call, where a receipt counts only when it is bound to a key the caller holds — the browser agent's bindReceipt (API calls only your pages can make), or a mobile SDK's binding — and comes with a fresh signature by that key in antibot-pop, as the edge's ANTIBOT_BOUND_PATHS. There the token header alone is read, never the cookie a solver's browser can hand on; a call without such a receipt is a 403 whatever the assessment would say (it would read an HTTP client copying a browser as fine), and antibot-enforcement says why — absent, expired, binding:unbound, binding:pop:missing, binding:pop:invalid, binding:pop:stale, revoked — for the page's agent to renew its receipt and send the call again. In monitor mode the call goes on, with that as the would-act reason. A bound path is protected whatever protect says, and the option needs sessionKeys (the guard refuses to start without them). Python and Ruby name it bound_paths (Python also needs cryptography), Go and .NET BoundPaths, Java boundPaths(...); Laravel reads ANTIBOT_BOUND_PATHS, Symfony bound_paths, Spring Boot antibot.bound-paths.

  • flows: a checkout run faster than a person runs it. The steps of your own flows by name, each the request that takes it, in order — { checkout: ["POST /cart/checkout", "/checkout/delivery", "/checkout/payment@3000"] }, [METHOD ]path[@minMs], POST unless said (a GET only as a page navigation), the path exact — as the edge's ANTIBOT_FLOWS. A request that takes a step is assessed with it, behind a passing receipt too, and read against the step before (steps taken faster than a person). A challenge there, behind a receipt, is the press-and-hold (with challenge set): over the page for a form a browser posts, 403 with antibot-step-up: hold for an API call. Covered whether or not protect names the path. A step that is also a configured form (a sign-in, a card's, a code's) is read as the form: its challenge goes on to the route with the verdict, for the route to act on, as every form's does — as at the edge. Laravel reads ANTIBOT_FLOWS in the edge's own words, Symfony flows, Spring Boot antibot.flows.<name>.

  • graphql: one request that is many. Your GraphQL endpoints, by path (["/graphql"]) — one path for a sign-in, a checkout and a search alike, where one request can be a hundred sign-ins as aliases of a mutation, or a batch. A request there is assessed with its documents (graphql): a JSON body's (one, or a batch's), a form's fields, an upload's operations, an application/graphql body's, and the URL's query beside any of them — each with its string values emptied, comments and spacing out, so a password written inline never leaves your server, and each distinct text sent once with the times it came. A body there the guard cannot read may hold anything, and is sent as a document cut short, which the API counts as unread: mount a JSON body parser before the middleware (Express), and graphql-upload's too. A mutation, a batch, or a document cut short is assessed behind a passing receipt too; a page's queries behind one go through as any request does. A persisted query's hash is read as its text once the process has seen the two together, or when graphqlPersisted (your trusted documents, id to text) names it, and is otherwise counted. Covered whether or not protect names the path. graphqlOf(body) makes the field for a call of your own; the edge reads ANTIBOT_GRAPHQL. Python and Ruby name them graphql and graphql_persisted, Go and .NET GraphQL and GraphQLPersisted, Java graphql(...) and graphqlPersisted(...), PHP graphql and graphqlPersisted (after flows); Laravel reads ANTIBOT_GRAPHQL and ANTIBOT_GRAPHQL_PERSISTED, Symfony graphql and graphql_persisted, Spring Boot antibot.graphql and antibot.graphql-persisted.

  • sessionCookie: a signed-in session, bound to its device. The name of your own session cookie ("connect.sid"). Every assessment carries it as userSession — its SHA-256 (sessionDigest(value)), never the cookie — and the API binds it to the device and the browser that first used it: the same session from another device, or another browser or system, is a cookie lifted from its owner and replayed elsewhere (userSession:otherDevice, userSession:otherClient). On a covered path behind a passing receipt, each new pair of session and device is assessed once and a challenge there is the press-and-hold; a pair let through is not asked about again for ten minutes, unless the verdict said the session was elsewhere — then each of its requests carries that on, for you to ask for the password again. The edge reads ANTIBOT_SESSION_COOKIE; Python and Ruby name it session_cookie (Django's ANTIBOT settings too), Go and .NET SessionCookie, Java sessionCookie(...), PHP sessionCookie (after graphqlPersisted, or a request's own session id); Symfony session_cookie, Spring Boot antibot.session-cookie, and Laravel ANTIBOT_SESSIONS=true, which reads the session's id itself. Each SDK's digest is sessionDigest in its own spelling.

    Name a cookie whose value stays for the session's life: a session id — PHPSESSID, JSESSIONID, Express's connect.sid, Django's sessionid (database or cache sessions), ASP.NET Core's .AspNetCore.Session. A cookie written anew on every response — Laravel's (encrypted afresh each time: use ANTIBOT_SESSIONS), Django's signed-cookie sessions, a JWT refreshed on each request — is a new session every time: nothing is ever bound, and behind a receipt every request is assessed. One rewritten now and then (Rails' cookie store when the session changes, ASP.NET's authentication cookie when it slides) binds again from each new value.

  • Your blockSignals go with the assessment (when you set them in Node; always in Python), so a form the API judges with the receipt on it is held to the same signals — an empty list there is none there too.

  • Everything else is assessed — the address clientIp gives, the headers in the order they arrived (never Cookie or Authorization), and for a configured form its one field from the parsed body: a sign-in's account, a redemption's code, a message. Never the password beside them.

  • A form whose field cannot be read is assessed as one, never passed as if the field were absent: a body past the cap (32 KB, 96 KB for a message; declared or streamed), a type that is not urlencoded, JSON or multipart, a multipart body no parser has run on (the route parses it after the guard), JSON that does not parse, a JSON number that is not an exact integer (past 2^53 − 1, or fractional: the parser rounds it, so the code counted would not be the one redeemed), an object or a list where the value goes, a file — and the field sent twice, since PHP reads the last and most else the first, so a decoy would be counted while another account signed in. So is the field sent another way some framework reads as the same: a name PHP strips ( email), qs and Rack read through ([email]), ASP.NET matches ignoring case (EMAIL), one behind a ; that python-multipart and Rack 2 split on; a body the application inflates (Content-Encoding), or in another charset; a multipart body with two boundaries, a part with two names, an encoded part; a JSON body that is no object (a list, a string, a number, null), or an object or a list where the field's value should be. Every guard and the edge read the same 61 requests alike (sdk/field-cases.json). JSON keys are matched ignoring case, as Go's encoding/json and ASP.NET match them (and ſ as s, as Go folds it): one spelling is the field, two are a decoy. A nested field is one field however it is named: user[email] and user.email both name user=… email=… in a form or the query string, sent either way (Rails and qs read the first, Spring the second), and {"user": {"email": …}} in JSON — so the Devise sign-in's user[email] is read from a JSON body too; both spellings at once are a decoy, and so are user_email and an unclosed user[email (PHP's readings) and Spring's !email default beside an absent email. Content-Transfer-Encoding 7bit, 8bit and binary on a part are read (Apache HttpClient writes them on every part); any other is an encoded part. It is sent as unreadField (the reason form:unread:<kind>), never the value. A field that is simply absent — a two-step sign-in's second step — says nothing.

  • Paths are matched as the application reads them, as at the edge: /LOGIN, /%6Cogin, //login/, /x/../login, /login;jsessionid=… and an absolute-form target (POST http://shop.example/login) are all the /login form, and all under a protected /login. A path is read three ways — resolved, each segment decoded once, and as written — and a prefix covers it when it covers any: Express, ASP.NET and Go's ServeMux keep .. or %2F.. inside a segment, so /admin/report/42%2F..%2F..%2Fabout reaches an admin handler.

  • A form is any request that is not a read — a PUT's or a PATCH's body is read by r.FormValue and model binding as readily as a POST's — and any request that carries a body (a Content-Length above zero, or a Transfer-Encoding), whatever its method: an app.all() route reads an OPTIONS's body, and Laravel's input() a GET's JSON. Without a body, a GET or HEAD is a form only when its query names the field; an OPTIONS is no form (a CORS preflight).

  • A flat key is the field too. A nested field's name may arrive as one flat key that a parser left flat: {"user.email": …} in JSON, or user.email / user[email] in a urlencoded body Express parses with extended: false (and Express 5's default). It is read there when the nested form is absent; beside it, or beside a second flat spelling, it is a decoy and unread.

  • The assessed URL's host is the request's Host only when it is one: a name or address with a port of 1–65535. shop.example:99999 once made the risk call a 400, and every guard lets a 400 through.

  • No address is no pass. When clientIp finds no address, the connection's own is used; a request with none at all (a runtime that knows none) is still assessed, as an unknown address, and the first one is reported once to your error hook (onError), saying how to set clientIp.

  • The call never outgrows the API. The receipt is cut to 4,096 characters, its proof to 256, the URL to 2,048, a header's value to 2,048, at most 64 headers, and the headers to 32 KiB as the call encodes them — a client padding a cookie with bytes past 0x7F, which JSON writes as six-byte escapes, once pushed the call past the API's 256 KiB and through the guard's fail-open. Make the origin accept only your CDN's address ranges as well: a client that goes round the CDN without its header is otherwise assessed by its own address, which is the right answer, but nothing in front of it saw it.

  • WebSockets are assessed at the edge. The Express and ASGI middlewares see only HTTP requests: an upgrade goes to the server's upgrade event (or an ASGI websocket scope) without passing through them. Put a socket endpoint behind the edge, or check the receipt on the upgrade yourself.

  • The query string is read beside the body, because Spring's getParameter, Rails' params and ASP.NET's binding read it there: POST /login?email=victim with no email in the body is that sign-in. The field sent there twice, or there and in the body with another value, is assessed as unread.

  • A block is a 403 with a reference the visitor can quote. A challenge to a page a browser is navigating to is the interstitial that identifies it and reloads (with challenge set); to anything else a 403 ChallengeRequired; to a configured form, nothing — it goes on to your handler with the verdict on req.antibot.verdict, so a sign-in can ask for a second factor instead of showing an error where the login page was. The block page and the interstitial speak the visitor's language from Accept-Language, in the same words as the edge's pages — forty languages (every official EU language but Irish and Maltese), Arabic, Hebrew and Persian laid out right to left, English otherwise, with Content-Language set to match; every SDK carries the same table (sdk/page-text.json) and chooses from it the same way. An API call's JSON answer stays in English.

  • Outcomes. req.antibot.outcome("success" | "failure") reports a sign-in or a redemption in your own words — and only a success said this way is remembered as a device the account signs in from. Without it the outcome is read from your response by the same rules as at the edge, and sent as inferred: a sign-in's 401 and 422 a failure, a redirect back to the form a failure, a JSON 2xx a success, a 2xx page, 403 and 429 nothing; a redemption's 400, 404, 409, 410 and 422 a refusal, a JSON 2xx an acceptance, and anything else nothing — a redirect too, as after post-redirect-get a refusal and an acceptance both redirect, so a redemption form that redirects says its outcome itself. Nothing at all after a challenge. An attempt's outcome is counted once, in every SDK: the first said or read stands, so saying it twice, or after it was read from the answer, counts nothing more.

  • missing: "challenge" runs the interstitial for a page navigation without a passing receipt even when the assessment allowed it — the edge's challenge posture, and what stops a headless browser with a stealth layer, whose requests look exactly like a person's. Verified crawlers, vouched agents and API calls are left to the assessment.

  • stepUp: "hold" holds the challenge band to a press-and-hold — the edge's step-up, the same page in the same words. A page navigation carrying a receipt minted on a challenge, or one whose visitor's session the pulse hardened to a challenge since (the list comes with the revocations), is answered with the hold until a hold earns a receipt of its own (ch). API calls and form posts go on — with "hold-all" an API call is refused instead, 403 StepUpRequired with antibot-step-up: hold, for the page's agent.fetch to show the hold and send it again, and a form a browser posts is shown the hold. Under attack mode — the revocations say when — a receipt due a step-up is held as under "hold-all", whatever this is set to. It needs challenge set, and does nothing without it. In monitor mode the request goes on with stepUp:receipt or stepUp:session as the would-act reason. Python and Ruby name it step_up, Go StepUp, .NET StepUp = AntibotStepUp.Hold.

  • The API out of reach (no answer within the guard's timeoutMs, 2,000 by default, or the client's own if that is shorter): the request goes on, and onError is told. While the API is slow every assessed request waits this long — the edge waits 250 ms — so keep it to what a sign-in can wait. Python names it timeout, in seconds (Django's ANTIBOT["timeout"] is the guard's wait as well as its client's), Go Timeout (zero is DefaultAssessTimeout), .NET Timeout, Ruby timeout: (seconds), PHP timeout (whole seconds, at least 1; Laravel's and Symfony's timeout is the guard's wait as well as its client's), Java timeout(Duration) (Spring Boot's antibot.timeout is both).

  • What receipts let through is reported in batches, behind the answer, as the edge does it.

    • A batch goes ten seconds after its first pair (a receipt and an address), or at 500 pairs, and carries at most 1,000. What did not fit goes on the next request; up to 5,000 pairs are held.
    • A receipt used from five networks, or a device at a machine's pace, is revoked.
    • The environment's revocations are fetched every fifteen seconds, and a revoked receipt is assessed instead of passing.
    • In Python, both run on a background thread.
    • In Go, both run on goroutines, on context.WithoutCancel(r.Context()).
    • In .NET, both run on the thread pool, outside the request's context.
    • In Java, both run on the guard's daemon threads.
    • In Ruby, both run on threads of their own; middleware.settle waits for them.
    • The Fetch-API guard returns the work as decision.background, for event.waitUntil.

Fastify: the same enforcer, as a plugin

antibotFastify (from @antibot/node-sdk/fastify) is the middleware for Fastify. Register it, and it assesses and acts on every request it protects, as above. It has no dependency on fastify: it is marked the way fastify-plugin marks a plugin, so its hooks apply to the whole application, including routes registered in child plugins. Register it on the root instance, not inside a child context: registered in a child, its hooks cover only that child.

import Fastify from "fastify"
import formbody from "@fastify/formbody"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotFastify } from "@antibot/node-sdk/fastify"

const app = Fastify()
// Fastify parses JSON out of the box; a urlencoded sign-in needs this.
await app.register(formbody)
await app.register(antibotFastify, {
  client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
  sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
  clientIp: (req) => req.headers["cf-connecting-ip"],
  protect: ["/shop", "/api"],
  logins: [{ path: "/login", field: "email" }],
})

app.post("/login", async (request, reply) => {
  if (request.antibot?.verdict?.action === "challenge") return askForSecondFactor(request, reply)
  const ok = await checkPassword(request.body.email, request.body.password)
  await request.antibot?.outcome(ok ? "success" : "failure")
  // …
})

As with the middleware, clientIp above is right only when the origin accepts connections from your CDN alone: a client that reaches the origin directly can write cf-connecting-ip itself. Otherwise read the last hop your own proxy appended.

  • Where the verdict is: request.antibot, set in preHandler, after the body is parsed; the same object is on request.raw.antibot. A block is answered there and the route never runs; a challenge to a configured form goes on to the route with the verdict, as in the middleware.
  • clientIp receives Node's request (req.headers, req.socket), not Fastify's wrapper, so it reads the same headers as the Express version.
  • Outcomes are read in onResponse, from the reply the route sent, or said by the route with request.antibot.outcome(...), as above.
  • A body no parser took. Fastify parses JSON and text/plain by default, and answers a urlencoded body with 415 until @fastify/formbody is registered, before the guard runs. A text/plain sign-in does reach the guard, with its body unparsed, so it is assessed as unread, as the middleware does it.
  • A multipart sign-in must be parsed before the guard. @fastify/multipart leaves a multipart body's fields unparsed on request.body unless it is registered with attachFieldsToBody: "keyValues"; without that, the sign-in is assessed as unread, never as one with no account. Register it that way: await app.register(multipart, { attachFieldsToBody: "keyValues" }), before antibotFastify.
  • In TypeScript, request.antibot is not on Fastify's request type unless you declare it: add antibot?: AntibotState (the type is exported from @antibot/node-sdk/fastify) to Fastify's FastifyRequest interface.

NestJS runs on either adapter:

  • Express adapter. Nest adds its own body parsers when the application initialises, after anything mounted with app.use() before listen() — so a middleware mounted that way sees no body. Turn Nest's parsers off, and mount the parsers and the guard in that order:
import { NestFactory } from "@nestjs/core"
import express from "express"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotMiddleware } from "@antibot/node-sdk/middleware"
import { AppModule } from "./app.module"

const app = await NestFactory.create(AppModule, { bodyParser: false })
app.use(express.urlencoded({ extended: false }), express.json())
app.use(antibotMiddleware({
  client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
  sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
  logins: [{ path: "/login", field: "email" }],
}))
await app.listen(3000)
  • Fastify adapter. Nest's Fastify adapter parses JSON and urlencoded bodies itself, so @fastify/formbody is not needed here. Register the plugin on the adapter before listen():
import { NestFactory } from "@nestjs/core"
import { FastifyAdapter } from "@nestjs/platform-fastify"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotFastify } from "@antibot/node-sdk/fastify"
import { AppModule } from "./app.module"

const adapter = new FastifyAdapter()
const app = await NestFactory.create(AppModule, adapter)
await adapter.register(antibotFastify, {
  client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY, endpoint: "https://api.antibot.dev" }),
  sessionKeys: process.env.ANTIBOT_SESSION_KEYS.split(","),
  logins: [{ path: "/login", field: "email" }],
})
await app.listen(3000, "127.0.0.1")

The Nest orders above are held by sdk/node/test/nest.test.mjs, a minimal app on a real Nest (12.3) on either adapter: without bodyParser: false the sign-in's account is not read — it is assessed as unread, never as absent — and with it, and on the Fastify adapter, it is.

Next.js, SvelteKit, Remix, Hono: the same, on Request and Response

antibotGuard makes the same decisions from the same code for frameworks built on the Fetch API. It is tested in Node, and also inside Deno and Bun, each serving with its own server; those runtimes run Netlify Edge Functions, Deno Deploy and Bun's own servers. It takes a Request and answers with either:

  • a response to send in its place, or
  • the headers to carry the request on with. These are the visitor's own headers, minus any antibot-assess-* a client wrote (with underscores too), plus this verdict's (antibot-assess-action, -score, -reasons, -id), the same ones your application reads behind the edge.
// proxy.ts (Next.js 16)
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server"
import { AntibotClient } from "@antibot/node-sdk"
import { antibotGuard } from "@antibot/node-sdk/fetch"

const guard = antibotGuard({
  client: new AntibotClient({ apiKey: process.env.ANTIBOT_ASSESS_KEY!, endpoint: "https://api.antibot.dev" }),
  sessionKeys: process.env.ANTIBOT_SESSION_KEYS!.split(","),
  // On Vercel, the address its edge writes; elsewhere, the header your proxy sets.
  clientIp: (request) => request.headers.get("x-real-ip"),
  protect: ["/shop", "/api"],
  logins: [{ path: "/api/login", field: "email" }],
  challenge: { publicKey: process.env.ANTIBOT_PUBLIC_KEY!, agentUrl: "/_ab/agent", endpoint: "https://shop.example" },
})

export async function proxy(request: NextRequest, event: NextFetchEvent) {
  const decision = await guard(request)
  // Receipt usage and fresh revocations, sent and fetched behind the answer.
  if (decision.background) event.waitUntil(decision.background)
  if (decision.response) return decision.response
  return NextResponse.next({ request: { headers: decision.headers } })
}

The SDK verifies receipts with node:crypto, so it needs the Node.js runtime. A proxy.ts runs on it already, and must not set runtime — Next.js 16 throws if it does. On Next.js 15.5 to 15.x the file is middleware.ts, the function is middleware, and it opts in with export const config = { runtime: "nodejs" }.

Served under a basePath, name it as mount: "/app": a configured /login or protected path is then the request for /app/login too, as the Express middleware reads paths under its mount.

Then, in a route handler:

const action = request.headers.get("antibot-assess-action")

Where it differs from the Express middleware:

  • clientIp is required. A Fetch-API runtime has no socket address to fall back on.
  • The header order is not evidence. A Headers object iterates sorted, so the assessment is told so (headersOrdered: false).
  • The form's field is read from a copy of the body. Your handler still reads the body whole.
  • Outcomes are reported from the handler. The guard never sees your answer, so report a sign-in's outcome there with client.reportOutcome.

Monitor mode: see what enforcing would do, before it does

Set monitor: true in the options of any Node guard (the Express middleware, the Fastify plugin, or the Fetch-API guard) to decide every request exactly as it would be decided, and act on none of those decisions. A request that would have been refused or challenged goes on to your application, and the state the application receives says what would have happened: on Express and Fastify, req.antibot.wouldAct (request.antibot.wouldAct in Fastify); on the Fetch-API guard, decision.state.wouldAct. It is { action, reason }, where action is "block" or "challenge", and reason is what the guard would have answered with, such as signal:bot for a receipt convicted of automation, assess:block for a verdict of block, assess:challenge or missing:challenge for a challenge. The property is absent when nothing would have been done. Log it, or check it in your application, to see what enforcing would have refused before you turn monitor off. Monitor mode is for a site's first weeks: the risk call is told, so the API keeps those assessments marked as monitored.

Python

pip install antibot          # or: pip install "antibot[sealed]"
import os

from antibot import AntibotClient, AntibotApiError

antibot = AntibotClient(api_key=os.environ["ANTIBOT_SECRET_KEY"],
                        endpoint="https://api.antibot.dev")

event = antibot.get_event(request_id)
if event["products"]["bot"]["result"] == "bad":
    deny()

for e in antibot.iterate_events(linked_id="user_42"):
    print(e["requestId"])

Middleware: WSGI and ASGI

The same decisions as the Node middleware, by the same rules, for Flask, Django or anything WSGI, and FastAPI, Starlette or anything ASGI — standard library only.

from antibot import AntibotClient
from antibot.middleware import AntibotASGIMiddleware, AntibotWSGIMiddleware

options = dict(
    client=AntibotClient(api_key=os.environ["ANTIBOT_ASSESS_KEY"], endpoint="https://api.antibot.dev"),
    session_keys=os.environ["ANTIBOT_SESSION_KEYS"].split(","),
    # The header your load balancer or CDN sets — never one a client can write.
    client_ip=lambda headers, peer: headers.get("cf-connecting-ip"),
    protect=["/shop", "/api"],
    logins=[("/login", "email")],
    codes=[("/gift-card/redeem", "card")],
    contents=[("/reviews", "review.body")],
    challenge={"public_key": os.environ["ANTIBOT_PUBLIC_KEY"], "agent_url": "/_ab/agent", "endpoint": "https://shop.example"},
)
flask_app.wsgi_app = AntibotWSGIMiddleware(flask_app.wsgi_app, **options)   # Flask
asgi_app = AntibotASGIMiddleware(fastapi_app, **options)                     # FastAPI

# Django: settings.py — the same options, under ANTIBOT.
MIDDLEWARE = ["django.middleware.security.SecurityMiddleware", "antibot.django.AntibotMiddleware", ...]
ANTIBOT = {**options}                                                       # request.antibot in a view

@flask_app.post("/login")
def login():
    state = request.environ["antibot"]          # scope["antibot"] under ASGI
    if state.verdict and state.verdict["action"] == "challenge":
        return ask_for_second_factor()
    ok = check_password(request.form["email"], request.form["password"])
    state.outcome("success" if ok else "failure")
    ...

A configured form's body is read from a copy and put back, so the application still reads it whole (32 KB at most for a sign-in or a code, 96 KB for a message). Under ASGI no more than that is read before the application runs: a larger body, declared or streamed, is left for it and assessed as unread. Your application can also answer with an antibot-login or antibot-code response header, which is taken off before the visitor sees it. Under Django, a configured sign-in that Django's own authentication decides is reported in its words — authenticate() failing (user_login_failed) and login() (user_logged_in) — so LoginView's wrong password, answered with the form again, is counted as the failure it is. A request's outcome is counted once: the first said stands. Reports go out on a background thread, never in the response's way. Under ASGI the headers reach the assessment in the order they arrived; a WSGI environ does not keep that order, and the assessment is told so (headersOrdered: false). missing="challenge" is the edge's challenge posture here too. An application served under a prefix (SCRIPT_NAME under WSGI and Django, root_path under ASGI: /shop) is read at its own paths as well as the whole one: a configured /login is the request for /shop/login, and so is /shop/login written whole. The assessed URL keeps the prefix, and a phone's signed request is checked against it, also when a proxy took the prefix off before the server and the application is told it another way (--root-path, FORCE_SCRIPT_NAME, ProxyFix's X-Forwarded-Prefix).

Monitor mode (monitor=True in these options, or "monitor": True under ANTIBOT in Django) decides every request as above and acts on none of it: a refusal, a challenge or a block the middleware would have answered goes on to the application instead, and state.would_act (request.antibot.would_act under Django) says what would have been done, as {"action": "block" | "challenge", "reason": ...}, where the reason is what would have been used, such as signal:bot, assess:block, assess:challenge or missing:challenge. It is None when nothing would have been done. A sign-in the API would block still has its failure reported, and every assessment says monitor to the API, which keeps it marked as monitored. Use it in a site's first weeks, to see what enforcing would refuse before it does.

Go

go get github.com/antibot/antibot-go
client, err := antibot.NewClient(os.Getenv("ANTIBOT_SECRET_KEY"), "https://api.antibot.dev")

event, err := client.GetEvent(ctx, requestID)

yes := true
page, err := client.SearchEvents(ctx, antibot.Filters{
    Bot:     &yes,
    Country: []string{"GB", "IE"},
})

Pointer fields keep the tri-states honest: a nil Suspect means unreviewed, which is not the same as reviewed and clean.

Middleware: net/http

The same decisions as the Node middleware, by the same rules, for any net/http server, using only the standard library. Wrap your handler:

enforce := antibot.Middleware(antibot.MiddlewareOptions{
    // A key allowed to assess and nothing else — not the secret key above.
    Client:      assessClient, // antibot.NewClient(os.Getenv("ANTIBOT_ASSESS_KEY"), …)
    SessionKeys: strings.Split(os.Getenv("ANTIBOT_SESSION_KEYS"), ","),
    // The header your load balancer or CDN sets — never one a client can write.
    ClientIP: func(r *http.Request) string { return r.Header.Get("Cf-Connecting-Ip") },
    Protect:  []string{"/shop", "/api"},
    Logins:   []antibot.FormField{{Path: "/login", Field: "email"}},
})
http.ListenAndServe(":8080", enforce(mux))

In a handler, antibot.StateFrom(r.Context()) returns the verdict and the receipt (nil where the middleware did not assess). state.Outcome(ctx, "success") reports a sign-in in your own words, and is safe on a nil state. Without it the outcome is read from your response by the edge's rules (antibot.LoginOutcome, antibot.CodeOutcome), sent behind the response.

A form's field is read from a copy of the body, up to 32 KiB (96 KiB for a message), so your handler still reads it whole; a body that cannot be read for the field is assessed with unreadField, as in Node. The query string is read for the field beside the body, path parameters (;jsessionid=…) are cut before paths are matched, and a request in absolute form (POST http://shop.example/login HTTP/1.1) is matched on its path, as Go routes it. Inside http.StripPrefix("/shop", …) the guard reads the path your handlers route as well as the whole one: a configured /login is the request for /shop/login, and so is /shop/login written whole. The same goes for a path a handler before the guard rewrote. The assessed URL is the one sent.

The assessment, outcome reports, receipt usage and revocations all run on context.WithoutCancel(r.Context()), bounded by Timeout (the assessment) or the client's timeout (the rest). net/http cancels r.Context() as soon as an HTTP/1.1 client half-closes its connection, so a client could otherwise call its own assessment off — and a call that fails, fails open. state.Outcome(ctx, …) keeps ctx's values but not its cancellation.

Set your server's ReadHeaderTimeout and ReadTimeout: a client that sends a sign-in's body slowly holds the guard's read of it, as it would hold your handler's r.FormValue, and only the server's timeouts bound either.

An http.Header has lost the order the headers arrived in, so the assessment is told so. TokenHeader is a *string: nil is antibot-token, a pointer to "" turns it off. A MaxScore of 0 means the default, 70. See sdk/go/README.md for every option.

Monitor: true in MiddlewareOptions decides every request as above and acts on none of it: a refusal, a challenge or a block goes on to your handler instead, and state.WouldAct (a *antibot.WouldAct, nil when nothing would have been done) says what would have happened — Action is "block" or "challenge", and Reason is what would have been used, such as signal:bot, assess:block, assess:challenge or missing:challenge. A sign-in the API would block still has its failure reported, and every assessment says monitor: true to the API, which keeps it marked as monitored. Use it in a site's first weeks, to see what enforcing would refuse before it does.

Gin, Echo, Chi and Fiber

The middleware above is net/http, so Chi and Echo take it as it is, and Gin and Fiber take it through a small adapter. Build enforce as in the example above. Each recipe below was run against a stand-in API, for a refused library request, an allowed one and a sign-in, with chi v5.2.5, echo v4.13.3, gin v1.10.1 and fiber v2.52.16 on Go 1.22. Those are the newest releases of each that build on Go 1.22 (chi v5.3, echo v4.14, gin v1.11 need Go 1.23 or later); none of them is a dependency of sdk/go.

In every framework the verdict is read the same way, from the request's context, and is nil where nothing was assessed. authenticate stands for your own check of the email and password.

Chi takes the middleware as it is:

r := chi.NewRouter()
r.Use(enforce)
r.Get("/shop", func(w http.ResponseWriter, req *http.Request) {
	if st := antibot.StateFrom(req.Context()); st != nil && st.Verdict != nil {
		fmt.Fprintf(w, "shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID)
	}
})
r.Post("/login", func(w http.ResponseWriter, req *http.Request) {
	target := "/login?error=1"
	if authenticate(req.FormValue("email"), req.FormValue("password")) {
		target = "/shop"
	}
	http.Redirect(w, req, target, http.StatusFound)
})

Echo takes it through echo.WrapMiddleware. A refusal writes its 403 and does not call the next handler, and the handler reads the verdict from c.Request().Context():

e := echo.New()
e.Use(echo.WrapMiddleware(enforce))
e.GET("/shop", func(c echo.Context) error {
	if st := antibot.StateFrom(c.Request().Context()); st != nil && st.Verdict != nil {
		return c.String(http.StatusOK, fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
	}
	return c.NoContent(http.StatusOK)
})
e.POST("/login", func(c echo.Context) error {
	target := "/login?error=1"
	if authenticate(c.FormValue("email"), c.FormValue("password")) {
		target = "/shop"
	}
	return c.Redirect(http.StatusFound, target)
})

Gin needs an adapter, because Gin's handlers write to c.Writer, and the middleware reads a sign-in's answer from what the handler wrote. The adapter hands the middleware's writer to the handler, aborts the chain when the middleware answers by itself, and hands the request on carrying the state:

package main

import (
	"io"
	"net/http"

	"github.com/gin-gonic/gin"
)

// AntibotGin runs a net/http middleware around the rest of a Gin chain.
func AntibotGin(mw func(http.Handler) http.Handler) gin.HandlerFunc {
	return func(c *gin.Context) {
		passed := false
		mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			passed = true
			own := c.Writer
			c.Request = r
			c.Writer = &ginWriter{ResponseWriter: own, to: w}
			c.Next()
			c.Writer = own
		})).ServeHTTP(c.Writer, c.Request)
		if !passed {
			c.Abort()
		}
	}
}

// ginWriter sends the handler's writes to the writer the middleware handed on
// (which records the status and Location of a sign-in's answer), and keeps
// Gin's writer for everything else: Status, Size, Hijack, WriteHeaderNow.
type ginWriter struct {
	gin.ResponseWriter
	to http.ResponseWriter
}

// WriteHeader forwards only a status: Gin calls WriteHeader(-1) to mean "keep
// the status" (c.Redirect does), and Gin itself ignores any code of 0 or less.
func (g *ginWriter) WriteHeader(code int) {
	if code > 0 {
		g.to.WriteHeader(code)
	}
}
func (g *ginWriter) Write(b []byte) (int, error) { return g.to.Write(b) }
func (g *ginWriter) WriteString(s string) (int, error) {
	return io.WriteString(g.to, s)
}
func (g *ginWriter) Flush() {
	if f, ok := g.to.(http.Flusher); ok {
		f.Flush()
	}
}

Then, with AntibotGin in the same package:

r := gin.New()
r.Use(AntibotGin(enforce))
r.GET("/shop", func(c *gin.Context) {
	if st := antibot.StateFrom(c.Request.Context()); st != nil && st.Verdict != nil {
		c.String(http.StatusOK, fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
	}
})
r.POST("/login", func(c *gin.Context) {
	target := "/login?error=1"
	if authenticate(c.PostForm("email"), c.PostForm("password")) {
		target = "/shop"
	}
	c.Redirect(http.StatusFound, target)
})

The c.Abort() is what stops the chain: without it Gin ran the handler after the 403 and its output followed the refusal. The redirect's outcome is read because the handler's writes go through the middleware's writer.

Fiber runs on fasthttp, so the middleware goes in through adaptor.HTTPMiddleware. The adaptor runs the middleware before the handlers after it, so the middleware cannot read a sign-in's response; without the observeLogin handler below no outcome is reported. observeLogin reads the status and Location the route wrote and reports them with State.Outcome (antibot.LoginOutcome is the same rule the middleware uses). The refusal, the verdict and the body of a sign-in all work through the adaptor unchanged.

app := fiber.New()
app.Use(adaptor.HTTPMiddleware(enforce))
app.Use(observeLogin())
app.Get("/shop", func(c *fiber.Ctx) error {
	if st := antibot.StateFrom(c.Context()); st != nil && st.Verdict != nil {
		return c.SendString(fmt.Sprintf("shop ok %s %s", st.Verdict.Action, st.Verdict.RequestID))
	}
	return c.SendStatus(fiber.StatusOK)
})
app.Post("/login", func(c *fiber.Ctx) error {
	target := "/login?error=1"
	if authenticate(c.FormValue("email"), c.FormValue("password")) {
		target = "/shop"
	}
	return c.Redirect(target, fiber.StatusFound)
})
// observeLogin reports a sign-in's outcome from the response a route wrote.
func observeLogin() fiber.Handler {
	return func(c *fiber.Ctx) error {
		err := c.Next()
		st := antibot.StateFrom(c.Context())
		if st == nil {
			return err
		}
		res := c.Response()
		outcome := antibot.LoginOutcome(
			res.StatusCode(),
			string(res.Header.Peek(fiber.HeaderLocation)),
			string(res.Header.ContentType()),
			c.Path(),
		)
		if outcome != "" {
			_ = st.Outcome(c.Context(), outcome)
		}
		return err
	}
}

The client address. ClientIP receives the *http.Request, not the framework's context, so it cannot call c.ClientIP(), c.RealIP() or c.IP(). Behind a proxy, return the header your proxy sets, as in the net/http example. The two can disagree: in Gin, c.ClientIP() takes X-Forwarded-For only from a proxy listed with SetTrustedProxies, and with no ClientIP option the SDK assesses the connection's own address. In a test with 127.0.0.1 as a trusted proxy and X-Forwarded-For: 203.0.113.9, c.ClientIP() gave 203.0.113.9 and the assessment carried 127.0.0.1.

Java

Java 17+. No build tool required — the sources compile with javac directly.

var antibot = new AntibotClient(System.getenv("ANTIBOT_SECRET_KEY"),
                                "https://api.antibot.dev");

Map<String, Object> event = antibot.getEvent(requestId);

The client pins HTTP/1.1. The JDK defaults to HTTP/2, which over cleartext sends an h2c upgrade preface, and an origin speaking only HTTP/1.1 closes the connection rather than declining — surfacing as header parser received no bytes.

Enforcing in your server: the guard and a Servlet filter

AntibotGuard makes the Node middleware's decisions, by the same rules, in plain JDK code. AntibotFilter runs the guard as a Jakarta Servlet 6 filter (Tomcat 10.1+, Jetty 12, Spring Boot 3). The filter lives in src-servlet/, so the client still compiles with javac alone. In Spring Boot, the starter registers the client, guard and filter from antibot.* properties, in the right order against Spring Security.

AntibotGuard guard = new AntibotGuard(new AntibotGuard.Options(
        new AntibotClient(System.getenv("ANTIBOT_ASSESS_KEY"), "https://api.antibot.dev", Duration.ofMillis(800)))
        .sessionKeys(List.of(System.getenv("ANTIBOT_SESSION_KEYS").split(",")))
        // The header your load balancer or CDN sets — never one a client can write.
        .clientIp(request -> request.header("cf-connecting-ip"))
        .protect("/shop", "/api")
        .logins(new AntibotGuard.FormField("/login", "email")));

servletContext.addFilter("antibot", new AntibotFilter(guard)).addMappingForUrlPatterns(null, false, "/*");

In Spring Boot, register the filter with an order, ahead of Spring Security:

@Bean
FilterRegistrationBean<AntibotFilter> antibotFilter(AntibotGuard guard) {
    FilterRegistrationBean<AntibotFilter> registration = new FilterRegistrationBean<>(new AntibotFilter(guard));
    // After the encoding and forwarded-header filters, before Spring Security (-100).
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
    return registration;
}

Don't declare the filter itself as a @Bean, or use a registration without an order: Boot then orders it last, after Spring Security has answered the sign-in, and no form login is ever assessed.

A handler finds the verdict and the receipt with AntibotFilter.stateOf(request), and reports a sign-in with outcome("success"). Without that call, the outcome is read from the response.

A configured form's body of any type is read up to the cap and replayed to your application whole: through getInputStream, getReader, getParameter, and for multipart getParts. A body that cannot be read for the field — past the cap, a type not parsed, JSON that is not JSON, an object where the value goes, a file part, the field sent twice, or a client gone mid-body — is assessed with unreadField. The field is read as getParameter reads it, query string included. Forms and prefixes match the path as the application may read it, ;jsessionid=… and all. A servlet container does not promise the order headers arrived in, so the assessment is told so. See sdk/java/README.md for every option.

Monitor mode, for a site's first weeks: monitor(true) on the guard's options decides every request as usual and acts on none of it. Where it would refuse or challenge, the request goes on to the handler, and State.wouldAct() says what would have been done and why (block or challenge, with signal:bot, assess:block, missing:challenge, ...). Assessments carry monitor: true, so the API keeps them as monitored, and outcomes are still reported.

PHP

composer require antibot/antibot-php
$antibot = new \Antibot\AntibotClient(getenv('ANTIBOT_SECRET_KEY'),
                                      'https://api.antibot.dev');

$event = $antibot->getEvent($requestId);

foreach ($antibot->iterateEvents(['linked_id' => 'user_42']) as $e) {
    echo $e['requestId'];
}

The client sends with curl. Where your platform has its own way, pass it as the fourth argument — given the method, the URL, the header lines, the body (null for none) and the timeout in seconds, it answers [status, body] and throws when no answer came; the WordPress plugin sends through wp_remote_request() this way. \Antibot\Guard::forgetShared($endpoint, $key) removes what guards for that endpoint and key keep between requests (a list file in the system's temporary directory, and APCu's entries), for when an application takes the integration out.

Enforcing every request: plain PHP and PSR-15

Antibot\Guard makes the Node middleware's decisions, by the same rules. It needs no framework.

$guard = new \Antibot\Guard(
    client: new \Antibot\AntibotClient(getenv('ANTIBOT_ASSESS_KEY'), 'https://api.antibot.dev', 2),
    sessionKeys: explode(',', getenv('ANTIBOT_SESSION_KEYS')),
    // The header your load balancer or CDN sets — never one a client can write.
    clientIp: fn (array $headers) => $headers['cf-connecting-ip'] ?? null,
    protect: ['/shop', '/api'],
    logins: [['path' => '/login', 'field' => 'email']],
);

// At the top of the front controller: a refusal is sent and the script exits.
$antibot = $guard->enforceGlobals();

How you use the result:

  • Classic PHP. enforceGlobals() returns the state: $antibot->verdict, $antibot->receipt, and $antibot->outcome('success') to report a sign-in. Without that call, the outcome is read from your response once the script ends.
  • PSR-15 (Slim, Mezzio, Laminas). Add new \Antibot\AntibotMiddleware($guard, $responseFactory, $streamFactory); the state is the request's antibot attribute. The PSR packages are suggested, not required, and the guard never loads them.
  • Laravel. The antibot/laravel package is this guard as middleware, with $request->antibot() for the route.
  • Symfony. The antibot/symfony bundle is this guard on the kernel's request event, with the state in the request's antibot attribute.
  • Anything else. $guard->decide([...]) takes the request as an array and returns a GuardResponse to send or the GuardState to go on with.

Forms can also be a callable, forms: fn (string $method, string $path, array $request): ?array returning ['kind' => …, 'field' => ?string], for a site that recognises its forms by script, query or posted fields rather than by path (the WordPress plugin does); a null field is a form with nothing to read — always assessed, its challenge going on to the application. The state carries ->ip and ->field, as assessed, for an outcome reported later. Field names are read as PHP reads them (user[email] is user.email). In classic PHP a multipart body is read from $_POST/$_FILES, since php://input never holds it, so a field PHP has already collapsed is read as PHP kept it. Receipt usage and revocations are pooled between requests with APCu; without it, classic PHP sends each request's usage as it ends, and keeps the revocation list in a file in the system's temporary directory, signed with a key derived from the secret key (a file it did not sign is read as none), fetched again by the one request that finds it fifteen seconds old, after its response. Long-lived workers call $guard->upkeep() after each response (the PSR-15 middleware does).

PHP does not reliably keep the order headers arrived in, so the assessment is told so. See sdk/php/README.md for every option.

Monitor mode (monitor: true, a named argument of Guard) decides every request as it would be decided, and acts on none of those decisions: a refusal or a challenge goes on to your application, and the GuardState it receives has wouldAct, ['action' => 'block' | 'challenge', 'reason' => …] (the reason the guard would have answered with, such as signal:bot or assess:block), or null when nothing would have been done. Sign-in outcomes are still reported, and each assessment is sent with monitor: true, so the API keeps it as what would have been done, not as done. Use it for a site's first weeks, then turn it off.

.NET

dotnet add package Antibot
using var antibot = new AntibotClient(
    Environment.GetEnvironmentVariable("ANTIBOT_SECRET_KEY"),
    "https://api.antibot.dev");

var evt = await antibot.GetEventAsync(requestId);

await foreach (var e in antibot.IterateEventsAsync(
    new Dictionary<string, object?> { ["linked_id"] = "user_42" }))
{
    Console.WriteLine(e.GetProperty("requestId").GetString());
}

Middleware: ASP.NET Core

Antibot.AspNetCore makes the Node middleware's decisions, by the same rules, in the ASP.NET Core pipeline. It is a package of its own, so the client keeps no ASP.NET Core dependency.

builder.Services.AddSingleton(new AntibotClient(Environment.GetEnvironmentVariable("ANTIBOT_ASSESS_KEY")!, "https://api.antibot.dev"));
builder.Services.AddAntibot(options =>
{
    options.SessionKeys = Environment.GetEnvironmentVariable("ANTIBOT_SESSION_KEYS")!.Split(',');
    // The header your load balancer or CDN sets — never one a client can write.
    options.ClientIp = context => context.Request.Headers["CF-Connecting-IP"];
    options.Protect = new[] { "/shop", "/api" };
    options.Logins = new[] { new AntibotFormField("/login", "email") };
});
var app = builder.Build();
app.UseAntibot();

In a handler, context.Antibot() gives the verdict and the receipt — null where the middleware did not protect the request — and if (context.Antibot() is { } antibot) await antibot.OutcomeAsync("success"); reports a sign-in in your own words. Without it, the outcome is read from the response by the edge's rules once the response has gone. With ASP.NET Core Identity, let Identity say it: builder.Services.AddDefaultIdentity<IdentityUser>().AddAntibotSignIns<IdentityUser>() makes its sign-in manager report each password sign-in on a configured form — a wrong password (which its login page answers with the page again, a 200 that says nothing) and a lockout as failures, a success once the visitor is signed in, and nothing while a second factor is still to come, so a device that knew the password and not the second factor is never remembered as the account's. A sign-in manager of your own derives from AntibotSignInManager<TUser>.

A form's field is read from a buffered copy of the body, rewound so your endpoint still reads it whole; a body that cannot be read for it is assessed with unreadField, as in Node. Field names are matched ignoring case, as Request.Form and ASP.NET Core's JSON binding match them, so EMAIL= is the email field and email=…&EMAIL=… is the field sent twice. The query string is read beside the body, as ASP.NET Core's model binding reads it (a minimal API's string email comes from ?email=), and path parameters are cut before paths are matched. Form names are read as model binding reads them: with Input.Email configured (ASP.NET Core Identity's login page), Email, model.Email and Input[Email] are the field too, in any case, and two different values among them are not believed. Under UsePathBase, configure paths as your routes see them (/login): that covers /app/login and /login alike. Timeout (2 seconds) bounds the assessment, whatever HttpClient the client has; past it the request goes on. The calls behind the answer are given it or the client's 10 seconds, whichever is longer. A large chunked upload to a form path is streamed on to your application, never spooled to disk. Nothing the middleware decides waits on RequestAborted, which Kestrel fires when a client half-closes its side of the connection: a sign-in sent that way is still read (or, if its body never arrived, assessed as unread), assessed and answered — bounded by the client's own timeout — never let through unassessed, and a refusal that cannot be written is still never passed on. ReportReceiptUsageAsync and RevocationsAsync are on the client for a verifier of your own. ASP.NET Core does not keep the order headers arrived in, so the assessment is told so. The URL's scheme comes from Request.IsHttps, which makes it right behind UseForwardedHeaders. See sdk/dotnet/README.md for every option.

Monitor mode, for a site's first weeks: Monitor = true in the middleware's options decides every request as usual and acts on none of it. Where it would refuse or challenge, the request goes on to the application, and context.Antibot().WouldAct says what would have been done and why (block or challenge, with signal:bot, assess:block, missing:challenge, ...). Assessments carry monitor: true, so the API keeps them as monitored, and outcomes are still reported.

Ruby

Ruby 3.1+. Standard library only.

gem install antibot
antibot = Antibot::Client.new(api_key: ENV.fetch("ANTIBOT_SECRET_KEY"),
                              endpoint: "https://api.antibot.dev")

event = antibot.get_event(request_id)
antibot.iterate_events(linked_id: "user_42") { |e| puts e["requestId"] }

Answers are Hashes with String keys. A non-2xx answer raises Antibot::ApiError (status, code); the API out of reach raises Antibot::ConnectionError. timeout: (10 s) bounds a whole call, including a 429's Retry-After wait, which is retried (max_retries:, 2) only when it fits.

Middleware: Rack (Rails, Sinatra, Hanami)

Antibot::Middleware makes the Node middleware's decisions, by the same rules, in any Rack application; its answers are the Node guard's, byte for byte.

# config/application.rb — first, before anything reads the body
config.middleware.insert_before 0, Antibot::Middleware,
  client: Antibot::Client.new(api_key: ENV.fetch("ANTIBOT_ASSESS_KEY"), endpoint: "https://api.antibot.dev", timeout: 2),
  session_keys: ENV.fetch("ANTIBOT_SESSION_KEYS").split(","),
  # The header your load balancer or CDN sets — never one a client can write.
  client_ip: ->(env) { env["HTTP_CF_CONNECTING_IP"] },
  protect: ["/shop", "/api"],
  logins: [{ path: "/users/sign_in", field: "user[email]" }],
  challenge: { public_key: ENV.fetch("ANTIBOT_PUBLIC_KEY"), agent_url: "/_ab/agent", endpoint: "https://shop.example" }

In a controller, request.env["antibot"] is the state: verdict (the assessment) and receipt (the verified claims), nil where the request was not assessed.

def create
  antibot = request.env["antibot"]
  return ask_for_second_factor if antibot.verdict&.dig("action") == "challenge"
  user = User.authenticate_by(email: params[:email], password: params[:password])
  antibot.outcome(user ? "success" : "failure")
end

Sinatra, Hanami or a config.ru: use Antibot::Middleware, client: …. Without outcome, a sign-in's outcome is read from your response by the edge's rules, or from an antibot-login / antibot-code response header, which is taken off before the visitor sees it.

A form's field is read from a copy of the body (32 KiB, 96 KiB for a message) put back for your application to read whole, and from the query string, as Rails' params merges the two. A body that cannot be read for the field — including one whose read fails because the client went away — is assessed with unreadField. The path is SCRIPT_NAME + PATH_INFO, so an absolute-form request is its path; an application mounted under a prefix (map "/shop") is read at its own paths as well, so a configured /login is the request for /shop/login, and so is /shop/login written whole. A format suffix is a path of its own: Rails routes /orders.json to the action /orders names, so protect a prefix both share, or name each. A Rack env does not keep the order headers arrived in, so the assessment is told so. Outcome reports, receipt usage and revocations run on background threads, never delaying a response. See sdk/ruby/README.md for every option.

Monitor mode (monitor: true in the Antibot::Guard options, or the middleware's) decides every request as it would be decided, and acts on none of those decisions: a refusal or a challenge goes on to your application, and env["antibot"].would_act says what would have been done, { action:, reason: } (nil when nothing would have been). Sign-in outcomes are still reported, and each assessment is sent with monitor: true, so the API keeps it as what would have been done, not as done. Use it for a site's first weeks, then turn it off.

Why responses are maps, not typed models

Every client returns decoded maps or JsonElement rather than a fixed class. The signal vocabulary grows server-side, and a struct compiled last year would silently drop anything added since. You get the whole payload, always.