← All docs

Rules

Signals are only useful once someone turns them into a decision. Without somewhere to express that, every integrator reimplements the same thresholds in their own backend and cannot change one without a deploy.

A rule is a condition, an action and a priority. Every identification returns a decision alongside the signals.

{ "decision": { "action": "challenge", "matched": ["High risk checkout"] } }

Actions

allow Explicitly fine. Use above a broad block to carve out an exception.
flag Record it, do nothing.
challenge Step up: 2FA, a captcha, manual review.
block Refuse.

The engine recommends; it does not enforce. LayerSift never blocks your visitor. Only you know what the request was trying to do.

Order

Every enabled rule is evaluated, so the dashboard can show all matches, but the first match in priority order decides. That is the firewall model: an explicit allow placed above a broad block carves out an exception without restructuring the reasoning. A flag never decides while a rule below it that acts matches too: it records and the evaluation goes on, so a new rule can be tried out as a flag at the top without switching off the rules under it.

A rule that no longer parses is skipped rather than failing the request. Paused rules are skipped entirely. An environment holds at most 500 rules.

A rule saved, paused or deleted applies within five seconds: each server keeps an environment's rules, as it keeps its request filters, threat feed and webhooks, that long between reads, and the server that made the change reads them again at once.

Expression language

Conditions are parsed, never evaluated as code — a real tokenizer, parser and tree-walking evaluator, because a rule is untrusted input stored in a database. Path resolution reads own properties only, so an expression can see the event it is evaluating and nothing else. constructor and __proto__ cannot be reached. Regex patterns are length-capped and fail closed.

Operators

Comparison == != > >= < <=
Logic and or not
Membership in, not in
Strings contains, startsWith, endsWith, matches
Address ranges inCidr — ip inCidr ["198.51.100.0/22", "2001:db8::/32"], one range or a list. IPv4 and IPv6, a dual-stack client's ::ffff: address counted as the IPv4 it is; a range that cannot be read is refused when the rule is saved. Negate with not ip inCidr …
Literals numbers, "strings", true, false, null, [lists]

Numeric strings compare as numbers, so "70" >= 70 holds; an empty string is not a number. null is a value that is not there, missing and set to null alike: products.x.score == null holds for both, and a missing score is not < 30 (nor >= 30) — a signal that did not run never matches an ordering. ==, != and in compare exactly, case and all; contains, startsWith, endsWith and matches ignore case.

A comparison that can never be true is refused when the rule is saved, rather than matching nothing on every request afterwards: in takes a list, == and the string operators one value, < and the rest a number, and a list at least one value (in [] is never true).

matches takes a regular expression in the part of JavaScript's syntax that can be matched in time linear in the text: literals and escapes, ., classes ([a-z], [^/], \d \w \s and their negations), \b, ^ and $, groups ((…), (?:…)), |, and the quantifiers * + ? {n} {n,} {n,m} (repeat counts up to 100). The text is a visitor's, so no pattern may make it expensive: backreferences and lookarounds, which need backtracking, are refused when the rule is saved, and a pattern is matched against the first 4,096 characters of the text and, of a longer one, its last 4,096 — a path padded past the first still has its ending read, and $ holds only where the text itself ends, never where a reading stops. Case is ignored. A pattern is written in a string: a backslash it needs is kept, written once or twice ("\.env$" and "\\.env$" are the same pattern); in the string itself only a quote or a backslash is escaped. And the patterns of all an environment's rules share one budget per request — ten times what fifty pattern rules over a real User-Agent, path and query spend, every step of every pattern counted. A rule whose patterns would run past it is not read for that request, rather than slow every request on the servers every customer shares. The text is the visitor's, though, and a long one against an early costly rule could leave a block rule after it unread: so a challenge or block rule left unread makes the request a challenge at least (a block where a block matched after it; an allow that matched before it still decides), never less than its score, with rule-unread:<name> among its reasons. Trying a condition on recent events reads each within the same budget and says how many it could not.

Paths

Everything under products from the event, plus the top-level fields.

products.bot.result
products.vpn.result
products.ip_info.countryCode
products.mobile.root_apps.result
velocity.distinct_ip.1h
suspectScore
confidence
linkedId

confidence is the event's confidence.score, the number itself. A path that an event lacks is falsy rather than an error, so a rule written for a browser signal does not explode on a mobile event. A path that no event has — a misspelling, or a field of the other kind of rule — is refused when the rule is saved, with the nearest field there is: it would parse, and never fire. So is a group of fields used as a value — products.vpn, ipInfo: it is an object on every event that has it, so products.vpn meant as "a VPN was seen" would match them all. Name one of its fields, products.vpn.result.

Reasons are arrays

Most signals report why they fired as a list — products.tampering.reasons, products.network.reasons, products.bot.reasons. Two operators reach into one:

reasons contains "engineNewerThanClaimed" An element equal to the string, exactly. engineNewerThanClaimedAndMore does not match.
reasons matches "engineFamilyContradictsUa" The list is tested as its comma-joined text, so a pattern catches a family of reasons — here engineFamilyContradictsUa:claims-chromium-runs-gecko and every other variant, whatever follows the colon. `

Use contains when you mean one reason and matches when the reason carries a parameter. A bare list in a condition (products.tampering.reasons) is true when it is not empty. Numbers inside a signal are comparable like any other path:

products.tampering.engine_version.claimed < 120
  and products.tampering.engine_version.at_least >= 140

Examples

products.bot.result == "bad" or suspectScore >= 70

A browser whose engine is not what its user agent says — the version, or the whole family (a Gecko build presenting itself as Chrome):

products.tampering.reasons matches "engine(FamilyContradictsUa|NewerThanClaimed|OlderThanClaimed)"

A stealth build that patched navigator and left another scope telling the truth — the rule to put above the broad ones, since it is nearly conclusive:

products.tampering.anti_detect_browser

Narrower still: a value the claimed browser produces by construction, replaced — WebGL strings from another platform's driver, a decoy brand from an older Chrome. No honest browser does either:

products.tampering.forged_identity > 0

An identification that did not bring a browser's answer to its integrity key's program — unsigned, signed by a build no one knows, the program unrun, unpaid or answered as no browser does. Attack mode holds identifications to this on its own; as a challenge rule, it holds the environment to it always, the way the strictest vendors require a solved challenge token — where the edge or middleware steps receipts up (ANTIBOT_ENFORCE_STEP_UP=hold-all, stepUp: "hold-all"), so that the receipt such an identification earns meets the hold; elsewhere it says challenge, and acting on it is the site's. A field that is not there is not "ok", so a payload with no program at all matches too:

products.payload_integrity.program != "ok"

Client Hints copied from a different Chrome or a different operating system. Note it names the contradiction, not missingClientHints: a privacy extension suppresses the hints for an honest browser, and one missing observation is not a reason to challenge anyone:

products.network.reasons matches "clientHintsContradictUa"

A browser the edge timed further away than the connection that reached it — a proxy that terminates TCP between them, a residential pool's exit. Read from a tab's second page on, and only behind a proxy that reports the connection's round trip (the browser's distance):

products.proxy.methods.relay_latency

A driver's hands: a field's text arriving whole with no key pressed for it (fill()), every click reached in one jump of the pointer (a driver's mouse.move, a computer-use agent's xdotool), and — where the edge places the agent — every page of ten minutes reached as goto() reaches one. Each a challenge, as their templates are:

products.behaviour.reasons contains "textInsertedWhole"
  or products.behaviour.reasons contains "jumpedClicks"
  or products.behaviour.reasons contains "navigationsTyped"
products.ip_info.countryCode not in ["GB", "IE"] and velocity.distinct_ip.1h > 5
products.mobile.root_apps.result or products.mobile.frida.result
products.ai_agent.result and not products.ai_agent.verified
  and products.ai_agent.kind == "agent"
products.remote_control.result and products.remote_control.confidence == "high"
  and suspectScore >= 30

Rules for requests without the agent

A rule applies to identifications (the default — the agent ran) or to requests without the agent: what the assess call judges, at the edge or from your server. The two are written against different fields, and every existing rule stays an identification rule. An assessment rule sees:

Field
path, method, host, query, url The request
userAgent, headers.<name> Its headers, names lowercase (cookies and authorization are never there)
ip, ipInfo.countryCode, ipInfo.asn, ipInfo.asnOrganization, ipInfo.datacenter, ipInfo.vpn, ipInfo.tor Where it came from
ipInfo.crowd The address is many people's: a privacy service's exit (a VPN's, a relay's, Tor's), or one twenty devices ran the agent from in the last hour — a carrier's, an office's. The scoring weighs what is counted of such an address as everyone's (shared addresses); a rule on the address's counts — velocity.accountsFromAddress, card.cardsFromAddress — can do the same with not ipInfo.crowd, beside the device's own
score, action, reasons What the documented scoring reached, before rules
bot.result, bot.type, crawler.status, crawler.bot What it is
crawler.category, crawler.name, crawler.operator What a crawler the User-Agent names is for: search, social, seo, monitoring, aiTraining, aiSearch, aiAssistant — crawlers by purpose
reputation none, low, medium, high — network reputation
trap.followed The address requested a trap link in the last six hours
rate.hourly Requests assessed from the address in the last hour without a valid receipt
rate.devicesFromAddress Devices that ran the agent from the address in the last hour, paid its proof of work and were not convicted, in this environment — a household's few, a carrier's NAT's or an office's many. From twenty the address is weighed as a crowd's, as a VPN's exit is (assessing a request)
rate.pathPerMinute Requests assessed from the address to this one path (case-blind, a trailing / aside) in the last minute without a valid receipt, counted across instances — a scraper walking a search or a product listing: rate.pathPerMinute > 60
rate.pagesWithoutAgent Where the agent is on every page — placed by the edge (ANTIBOT_AGENT_INJECT=on on Cloudflare, Azure or Fastly), or said by agentOnPages: pages this client — its address, User-Agent and languages, read as a set of tags — asked for in the last ten minutes without the receipt the agent writes
network What per-address counts are keyed on: the address for IPv4, its /64 (2001:db8:1:2::/64) for IPv6
now The time, in milliseconds since 1970 — for a rule that should stop matching (now < 1793000000000)
velocity.accountsFromAddress, velocity.accountsFromNetwork (across an IPv6 source's /48; 0 for IPv4), velocity.addressesForAccount, velocity.failuresFromAddress, velocity.successesFromAddress, velocity.failuresForAccount, velocity.variantsOfAccount, velocity.accountsFromDevice, velocity.devicesForAccount, velocity.failuresFromDevice, velocity.successesFromDevice, velocity.newDevice, velocity.newCountry For requests that named an account; the device fields need a valid receipt, the new ones reported sign-in outcomes
email.present, email.domain, email.disposable, email.freeProvider, email.subaddressed, email.generated, email.relay When that account is an email address — what the address says
card.present, card.cardsFromAddress, card.cardsFromNetwork, card.cardsFromDevice, card.addressesForCard, card.declinesFromAddress, card.approvalsFromAddress, card.declinesFromDevice, card.approvalsFromDevice, card.declinesForCard For payments that named a card — protecting checkout
user.present, user.addresses, user.requests, user.countries For requests that named the signed-in user: its distinct addresses and requests in the last hour, and the countries it was placed in within the last half hour (by addresses that say where a person is — never a VPN's, a relay's, a datacenter's or a Tor exit's): user.countries >= 2 before a payment or a change of email is where to ask for the password again (the template Flag a session in two countries at once)
userSession.present, userSession.known, userSession.otherDevice, userSession.otherClient, userSession.ageMs For requests that sent their signed-in session (userSession, a digest of your session cookie): whether it was bound before, whether it is used from another device than it was bound to (both with a receipt) or another browser, system or kind of device, and how long since it was bound — its cookie stolen and replayed elsewhere (the template Ask again when a session moves to another device)
content.present, content.words, content.links, content.linkHosts, content.sendersOfContent, content.postsFromAddress, content.postsFromDevice, content.postsFromNetwork, content.reportedSpam, content.approved For submissions that sent content — protecting forms
agent.signed, agent.verified, agent.signer, agent.host, agent.operator, agent.name, agent.intent The AI agent that signed the request (Web Bot Auth): agent.verified and agent.operator == "OpenAI" is ChatGPT agent, proved by its key. verified is proved by keys someone vouches for — a known operator's directory (OpenAI's, Google's, DuckDuckGo's) or a payment network's registry — never only that the signature checks out: anyone can publish keys and sign. signer is the directory whose keys made a valid signature, vouched for or not: agent.signer == "agents.partner.example" trusts a partner of yours by name. A shopping agent a card network registered (Trusted Agent Protocol) has the network as operator and intent "browse" or "pay": agent.verified and agent.intent == "pay" at checkout is one paying for a person (the template Let shopping agents a card network vouches for pay, while what it does there scores under 50); intent is empty unless verified
attack.on The environment is under attack mode
form.unread account, code or content when an edge could not read a configured form's field
code.present, code.codesFromAddress, code.codesFromNetwork, code.codesFromDevice, code.addressesForCode, code.failuresFromAddress, code.successesFromAddress, code.failuresFromDevice, code.failuresForCode For redemptions that named a code — protecting gift cards
flow.present, flow.name, flow.step, flow.sincePreviousMs, flow.skipped, flow.rushed, flow.rushedSteps For a step of one of your flows (flow): its name as sent (lower-cased), the step, the milliseconds since the step before (an hour when there was none: test flow.skipped beside a rule that reads a long gap as a person's), and whether it was skipped or taken too soon — protecting checkout
graphql.present, graphql.documents, graphql.persisted, graphql.persistedRepeated, graphql.unread, graphql.operations, graphql.mutations, graphql.names, graphql.fields, graphql.mutationFields, graphql.repeated, graphql.repeatedField, graphql.mutationRepeated, graphql.mutationSelections, graphql.depth, graphql.introspection, graphql.malformed For requests that sent their GraphQL (graphql): the documents the request carried, how many were persisted queries whose text was not known (and the most times one came) and how many could not be read through, the operations that would run and how many were mutations, their names and the fields at their top — all of them, and mutations' alone (lists: graphql.mutationFields contains "login") — the most times one field repeats in the request (by aliases, by the batch) and of mutations alone, the mutation fields selected in all, how deep it selects as a server runs it (fields' levels: a fragment counted where it is spread, an inline fragment no level, an input object in an argument — a where: { … } — no level), whether it asks for the schema, and whether a document was one a server refuses (templates Stop sign-ins batched into one GraphQL request, Refuse GraphQL introspection from outside)
hold.present, hold.unitsFromAddress, hold.purchasedFromAddress, hold.itemsFromAddress, hold.unitsFromDevice, hold.purchasedFromDevice, hold.unitsFromNetwork, hold.purchasedFromNetwork, hold.itemHoldsFromAddress, hold.itemLapsesFromAddress, hold.itemPurchasedFromAddress, hold.itemHoldsFromDevice, hold.itemLapsesFromDevice, hold.itemPurchasedFromDevice, hold.unitsForItem, hold.purchasedForItem, hold.holdersForItem For holds that named a hold — protecting stock from being held
shipping.present, shipping.country, shipping.countryMatchesIp, shipping.accountsForDestination, shipping.cardsForDestination, shipping.devicesForDestination, shipping.addressesForDestination, shipping.variantsForDestination For orders that named a shipping address — protecting limited releases. countryMatchesIp is true when either country is unknown
phone.present, phone.callingCode, phone.nonGeographic, phone.phonesFromAddress, phone.phonesFromNetwork, phone.phonesFromDevice, phone.addressesForPhone, phone.sendsToPhone, phone.numbersInRange, phone.verifiedInRange For SMS sends that named a phone — protecting text messages
session.present, session.valid, session.challenged, session.reason The receipt it carried, if any; challenged when it was earned by passing a challenge rather than by identifying
session.visitorId The device a valid receipt names ("" without one): what letting one device through matches, where its address is shared
session.silent, session.pulses A browser's receipt more than five minutes old whose page never pulsed (session:silent, reported and never weighed): the agent stopped running where it was minted, or a script carries it — and a fast clicker whose pages each close before their first beat, or a blocked beacon, looks the same. pulses is how many beats its page sent, -1 where they were not asked (a younger receipt, a phone's, none)
path startsWith "/checkout" and bot.result == "bad"
ip inCidr ["198.51.100.0/24"] and path startsWith "/partner-api"
path == "/login" and not ipInfo.crowd and not session.challenged and velocity.failuresFromAddress >= 10 and velocity.successesFromAddress <= 1
path contains "signup" and not session.challenged and (email.disposable or velocity.variantsOfAccount >= 3)
path == "/trial" and email.freeProvider
card.present and (card.cardsFromDevice >= 5 or (not ipInfo.crowd and not session.challenged and card.cardsFromAddress >= 5))
phone.present and phone.callingCode not in ["1", "44"]
crawler.category == "aiTraining"
ipInfo.countryCode in ["KP", "IR"] and path startsWith "/account"

The first matching rule decides, as for identifications: block and challenge act, allow lets the request through whatever the score was — an allow first is how a partner's script or an uptime monitor is let in — and flag only reports, and lets the next match decide. Every match is added to the answer's reasons as rule:<name> and counted on the rule. Your filters still run first. A rule's verdict is your policy, not evidence: it never feeds network reputation. Assessed requests are counted, not kept, so the dry run only checks that the condition parses; Edge traffic shows what a rule matched once it is live.

Writing one

Rules → New rule. Start from a template if one fits. Before saving, use Dry run against recent events — it evaluates your condition against real traffic and reports how many of the last events would have matched. A rule that matches everything or nothing is usually a mistake, and this is where you find that out rather than in production.

An invalid condition is rejected at write time, not silently never fired — one that does not parse, and one that names a field no event of its kind has.

Acting on the decision

const event = await antibot.getEvent(requestId)

switch (event.decision?.action) {
  case "block":     return deny()
  case "challenge": return stepUp()
  case "flag":      log(event); return allow()
  default:          return allow()
}

Read the decision from your server, not from the browser result. A decision the client hands you is a decision the client can edit.