← All docs

Server API

Authenticate with a secret key, in any of these headers:

Authorization: Bearer sk_live_…
Auth-API-Key: sk_live_…
X-API-Key: sk_live_…

An ?api_key= query parameter is accepted too, but it writes the key into every log the URL passes through; prefer a header.

Base URL is your LayerSift origin, or your own if you serve first-party.

GET /api/v4/events Search across every filter
GET /api/v4/events/{requestId} One event, with every signal
PATCH | PUT /api/v4/events/{requestId} Attach a linked id, tags or a suspect label
DELETE /api/v4/events/{requestId} Delete one event, and what webhooks were sent of it
GET /api/v4/visitors/{visitorId} Paginated visit history
DELETE /api/v4/visitors/{visitorId} Right-to-erasure
GET /api/v4/related-visitors?visitor_id=… Devices sharing an IP or linked id

The key decides the environment, so a key issued in staging never sees production data — and gets a 404, not a 403, because any other answer confirms the record exists.

Get one event

curl https://api.antibot.dev/api/v4/events/msdcg6f1.kRuTmb \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY"
{
  "requestId": "msdcg6f1.kRuTmb",
  "visitorId": "CTBiu58afNEqTbn1hWj2",
  "visitorFound": true,
  "confidence": { "score": 0.9997 },
  "suspectScore": 8,
  "suspect": null,
  "platform": "web",
  "incognito": false,
  "decision": { "action": "allow", "matchedRules": [] },
  "proxied": false,
  "timestamp": 1785719825943,
  "time": "2026-08-03T12:37:05.943Z",
  "url": "https://example.com/checkout",
  "ip": "203.0.113.9",
  "linkedId": "user_42",
  "tag": { "action": "checkout" },
  "sdk": { "name": "antibot-js", "version": "1.0.0" },
  "browserDetails": { "…": "…" },
  "products": { "…": "every signal" }
}

Search events

curl -G https://api.antibot.dev/api/v4/events \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY" \
  -d bot=true -d country=GB -d country=IE -d limit=50

Repeating a key ORs its values. Different keys AND together. The example reads "a bot, from either country". A key takes at most a hundred values (400 past that). Where addresses are not kept (privacy), ip finds the events whose stored digest is the address's: a whole address only, a range there is 400, no digest being in one.

Filter Values
visitor_id, linked_id, country repeatable
high_recall_id a high-recall visitor id (supplementaryIdHighRecall.visitorId); repeatable
url the start of the page's address, as given, case and all: https://shop.example/checkout is every checkout page, its queries too. % and _ are themselves; repeatable
sdk_version the SDK's version, exactly (1.4.2 is not 1.4.20); repeatable — which events a release still sends
ip an address in any spelling of it, or a CIDR range — 203.0.113.0/24, 2001:db8::/32 — for every address in it; repeatable, either. Anything else is 400
asn a network by its number, AS16509 or 16509; repeatable. Anything else is 400, and so is any network where addresses are not kept: none is
platform web, android, ios; repeatable
bot_type the event's bot.type, e.g. googlebot, headless-chrome, navigatorWebdriver, notDetected; repeatable
start, end epoch milliseconds, or a date (ISO 8601 is the one to send; any form JavaScript's Date reads is taken). Anything else is 400
suspect true, false, any
min_suspect_score 0–100
ai_agent_kind trainingCrawler, assistant, agent, none; repeatable
ai_agent, ai_agent_verified true / false
bot, vpn, proxy, tor, incognito, tampering, virtual_machine, replayed, proxied true / false
developer_tools true / false: a browser's inspector docked; on a phone, developer mode
datacenter, ip_blocklist, anti_detect_browser, high_activity, factory_reset true / false: the address a datacentre's, or on a blocklist or your threat feed; an anti-detect browser; a device busier than your others; a phone reset in the past week. Identifications made before these were kept read false
rooted, jailbroken, emulator, cloned_app, frida, location_spoofing, mitm_attack true / false
reverse true: oldest first — the window read forward, page keys and all, as a sync that goes on from where it stopped reads it
limit default 20, at most 200
pagination_key the paginationKey of the previous page
{ "events": [ … ], "paginationKey": "1785683034539" }

Pagination is keyset on the timestamp, so a deep page costs the same as the first. Pass each page's paginationKey back as pagination_key until it is absent.

A filtered page reads at most 20,000 events, newest first, unless visitor_id, linked_id, high_recall_id or an ip address narrows the search (each has an index of its own; a range has none, nor has asn: they are filters like the rest); an unfiltered one reads only what it returns. A rare filter — frida=true, a label, a high min_suspect_score, a narrow range — over a large environment can therefore come back short, or empty, with a paginationKey: nothing more matched in the 20,000 it read, and there are older events to read on from (newer ones, with reverse). Keep paging until the key is absent; narrow with start and end to read less.

To keep a copy in step — a warehouse, a review queue — read forward: reverse=true&start=<the last time you read>, paging until the key is absent, then start the next run from the newest timestamp you were given. An event is written as it is identified, so one stamped a moment before your last read can arrive after it: start a few seconds back, and drop what you already hold by requestId.

suspect is tri-state

null means unreviewed, which is not the same as reviewed and clean. That is why suspect=any exists — it measures how much of your traffic anyone has actually looked at.

Label an event

curl -X PATCH https://api.antibot.dev/api/v4/events/msdcg6f1.kRuTmb \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"linkedId":"order_9931","tag":{"reviewed":true},"suspect":true}'

linkedId and its snake_case alias linked_id are both accepted. linkedId is at most 256 characters and tag at most 16 KB serialised, as when they are set at identification. "linkedId": null clears the linked id, "tag": null the tags, and "suspect": null returns the event to unreviewed. A body naming none of the three is 400 InvalidPayload. The answer is the updated event.

Visitor history

curl -G https://api.antibot.dev/api/v4/visitors/CTBiu58afNEqTbn1hWj2 \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY" -d limit=50
{ "visitorId": "…", "visitor": { … }, "visits": [ … ], "lastTimestamp": 1785683034539, "paginationKey": "1785683034539" }

limit is 20 by default, at most 200; linked_id keeps only the visits with that linked id. For the next page, pass paginationKey back as paginationKey (the event search's pagination_key spelling is taken on both endpoints too). Both keys are absent on the last page.

DELETE on the same path erases the visitor and every row anywhere that names it — its events, its holds, its sessions and their beacons, its step-ups, its places in waiting rooms, the receipts an edge reported it on, the attested keys it signed with, its typing and pointer statistics, a revocation that names it, the cross-browser identity only its events were joined to, and the deliveries your webhooks keep of it (its identifications, its sessions' escalations, an alert revoking it) — in one transaction. What your endpoints already received is yours to erase on your side. Use it for right-to-erasure requests. It cannot be undone.

curl -G https://api.antibot.dev/api/v4/related-visitors \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY" \
  -d visitor_id=CTBiu58afNEqTbn1hWj2

Devices that share an IP address or a linked id with this one — the shape of a shared account or a device farm. Over the last 30 days, at most 50, a shared linked id first:

{
  "visitorId": "CTBiu58afNEqTbn1hWj2",
  "relatedVisitors": [
    { "visitorId": "…", "relation": "linkedId", "sharedIpEvents": 3, "sharedLinkedIdEvents": 2, "lastSeenAt": "2026-08-03T12:37:05.943Z" }
  ]
}

Assess a request

POST /api/v4/assess

A verdict on a request that never ran the agent: an API call, a page fetched by a script, a login posted by a credential-stuffing tool. Call it from your server (or let the edge enforcer call it) for requests that arrive without a session token, and act on action.

curl https://api.antibot.dev/api/v4/assess \
  -H "Authorization: Bearer $ANTIBOT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ip": "81.2.69.160",
    "method": "GET",
    "url": "https://shop.example/product/42",
    "headers": [["host", "shop.example"], ["user-agent", "python-requests/2.32.3"], ["accept", "*/*"]]
  }'
{
  "requestId": "asr_…",
  "action": "challenge",
  "score": 60,
  "reasons": ["userAgent:httpLibrary"],
  "bot": { "result": "bad", "type": "http-client" },
  "network": { "result": false, "reasons": [], "inferred_client": "unknown" },
  "crawler": null
}

crawler is crawler verification, { status, bot }, for a User-Agent naming one of the crawlers it knows (search engines, link previews, AI crawlers, uptime monitors), and null otherwise. status is verified (from its operator's own addresses), spoofed (every check its operator offers answered no), pending (not answered in time; see below), unknown (a check could not be made — a published list that would not load, a resolver that failed) or unverifiable (its operator publishes no way to check — Slackbot, ClaudeBot). When the User-Agent names any known crawler, declaredCrawler: { name, operator, category } says what it is for (search, social, seo, monitoring, aiTraining, aiSearch, aiAssistant — see crawlers by purpose); it is the claim, and crawler is whether the claim holds.

When the request carries a Web Bot Auth signature (Signature, Signature-Input, Signature-Agent — pass the headers as they came), signedAgent: { verified, host, operator, name, reason, definitive } says what it proved. Verified from a directory known to be an operator's — ChatGPT agent's, https://chatgpt.com — the agent is let through as itself (agent:verified:chatgpt-agent), and what is counted by address is not held against it: it leaves from its operator's addresses, which every person's agent shares. What it does to an account, a card, a code or a message still counts. Verified from any other directory, it has proved who it is and no more (agent:signed:<host>). Where your organization lists its allowed origins, a signature made for another site (reason: "authorityElsewhere") verifies nothing: it is the agent on a site you did not list, or its signature captured on someone else's site and sent here under that site's name. An identification, which an agent's browser sends to this service itself, is held so always: to this service's own hosts and your listed sites. A signature naming a known operator that fails as a verdict — a forgery, one replayed from another site or from long ago — is agent:spoofed:<name> (80, a block), whatever else the request carries beside it. Before a mismatch is called a forgery, a key set read more than five minutes ago is read again: an agent that rotated its key since is not one. Only a signature that covers @authority and carries created verifies at all — over the Host the agent sent, and the path where the signature covers it, so a proxy before your server that rewrites either makes every agent's signature fail. Agents sign the site alone, so a signature verifies on any of its pages for its five minutes; sent from more than four networks (an address, an IPv6 /64), or more than 200 times, it is a copy — taken from your site's traffic, or made for another site and sent with that site's name to a server that answers any — and verifies no more (reason: "signatureReplayed"), with no impersonation said: the key did sign it, and its agent's own requests come first.

Shopping agents a card network registered sign the same way, under Visa's Trusted Agent Protocol: the tag says what the agent is doing — agent-browser-auth browsing, agent-payer-auth paying at checkout — and the keyid is the network's name for the agent's key, which its registry answers for. Name the registries this deployment trusts in ANTIBOT_TRUSTED_AGENT_ISSUERS (Visa=https://…/keys/{keyid} — a registry asked for one key by its id, or the URL of its whole key set), and such a signature is checked against them and nothing else: a Signature-Agent beside the tag is not where its key is looked for, since the network, not the agent, vouches. Verified, signedAgent carries the network as operator, the key's id as name and intent: "browse" | "pay" (only when verified: unverified, it is a claim); the agent is let through as itself (agent:verified:<keyid>), with what it does to an account or a card still counted, and your rules read agent.intent (the template Let shopping agents a card network vouches for pay). It must cover @authority and @path, carry created, expires at most eight minutes later, and a nonce; a signature the registry's key did not make is agent:spoofed:<keyid>, while a key id no registry holds — one registered since the set was read is as likely as a made-up one — is only unverified. Nonces are not remembered: the signature binds the site and the page and lives eight minutes, and one request assessed twice (an edge, then your server's guard) would read as a replay. The objects a payment carries in its body — the consumer's identity token, the payment container — are your checkout's to verify with its payment provider.

Field
ip The client's address, as your edge or load balancer determined it. None — left out, empty, garbage from a header a client forged, 0.0.0.0, :: or loopback (a proxy on the same machine that passed on no client's address), in any IPv6 spelling — is never refused: the request is assessed as ip:unknown (20) on what it carries, and nothing kept per address counts it or is counted against it — no per-address rate, trap, hourly or page counts, no crawler check, no threat feed, no network reputation, and your rules read those counts as one request's too — since every address-less request would share one. What is counted by account, card, code or device still is; but one client working down a list of accounts from no address is then seen only by those, so make sure the address reaches the call (a request round your CDN, without its header, is the usual way one goes missing). A private address is assessed as itself but never reported to, or read from, other sites' reputation. Any optional field sent as null is read as absent, inside an object (transport, hold, shipping…) as at the top.
url The URL the request asked for. One that does not parse — most often a Host no browser sends, shop.example:99999, carried into it by a middleware — keeps its host and path for your rules and is request:malformedUrl (25); a path alone (/login?next=/, as a framework's request holds it) is read as that path, with no reason.
headers [name, value] pairs in the order they arrived — the order is evidence (Node's req.rawHeaders paired up — it is name, value, name, value…, which the Node SDK takes as it is; an edge runtime's header list; a Headers object iterates sorted and loses it). cookie and authorization are ignored; do not send them.
transport Optional TLS evidence (ja4, ja3, alpn, tlsVersion, tlsCipher, http2) when your edge captured it.
sessionToken The _abid cookie, when there was one. A valid token speaks for its client (session:valid, or a block if it was condemned); a browser's receipt more than five minutes old whose page never pulsed is said (session:silent, never weighed: for your rules, session.silent); one minted for another client is a reason against (session:bindingMismatch), and so is one minted on a browser's TLS handshake and presented, by the transport.ja4 beside it, on one no browser of its family produces — a receipt replayed from an HTTP library (session:transport, 20; the handshake is then judged as for any request).
account The account the request acts on — the username or email of a sign-in, a sign-up, a password reset. Compared as a sign-in reads it, so fullwidth letters, an invisible character, another case or padding make no new account. Digested on arrival, never stored as given. With it, the verdict sees one address working down a list of accounts (credential stuffing: 5 distinct accounts in ten minutes is a reason, 15 a challenge on its own) and one account tried from many addresses (a spray or a takeover rotating proxies: weighed lighter, since its owner is among them). With a valid sessionToken beside it, the device is known by its visitor id too: one device working down a list from many addresses (velocity:accountsFromDevice, the same thresholds as an address, the same list counted once) and one account from many devices (velocity:devicesForAccount, 5 a reason, 10 more; counted per address, so browser profiles rotated behind one address count once). And once outcomes are reported, each success your application declares is remembered for the account, by digest, for 180 days: newDevice and newCountry say the account has signed in before but never from this device (or from no device it can tell — a request without a receipt), or this country — the shape of a takeover with a stolen password. They score nothing on their own; they are for your rule. The response then carries velocity: { accountsFromAddress, addressesForAccount, …, variantsOfAccount, accountsFromDevice, devicesForAccount, failuresFromDevice, successesFromDevice, newDevice, newCountry }, your own counts for the last ten minutes — each, like every count beside it (cards, phones, codes, holds, posts, a destination's), read from at most 1,000 of the window's sightings (a user's requests from 5,000): past that a count reads 1,000, and a sum is what those sightings hold. A valid session token does not excuse working down a list — save, on a crowd's address after a passed challenge, the address's own count of it, never the device's (see the crowd paragraph below). When the account is an email address, see what the address says.
card The card a payment charges, by an opaque identifier of six characters or more (shorter is 400) — your processor's card fingerprint or a keyed hash of the number, never the number (400 CardNumberRefused). Digested on arrival. With it, the verdict sees card testing (one address, or one device behind a proxy pool, many cards, nearly all declined) and card cracking (one card declined again and again), and the response carries card: { cardsFromAddress, cardsFromNetwork, cardsFromDevice, addressesForCard, declinesFromAddress, approvalsFromAddress, declinesFromDevice, approvalsFromDevice, declinesForCard } — the device's counted by the session token's visitor, 0 without one; cardsFromNetwork across an IPv6 source's /48, 0 for IPv4. See protecting checkout.
phone The number a one-time code is about to be texted to, in E.164 form (+447700900123; the international prefix 0044… and a tel: link are read as it), read as libphonenumber reads it before the message goes: fullwidth, Arabic-Indic or any other script's digits, and characters nobody sees, make no other number. A number that is not one — a national format, padding past 32 characters — is assessed without its counts, phone:unreadable (20), never refused: a refusal is a call your code may let through, and a code texted to a number never counted. Digested on arrival with its block of a thousand. With it, the verdict sees SMS pumping (many numbers from one block, codes never entered) and SMS bombing (one number texted over and over), and the response carries phone: { callingCode, nonGeographic, phonesFromAddress, phonesFromNetwork, phonesFromDevice, addressesForPhone, sendsToPhone, numbersInRange, verifiedInRange } — phonesFromDevice counted by the session token's visitor, 0 without one; phonesFromNetwork across an IPv6 source's /48, 0 for IPv4. See protecting text messages.
content What was written — a comment, a review, a contact form's message (up to 20,000 characters). Read in memory and kept only as a fingerprint for two hours, never as text. With it, the verdict sees one message from many senders — three addresses posting it, or one near enough, in an hour is content:repeated (30, 45 with a link), ten 60 — a message your moderators reported as spam, posted again from anywhere (content:reportedSpam, 60), many posts from one address or one device (velocity:postsFromAddress, velocity:postsFromDevice, thirty 30, a hundred 60, counted once) and many links (content:manyLinks, 15). The response carries content: { words, links, linkHosts, sendersOfContent, postsFromAddress, postsFromDevice, postsFromNetwork, reportedSpam, approved } — postsFromDevice counted by the session token's visitor, 0 without one; postsFromNetwork across an IPv6 source's /48, 0 for IPv4. See protecting forms.
code A code being tried — a gift card number (without its PIN), a voucher, a coupon, an invitation. Case, spaces and dashes ignored; kept only as a 24-bit digest, for an hour. With it, the verdict sees codes being guessed — fifty distinct codes in ten minutes from an address or, by its receipt, a device is velocity:codesFromAddress / velocity:codesFromDevice (30), a hundred and fifty 60; fifty refusals with hardly an acceptance velocity:invalidCodes (40), a hundred and fifty 60; whoever passed a challenge is excused these — and a gift card's PIN being cracked: five refusals of one code, at least two from each address that tried it, is velocity:codeCracking (40). The thresholds leave coupon extensions alone. The response carries code: { codesFromAddress, codesFromNetwork, codesFromDevice, addressesForCode, failuresFromAddress, successesFromAddress, failuresFromDevice, failuresForCode } — codesFromNetwork across an IPv6 source's /48, 0 for IPv4. See protecting gift cards.
flow Which step of one of your own flows this request is — a checkout's cart, delivery and payment; a sign-up's pages; a booking: { "name": "checkout", "step": 2 }, and minMs when a person needs longer than a second between the step before and this one (4000 for a payment form typed by hand). Sent on each step's assessment, it is read against the steps before it, over the last hour, as the receipt's visitor took them (from any address) or as the client did — the address and the browser it says it is, so two people behind one office's address are two clients, and a script sending one step with its receipt and the next without is still one: a step taken sooner after the one before than minMs (one second when not said) — or, with none before it, in a burst with the flow's other steps — is flow:rushed (25), and a second such step in ten minutes a challenge on its own (50) — nobody clicks through two pages each in under a second; a step past the first entered without the one before it is flow:skipped (15) — a page reopened from history does that too, so it is a reason, never a challenge alone. A valid receipt excuses neither. The name is kept only inside a keyed digest, for the hour. The response carries flow: { sincePreviousMs, skipped, rushed, rushedSteps }; rules see flow.name, flow.step and those (flow.sincePreviousMs reads as an hour when there was no step before). A flow that is not an object, a name that is not a string or is empty, or a step that is no whole number of 1 or more, is no flow, never a 400. See protecting checkout.
graphql The GraphQL a request to your GraphQL endpoint carries: { "documents": [{ "query": "…", "operationName": "Login", "times": 1 }], "count": 1, "persisted": 0, "persistedRepeated": 0 } — its documents, each distinct text once with the times the request carried it (a batch of one document, folded), each with its string values emptied (""), its comments and runs of spacing taken out: a password written inline must not leave your server, and nothing here needs it. count is the documents the request carried; persisted how many were a hash whose text was not known, and persistedRepeated the most times one hash came. Twenty texts are read. Read in memory and dropped: the operation that would run (the one operationName names — compared whole — or the document's only one; where that is unclear, every one of them), the fields at its top by the key each answers under, through aliases and fragments, how often one repeats. One mutation five times or more in a request — a sign-in as aliases, a code tried in a batch — is graphql:repeatedMutation (40; twenty times, 60, a challenge on its own), past every limit that counts requests; otherwise ten mutations or more of any fields is graphql:manyMutations (40; thirty, 60) — one cause weighed once, so twenty copies of one sign-in are the first alone; one persisted query five times or more is graphql:repeatedPersisted (40; twenty, 60). A document that could not be read through — a text past 32,768 characters (cut), a body your guard could not read, nested or spread past what is read, or one of a batch past its twenty texts — may hold what it likes: graphql:unread (50, a challenge on its own). A batch of fifty documents or more is graphql:largeBatch (30); the schema asked for (__schema, __type) is graphql:introspection (20, a reason). A document a server would refuse runs nothing and counts nothing (malformed). Not excused by a receipt or a passed challenge: whoever holds it is the one sending. The answer says what was read (graphql), and your rules read it — graphql.mutationFields contains "login" and graphql.mutationRepeated >= 2 (the template Stop sign-ins batched into one GraphQL request). A document that is not one is left out alone; anything that is not documents is read as none, never refused
hold Stock being held, when the hold is placed — an item put in a cart that reserves it, seats picked, a booking held: { "item": "<your id for it>", "quantity": 2 } (quantity 1 when left out). The item is kept only as a keyed digest, for a day and an hour. With it the verdict sees, over the last hour, stock held and not bought — a hundred units held from an address or, by its receipt, a device, less what was bought from there in the hour, is velocity:holdsFromAddress / velocity:holdsFromDevice (30), three hundred 60 — and, over the last day, one item held again and again with none of it bought: four holds inventory:reheld (30), eight 60; whoever passed a challenge is excused these — and one item held again as each hold lapses: two lapses reported and a third hold is inventory:holdCycling (40), four and a fifth 60, excused by nothing, whether holds last ten minutes or an hour. With a receipt the device's own holds are counted apart from its address's: what an address cycled that this device did not — a carrier's shared address, an office during a drop — is the reason at 40 at most, not a challenge on its own. A lapse reported for an address and item is the hold of the device that held it there last — unless the report carries the hold's sessionToken and it names another device, or is "" for a hold that carried none: then the lapse is no device's, so someone else letting holds lapse on a shared address does not count against your customer's device. A hold's report may come as it lapses, an hour on: its receipt is read as its device that long after it expired. The response carries hold: { unitsFromAddress, purchasedFromAddress, itemsFromAddress, unitsFromDevice, purchasedFromDevice, unitsFromNetwork, purchasedFromNetwork, itemHoldsFromAddress, itemLapsesFromAddress, itemPurchasedFromAddress, itemHoldsFromDevice, itemLapsesFromDevice, itemPurchasedFromDevice, unitsForItem, purchasedForItem, holdersForItem }; the item's own totals are never weighed. See protecting stock from being held.
shipping Where an order is going: { "line1", "line2"?, "city"?, "postalCode"?, "country" } (country ISO 3166-1 alpha-2, read past fullwidth letters and invisible characters; anything else is read as unknown, ZZ; every line is trimmed and cut — line1, line2 256 characters, city 128, postalCode 32 — never refused, since the visitor typed it), sent with the account (or user) and card the order is from. Read as a delivery point — the country, the postal code (or the city, without one), the house number and the street's first word beside it — past the ways a reseller rewrites one door to look like many ("address jigging"), in any script, and past characters a label never shows (a zero-width space, a soft hyphen), and kept only as keyed digests, for two days. A unit is never part of it, so a block of flats is one door; with neither a postal code nor a city nothing is counted. With it, over the last day, the verdict sees many accounts or cards shipping to one door — ten is velocity:accountsForDestination / velocity:cardsForDestination (30), twenty-five 60, one ring counted once, excused for whoever passed a challenge — and one account or card writing one door many ways: four ways or more, twice the accounts and cards behind them, is shipping:jigged (40). The response carries shipping: { country, countryMatchesIp, accountsForDestination, cardsForDestination, devicesForDestination, addressesForDestination, variantsForDestination }; countryMatchesIp is never weighed. See protecting limited releases.
user Who is signed in, by your own opaque id for the account — not the account a sign-in acts on (that is account). Digested on arrival. With it, the verdict sees one account used from a proxy pool — scraping behind a login, or an account shared far beyond its owner: ten addresses in an hour is velocity:userAddresses (30), twenty-five 50 — and an account at a scraper's pace: two thousand requests in an hour is rate:user (30), five thousand 50. The counts are the account's, so they fall on every browser using it, the owner's too: a receipt from identifying does not excuse them, but one earned by passing a challenge does — the reasons are still given, at no points — so whoever passes is not asked again while the receipt lasts. And one session in two countries at once — a session cookie lifted from its owner's browser and used from wherever its thief is, while the owner goes on: two countries within half an hour is velocity:userCountries (20), three 35 — counted only from addresses that say where a person is, never a VPN's, a relay's, a datacenter's or a Tor exit's, whose country is the service's. It is a reason, never a challenge on its own: an address is sometimes placed in the wrong country (a carrier's pools, a satellite link), and whoever took the session passes a challenge as its owner does — so a passed challenge does not excuse it, and the answer is your own re-authentication, on a rule such as user.countries >= 2 for the actions that matter. Off for an organization that pseudonymizes addresses, whose addresses place nobody; elsewhere the country is kept beside the user's digest for two hours. The response carries user: { addressesForUser, requestsForUser, countriesForUser }: the last hour's counts, which slide — each request counts until it is an hour old rather than all resetting on the hour — and the countries of the last half hour.
userSession The signed-in session the request belongs to: a digest of your session cookie (or of the session's id) — SHA-256 hex is what the SDKs and the edge send — never the cookie itself. Digested again on arrival; kept only as that digest, beside the device and the client that first used it, for thirty days from its last use. A session cookie lifted from its owner's browser — an infostealer, a malicious extension — is replayed from the thief's own machine while the owner goes on: the same session from another browser, system or kind of device than it was bound to is userSession:otherClient (50, a challenge on its own: no session moves between browsers) — unless it comes with a receipt of the device it was bound to, which is its owner's whatever the User-Agent now says (a phone asking for the desktop site) — and from another device — both with a receipt from identifying, and their devices differ — userSession:otherDevice (35). The binding is the first one's and stays so: a thief's requests do not move it. Neither is excused by a passed challenge, since whoever holds the session passes one as its owner does — the answer is your own re-authentication, on a rule such as userSession.otherDevice or userSession.otherClient before the actions that matter (the template Ask again when a session moves to another device). The answer carries userSession: { known, otherDevice, otherClient, ageMs }
agentOnPages true when every page of the site loads the agent — the edge sends it with ANTIBOT_AGENT_INJECT=on where it places the agent (Cloudflare, Azure, Fastly), the middlewares with agentOnPages. A person's second page then carries the receipt the agent writes; page navigations without one are counted per client (address, User-Agent, languages as a set of tags, q= values aside) over ten minutes, and the third is session:agentNotRun (30), the fifth 60 — what stops an HTTP client that imitates Chrome on the wire and runs no script. Prefetches, API calls, verified crawlers, vouched agents and bots that say what they are and are classed good (Slackbot, an uptime monitor) are not counted.
proof The request's antibot-pop header, beside a sessionToken bound to an app's key (cnf, mobile). Such a token is a session only with a fresh proof by that key over this request's method and path; without one it is session:proof (20). The edge and the middlewares send it.
headersOrdered false when headers are not in the order the visitor sent them, for example behind a reverse proxy that holds headers in a map (Traefik, Caddy). The forward-auth service sends it there. Their order is then not read as evidence. Leave it out when the order is the visitor's.
blockSignals The signals in a receipt you refuse it for — the edge's ANTIBOT_ENFORCE_BLOCK_SIGNALS, the middlewares' blockSignals: up to 16 strings of 32 characters at most (past either, 400: it is your configuration, not a visitor's), ["bot"] when left out, none when empty. A valid sessionToken carrying one is judged as convicted (session:automated, a block), as you would judge it.
challengeAt, blockAt Thresholds, default 50 and 80.

What an email address says

When account is an email address, the response carries what the address alone says — no lookup, no mail sent. The address is read as mail software delivers it (UTS #46): x@mailinator.com, a soft hyphen in the domain or an ideographic full stop for its dot are the disposable address they deliver to:

"email": { "domain": "gmail.com", "disposable": false, "freeProvider": true, "subaddressed": true, "generated": false, "relay": false }
  • disposable — a throwaway-inbox service (Mailinator, YOPmail, Guerrilla Mail, 10 Minute Mail and their alternate domains, 111 in all, and with ANTIBOT_BLOCKLISTS_DIR the community's disposable-email-domains list, some four thousand more — never over a mailbox provider or a privacy relay), or a subdomain of one: email:disposable, 30. People use them for sites they do not trust, so on its own it is a reason, not a challenge.
  • generated — a local part shaped like no name: letters and digits alternating (xk3j9q2m7p), almost no vowels, a long hex string. email:generated, 10. A name, initials or a year after a name never trips it.
  • subaddressed — a +tag, which is how careful people sort their mail: email:subaddressed, reported and never weighed.
  • relay — a privacy relay's address, made for your site by the person's own service and forwarded to their inbox: Sign in with Apple's Hide My Email (privaterelay.appleid.com), Firefox Relay, DuckDuckGo, SimpleLogin, addy.io, Proton Pass. email:relay, reported and never weighed; its random local part is not read as generated.
  • freeProvider — Gmail, Outlook, Yahoo, iCloud, Proton and the like, as opposed to an organization's own domain. Not weighed; for your rules.

The many-names use of a subaddress is counted instead: velocity.variantsOfAccount is how many differently written accounts in the last ten minutes are delivered to one inbox — jane+1@, jane+2@, and on Gmail j.ane@ and ja.ne@, which Gmail delivers to jane@; one username at iCloud's icloud.com, me.com and mac.com, or at Proton's proton.me, protonmail.com and pm.me, is one inbox too. Three is velocity:accountVariants (30), six 50, from one address or from many; from one address the larger of it and accountsFromAddress counts, not both. The inbox is digested like the account: nothing stores it.

When the call fails

A client called from your own code raises (Go's returns an error) when there is no verdict: an error answered (AntibotApiError and its kin, with the code), or the API out of reach past the client's timeout (ten seconds by default; set it to what the request can wait). What happens then is yours to decide, action by action, and worth deciding before an outage does: the edge and the SDK middlewares let the request go on, so that people go on signing in, and say so in the log. Where a bot let through costs more than a person turned away — a text message sent, a code redeemed, stock held — refuse instead:

try:
    verdict = antibot.assess(ip=client_ip, headers=raw_header_pairs, phone=number)
except Exception:
    return refuse()  # no verdict: this one is not worth the risk

Report how a sign-in turned out

POST /api/v4/assess/outcome
{ "ip": "81.2.69.160", "account": "[email protected]", "outcome": "failure", "sessionToken": "ab1.…" }

204. Send it after each sign-in attempt you assessed with an account (the SDKs' reportOutcome; the edge enforcer does it for you from the login response). A stuffing run fails on nearly every pair it tries, so an address with ten or more failures and almost no successes beside them (velocity:failedSignIns) weighs 40, thirty or more 60 — and so does a device, by the session token's visitor, whatever addresses it signs in from (one browser behind a proxy pool, or on a VPN's exit), the two counted once; an office behind one address, failing often but succeeding far more, says nothing. An account failing from everywhere is velocity:failedForAccount. Digested on arrival, like the account in an assessment. "inferred": true is the edge's own mark on an outcome it read from the login response's status rather than one your application said: counted as any, but a success so read never teaches the account a device or a country it signed in from.

sessionToken is the receipt the attempt carried, "" for none: the outcome is counted to that device and no other, so someone else on a person's address failing at their account is not counted to their laptop. The outcome lands on the device's own sighting of the account, which an assessment writes only for a receipt it accepted, within ten minutes of it, and only where that device's receipt is the last one the report's address was assessed with for the account: a receipt the assessment refused for this attempt (lifted into another browser, unproved where it must be proved, revoked, already expired) names no device, whatever its owner's own device did elsewhere, and one that expired after its attempt still names its device. A value too long to be a receipt is cut, never refused. Left out, the last receipt seen for that address and account is taken for it. The edge enforcer and the Node SDK's guard send it; pass it to reportOutcome from your own code.

A payment's outcome goes to the same endpoint with card in place of account — success approved, failure declined — once your processor has answered (the SDKs' reportPaymentOutcome), with sessionToken as a sign-in's: a decline is counted to the device the payment was assessed as, and to none for one that carried no receipt:

{ "ip": "81.2.69.160", "card": "Xt5EWLLDS7FJjR1c", "outcome": "failure", "sessionToken": "ab1.…" }

A texted code's outcome takes phone — success the code was entered, failure it expired unused (the SDKs' reportPhoneOutcome). A redeemed code's takes code — success it was accepted, failure refused (the SDKs' reportCodeOutcome) — and sessionToken as a sign-in's does: a refusal is counted to the device the attempt was assessed as, and to none for an attempt that carried no receipt. A moderator's verdict on a message takes content — failure it was spam, success it was not (the SDKs' reportContentOutcome). A hold's end takes hold: { item, quantity } — success it was bought (the units bought), failure it lapsed or was let go unpaid (the SDKs' reportHoldOutcome). Each is read as the assessment read it, never refused for its size: content trimmed and cut to 20,000 characters, an item to 256, a quantity rounded into 1–100,000. Send any of account, card, phone, code, content and hold together when one request was several of these; at least one is required.

POST /api/v4/assess/trap
{ "ip": "81.2.69.160", "headers": [["user-agent", "python-requests/2.32.3"], ["accept", "*/*"]] }

A request for a link no person can reach (see trap links). A scanner's probe — a request for a path no site serves, such as /.env — is reported the same way with its "path" beside the headers; the answer then names what it was after ("probe": "secrets"), or "reason": "notAProbe" for a path that is not one (probes). A walk of the site's unlinked paths — twenty different pages it has not got, asked for from one address in ten minutes — is reported with "walk": { "notFound": 20 } and marks the address as a trap hit does ("walk": true in the answer); fewer is "reason": "notAWalk" (walks). The edge enforcer reports these itself; send one from your server if you answer a trap path there (the Node SDK's reportTrap). Pass the request's headers in order: a browser's prefetch, another site's embed (Sec-Fetch-Site: cross-site) and a verified crawler are not counted, and the answer says so — { "recorded": false, "reason": "crossSite" }. The ip must be an address (400 otherwise); one that names no client — 0.0.0.0, loopback — is "reason": "addressUnknown". A counted hit is { "recorded": true }, and every assessment without a valid receipt from that address for six hours carries trap:followed (60). With "monitor": true — a guard in monitor mode — the hit is marked for your environment as any other but not reported to the shared reputation network, as a monitored assessment's block is not.

action is allow, challenge — send a browser to a page that runs the agent (the edge enforcer serves one), refuse anything else — or block. The score is a documented rule set, like the suspect score: a library that names itself (60), a browser's name without a browser's headers (headers:contradiction, 50 and up, beside each network reading that made it as headers:<reading>; curl's exact default request under Chrome's name scores 90), a Chrome User-Agent no Chrome sends any more (50, userAgent:unreduced: a full build such as 131.0.6778.86, or a phone named in an Android one — since the reduction Chrome says 131.0.0.0 and Android 10; K, so the string is from a list; Edge, Opera, Samsung Internet, Electron and Android WebViews add to the string and are not read), self-declared automation (70), an Electron app's window (20, userAgent:electron: a site wrapped as a desktop app is one, so it is named, never convicted by its name), a crawler name its operator's addresses contradict (80, crawler:spoofed:googlebot), no User-Agent (55, userAgent:missing), a threat-feed hit (60), Tor (40, ip:tor), a residential-proxy network (40, ip:residentialProxyNetwork), a browser in a datacentre (30, ip:datacenter), a request another site's Cloudflare Worker sent (request:cfWorker, 30 under a browser's name: Cloudflare names the zone whose Worker made a subrequest in its CF-Worker header, and Workers are a free proxy that rotates by itself — your own zone's, the host or a name under it, are not counted, so a Worker of yours on another zone in front of the site should drop the header before the request is assessed), over 120 requests a minute from one address, or 1,920 from one IPv6 /48 whose /64s are not a crowd's devices (30, rate:ip), receipt-less requests from one address all hour (rate:sustained: past 500 in an hour 20, past 1,500 50, an IPv6 /48 read as one address's sixteenth unless its devices make it a crowd's — a browser is assessed once or twice and then carries a receipt, so this is a client that never runs the agent, however it paces itself), sixty-four /64s of one IPv6 /48 asking without a receipt in ten minutes (ip:rotatingNetwork: 25, two hundred and fifty-six 50 — a scraper taking a fresh /64 of a tunnel broker's /48 for every page; not where the /48's devices make it a crowd's), a trap link followed in the last six hours (60), an address other organizations condemned (network reputation: network:reputation:medium, 20; network:reputation:high, 35). A privacy service's exit — a consumer VPN's server, a privacy relay's egress, a Tor exit — is thousands of people on one address, and is weighed as that exit (Tor 40; a VPN's datacentre address 30, and 10 for the VPN, ip:vpn). So is an address no list names from which twenty devices or more ran the agent within the hour, paid its proof of work and were not convicted — a carrier's NAT, an office, a campus — counted per environment from the identifications that ran it (rate.devicesFromAddress, for a rule): a client that only fetches pages adds none. What is counted of the address beyond that — both rates, a trap followed, other organizations' word on it, IPsum's lists (ip:blocklisted:<n>), pages asked for without the agent running, and the accounts, cards, numbers, codes, holds and posts from it — is the crowd's: a streaming service's sign-in sees fifteen accounts from one VPN server in ten minutes with nobody working down a list. Together they bring a request up to a challenge and no further (short of the block, where your block threshold is your challenge threshold). Whoever passed the challenge is not asked again for the crowd's counts: the device's own — accounts, cards, numbers, posts and failed sign-ins, by the session token's visitor — stand in for the address's, and a browser working down a list past the challenge is held to them. (Codes, and holds but for one item held again as each hold lapses, a passed challenge excuses anywhere, from an address or a device, as above.) Each is still named in reasons, and where this lowered the score, ip:crowd says so. What is counted of the device, the account, the card and the user stands in full, and so does Spamhaus DROP, which is the network's. A proxy network's own addresses (Bright Data's, Oxylabs') are rented by the session, and are no crowd. The real crawler from its operator's addresses is allow, whatever its headers. A claimed crawler whose operator's addresses could not be checked in time (a reverse-DNS check on a new address can take seconds) is crawler:pending:<name>, 45 — never less than a bot nobody vouches for, so a spoofer who can make the check slow gains nothing by it — let through on its own and verified behind the response for its next request. When the operator publishes its addresses (Google, Bing, Apple, OpenAI, Perplexity), the lists have loaded and the address is not on them, it is crawler:unlisted:<name>, 65: a challenge, which a script cannot pass and the real crawler loses one request to until the reverse-DNS fallback lands. The address's geolocation is likewise waited on for at most 120 ms; past that the call goes on without it and the lookup fills the cache for the next one — an attack from a new address per request must not push every assessment past your edge's deadline. A person's navigation from a home address scores 0, with no reasons. Your request filters decide first: a filtered request is block with filter:<kind>.

An allowed request is not stored — this sits on your traffic's hot path, and an assessed request is not an identification. What was decided is counted, per hour, verdict and reason, and shown under Edge traffic in the dashboard. A challenged or blocked request is kept for seven days so the reference on its page finds it (path without the query string, User-Agent, reasons; the address only while addresses are not pseudonymized; at most 120 a minute per environment). Velocity — accounts, cards, phones, trap hits, the hourly count — keeps keyed digests, never what was sent, and sweeps them within a day. With IP pseudonymization on, the address is not looked up, as for identification.

Ingest

POST /api/v4/identify

Public key, CORS-enabled, called by the agent and the mobile SDKs. You do not normally call this yourself. It is documented because request filtering rejects at this boundary: a filtered request returns an error, is not billed, and leaves no event.

Errors

Every error is the same shape:

{ "error": { "code": "TokenNotFound", "message": "Unknown or revoked secret API key" } }

Branch on code. See Errors for the full list.

Rate limits

Identification is limited per organization and source address (120 a minute), as are the integrity key the agent fetches before it (120), challenges (60), the pulse (240) and the waiting room (300 entries, 120 polls). An address from which twenty devices or more ran the agent within the hour — a carrier's NAT, a VPN's server, an office — is many people's, each fetching a key, identifying, challenged or waiting in line: once its allowance is spent it is given as much again for every two of those devices — ten times as much at twenty, a hundred and fifty times for three hundred people in a waiting room. Over the limit returns 429 with TooManyRequests. Read endpoints and /assess are not limited by rate; a bulk export should page in turn rather than in parallel — and a search is held to that: one key runs at most four at once on a server, and a fifth sent while they run is 429 with Retry-After: 1. Paging one page after another never meets it.