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.
Related visitors
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 withANTIBOT_BLOCKLISTS_DIRthe 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 asgenerated.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.
Report a trap link
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.