Policy Rules
How to write rules that allow, block, rate-limit, or watch requests, and where the crawler allowlist fits in.
Overview
A policy rule has two parts. The matcher says when the rule applies. The directives say what the rule does. Rules live on the Policy page in the dashboard.
The validator checks your rules in priority order. For each thing a rule can set, the first rule that matches wins.
Policy runs partway through the checks. By the time your rules run, the validator has already identified any known crawler and looked up the visitor's country and network. It has not scored the request for bot behavior yet. So your rules can use what the validator knows about a crawler, and a rule can decide the request before scoring runs.
Evaluation order
Rules are sorted by priority, lowest number first. Rules with the same number keep their existing order.
For each request, the validator goes down the list. A rule can set five things: verdict, bot_detect, rate_limit, monitor, and response. The first rule to set one of these wins, and no later rule can change it.
As soon as a rule sets a verdict, the validator stops. No later rule runs, not even the built-in crawler rules below. Anything earlier rules already set stays in place.
If no rule sets a verdict, policy does not decide the request. It passes the request on to bot detection, which uses the protection level from your settings. If no rule set a verdict, bot_detect, or rate_limit, the validator turns on monitor logging for that request.
Policy never forces a blanket allow, and it never replaces your protection level with a fixed value. A rule can set just bot_detect and leave the verdict to a later rule, or to scoring.
Tip: leave gaps in your priority numbers
Numbering your rules 100, 200, 300, and so on leaves room to add new rules later without renumbering.
Crawler allowlist priority
The crawler allowlist is not a separate step that runs first. Instead, the validator adds two built-in rules for you. Both sit at the lowest priority, so they run after every rule you write:
- An identified crawler on your allowlist gets
verdict: allow. - An identified crawler not on your allowlist gets
verdict: block.
Both rules only apply to identified crawlers. Neither one fires unless the request matched a known crawler. Normal browser traffic is never affected. So by default, the allowlist decides what happens to crawlers. Allowlisted crawlers pass, and every other identified crawler is blocked.
Because the built-in rules run last, any rule of yours that sets a verdict wins over them. Say you want to let a crawler through that is not on your allowlist, or block one that is. Write your own rule with a lower priority number. (Any number works, since the built-ins sit at the highest possible number.) Your rule sets the verdict, evaluation stops, and the built-in never runs.
{
"priority": 100,
"note": "Let our partner's crawler through even though it is not allowlisted",
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "crawler.name", "op": "literal", "value": "PartnerBot" }
]
},
"set_directives": { "verdict": "allow", "bot_detect": "off" }
}Matchers
A matcher is a group of clauses. The group has a combinator (and or or) and a list of rules. Each clause in rules has a field, an operator (op), and a value. With and, every clause must match. With or, any one clause matches.
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "url", "op": "glob", "value": "/checkout/**" },
{ "field": "ua", "op": "regex", "value": "(?i)curl|wget" }
]
}The table below lists every field and the operators it accepts. You will usually build rules in the dashboard editor, but this JSON is the real shape the import and API endpoints expect, so you can copy it.
| Field | Operators | Matches |
|---|---|---|
url | literal, glob, regex | The request path. No scheme, no host. |
ua | literal, regex | The User-Agent header. |
ip | cidr, literal | The client IP. IPv4 and IPv6. |
hostname | literal, glob | The Host header. |
method | eq, in, not_in | The HTTP method. Case-insensitive. |
referer | literal, glob, regex, exists | The Referer header. |
query | exists, eq, regex | A named query-string parameter's value. |
cookie | exists, eq, regex | A named cookie's value. |
time_of_day | between | The request time inside a local-time window. |
country | eq, in, not_in | The two-letter ISO 3166-1 country of the client IP, from geo lookup. |
asn | eq, in, not_in | The autonomous-system number of the client IP. |
crawler.verified | is, is_not | Whether an identified crawler passed ownership verification. |
crawler.allowed | is, is_not | Whether an identified crawler is on your allowlist. |
crawler.name | literal, regex | The crawler name, such as Googlebot or GPTBot. |
crawler.category | eq, in, not_in | The crawler category (see below). |
country and asn only match when the IP lookup returned a value. If the lookup has no data for an IP, the clause does not match. A rule based on country or ASN skips that request rather than guess.
Crawler clauses
Every crawler.* clause is false unless the request matched a known crawler. There is no crawler.identified field, because identification is built in: match any crawler.* field and you are already requiring an identified crawler. To act only on verified crawlers, add crawler.verified with op: is and value: true.
crawler.category is one of search, seo, ai_training, ai_assistant, ai_search, ai_agent, scraper, archive, monitoring, social_media, aggregator, accessibility, advertising, feed_reader, preview, research, security, other.
Operators
Pick the simplest operator that fits the field.
literal: an exact text match. No wildcards, and uppercase and lowercase count as different.eq: a match on the whole value.methodandcountryignore case;queryandcookievalues must match exactly.in/not_in: checks whether the value is (or is not) in a list you give. Used bymethod,country,asn, andcrawler.category.is/is_not: a true/false test, forcrawler.verifiedandcrawler.allowed.exists: checks that the field is there at all.refererexists when the header is not empty;queryandcookieexist when the named parameter is set.between: fortime_of_day, a local-time window (see below).glob,regex, andcidr: pattern operators, described next.
glob (shell-style)
Available for url, hostname, and referer. Implemented with gobwas/glob. Supports:
| Token | Meaning |
|---|---|
* | Any run of characters except the path separator (/). |
** | Any run of characters including separators. |
? | Any single character. |
[abc] | Any one of the listed characters. |
{a,b,c} | Alternation. |
/api/* matches /api/users but not /api/users/42
/api/** matches /api/users and /api/users/42
/files/*.json matches /files/data.json
*.example.com matches shop.example.com and api.example.comregex (RE2)
Available for url, ua, referer, crawler.name, and the value side of query and cookie. Patterns compile with Go's regexp package, which uses RE2 syntax.
RE2 looks like Perl or PCRE, with two differences to know about:
- No backreferences (
\1,\2). - No lookaround (
(?=...),(?!...),(?<=...),(?<!...)).
In exchange, matching stays fast no matter the input. A bad pattern cannot slow the validator down the way it can with PCRE.
Regex matches are substring matches
The matcher looks for the pattern anywhere in the value. To require a full-string match, anchor with ^ and $. Without anchors, /admin as a regex matches /v2/admin/users too.
(?i)bot|crawler|spider matches anywhere in the UA, any case
^/admin(/|$) path is /admin, or starts with /admin/
^Mozilla/5\.0 \(.*Linux literal dot, parens are escaped
[a-z]{3,8} three to eight lowercase lettersTo ignore case, put (?i) at the start of the pattern. If a pattern does not compile, the dashboard rejects it when you save. If one somehow fails later, the rule is skipped.
cidr
Available for ip. Uses Go's net.ParseCIDR. IPv4 and IPv6 both work. For a single address, use literal instead.
192.168.0.0/16
10.0.0.0/8
2001:db8::/32between (time of day)
time_of_day takes a start and end time in 24-hour HH:MM form, plus a time zone (tz) like America/New_York. The window includes the start time and stops just before the end time. If start is later than end, the window runs past midnight into the next day. The value is an object, so the whole clause looks like this:
{ "field": "time_of_day", "op": "between", "value": { "start": "22:00", "end": "06:00", "tz": "America/New_York" } }Directives
A rule must set at least one directive. The first rule to set a directive wins it, and no later rule can change it.
| Directive | Values | Effect |
|---|---|---|
verdict | allow, block | Final decision. allow lets the request through. block returns a 403 (or your configured block response). Setting it stops evaluation. |
bot_detect | off, low, normal, high | How hard the validator scores for bots. off skips scoring. high is the strictest. |
rate_limit | object | Per-scope budget. Fields: max_requests, window_seconds, scope (session, ip, or session_or_ip), phase (currently only pre). |
monitor | true, false | When true, the rule logs what it would do but does not change the verdict. Use it to test new rules safely. |
response | object | Change the page and status returned on a block or challenge. Fields: block_page, block_status, challenge_page, challenge_status. |
The status fields only accept these codes: 200, 302, 401, 403, 404, 418, 429, 451, 460, or 503. The page fields name a template you have set up, not raw HTML.
Examples
Block obvious scraping clients on the checkout
{
"priority": 100,
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "url", "op": "glob", "value": "/checkout/**" },
{ "field": "ua", "op": "regex", "value": "(?i)curl|wget|python-requests|httpie" }
]
},
"set_directives": { "verdict": "block" }
}Always allow verified search engines, no scoring
{
"priority": 50,
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "crawler.verified", "op": "is", "value": true },
{ "field": "crawler.category", "op": "eq", "value": "search" }
]
},
"set_directives": {
"verdict": "allow",
"bot_detect": "off"
}
}Rate-limit the public API per IP
{
"priority": 200,
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "url", "op": "glob", "value": "/api/v1/**" }
]
},
"set_directives": {
"rate_limit": {
"max_requests": 60,
"window_seconds": 60,
"scope": "ip",
"phase": "pre"
}
}
}Turn detection up for a set of countries
{
"priority": 150,
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "country", "op": "in", "value": ["CN", "RU", "BR"] }
]
},
"set_directives": { "bot_detect": "high" }
}Shadow-test a new block rule for a week
{
"priority": 80,
"note": "Trial run for AI-agent block, 2026-05",
"when_matcher": {
"combinator": "and",
"rules": [
{ "field": "crawler.category", "op": "eq", "value": "ai_agent" }
]
},
"set_directives": { "verdict": "block", "monitor": true }
}Catch-all fallback at the bottom
A rule with an empty matcher ({}, an empty group) matches every request. Put one at the highest priority number to act as a final default. This example leaves the verdict to scoring and only sets the detection level.
{
"priority": 9999,
"when_matcher": {},
"set_directives": { "bot_detect": "normal" }
}Common mistakes
- Forgetting to anchor a regex.
/adminmatches/v2/admin/users. Use^/admin(/|$)if you mean only/adminand its sub-paths. - Treating
.as a literal dot. In regex,.is any character. Escape it as\.inside hostnames or paths. - Confusing
*and**in globs.*does not cross the/separator. Use**for nested paths. - Expecting a rule after a verdict to run. Once any rule sets
verdict, evaluation stops. Put the directives you always want (likerate_limitorbot_detect) on a higher-priority rule, or on the same rule as the verdict. - Matching a crawler without identification. Every
crawler.*clause only fires for identified crawlers. A rule keyed oncrawler.categorynever matches ordinary browser traffic. - Trying to use lookaround or backreferences. RE2 does not support them. Split the matcher into a glob plus a second clause, or rewrite the pattern.
- Combining clauses expecting OR. Clauses use the group's
combinator. Withand(the usual case), every clause must match. For OR, setcombinatortoor, use|inside a regex, or write two rules at the same priority. - Skipping
monitor: trueon first deploy. New rules can match more traffic than expected. Run them in monitor mode for a few days, watch the logs, then flipmonitoroff.