Add Rule to Traffic Policy
Append a rule to an existing traffic policy. A rule has a phase (when it runs), a rule_type (what it does), an optional CEL expression (a filter — when it should fire), and a config object whose schema depends on the rule type.
Per-rule-type plan gating runs on this endpoint: even if your plan allows traffic policies, individual rule types can still be switched off for a plan — each rule type is gated by its own traffic_rule:<rule_type> entitlement (a 403 is returned when the plan lacks it). Update calls don't re-check this per-type gate so admins can keep editing rules of types they no longer have access to.
Body is strict-decoded + the rule's config is validated against a per-type schema and run through an AI safety check (for rule types that serve user-controlled content).
Path parameters
uuid from /endpoints.uuid from the policy list. Resolved under this endpoint only.Deprecated: the legacy integer id is still accepted in the path until the sunset, answered with Deprecation and Sunset headers. Use the uuid.
Request body
on_tcp_connect, on_udp_connect, on_http_request, on_http_response.restrict-ips, rate-limit, add-headers, remove-headers, url-rewrite, redirect, custom-response, require-client-cert, jwt-validation, verify-webhook, force-https, webhook, custom-error, circuit-breaker, api-key-validation, cache-response, validate-body, cors, set-vars, replace-body, compress-response, image-optimization, waf, bot-management, challenge, edge-function. Note on phases: restrict-ips and rate-limit are valid in the on_tcp_connect/on_udp_connect phases; cors, set-vars, waf, bot-management and challenge must be in on_http_request; custom-error, replace-body, compress-response and image-optimization only in on_http_response; the rest in on_http_request.req.method, req.path, req.host, req.headers["x-…"], req.cookies["…"], req.query_params["…"], conn.client_ip, conn.ja3 (client TLS JA3 fingerprint), and vars.<name> (values set by earlier set-vars rules). Examples: req.method == "POST", req.path.startsWith("/api/"), vars.tier == "gold". Absent keys read "". Leave empty to always fire.rule_type — see rule-types catalog. Examples:
•
rate-limit: {"limit": int, "window": int (sec), "scope": "ip"|"endpoint"|"global"|"header"|"cookie"|"jwt", "key_name": string (required for header/cookie/jwt)}. The header/cookie/jwt scopes key on client-controlled values (JWT claim read without verifying the signature) — fair-sharing, not abuse protection; pair with an ip rule as the hard ceiling.
•
url-rewrite: {"pattern": regex, "replacement": string, "target": "path"|"query"|"host"|"path_query"} (target defaults to path).
•
cors: {"allow_origins": ["https://app.example.com"|"*"], "allow_methods": [...], "allow_headers": [...], "expose_headers": [...], "allow_credentials": bool, "max_age": int}. "*" may not be combined with allow_credentials.
•
set-vars: {"vars": {"tier": "req.headers[\"x-tier\"]", "is_admin": "req.path.startsWith(\"/admin\")"}} — each value is a CEL string expression; results are readable by later rules as vars.<name> and ${vars.<name>}.
•
replace-body (response phase): {"find": string, "replace": string, "regex": bool, "max_body": int (default 1 MiB)}. Applies to complete, uncompressed bodies only.
•
compress-response (response phase): {"min_size": int (default 1024), "content_types": ["text/","application/json"]}. Negotiates br (preferred) or gzip from Accept-Encoding.
•
image-optimization (response phase): {"default_quality": int 1-100 (default 82), "max_width": int px (0 = system max, never upscales), "allowed_formats": ["webp"|"avif"|"jpeg"|"png"], "disable_auto_format": bool, "cache_ttl": int sec (default 86400)}. Resizes + re-encodes image responses, driven per-request by ?w=/?width=, ?q=/?quality=, ?format= plus Accept auto-negotiation (WebP/AVIF); each variant is edge-cached. Works on agent tunnels and hosted apps.
•
waf (request phase): {"mode": "detect"|"block", "ruleset": "core"|"custom"|"core+custom", "custom_rules": string, "inspect_body": bool, "max_body_kb": int (default 128, max 1024)}. OWASP-style inspection via Coraza; mode defaults to detect (log only). core = built-in baseline (SQLi/XSS/traversal/scanners); custom supplies your own SecLang.
•
bot-management (request phase): {"mode": "detect"|"block", "ja3_denylist": ["<32-hex JA3>"], "ja3_allowlist": [...], "block_missing": bool, "message": string}. Matches the client's TLS JA3 fingerprint; mode defaults to detect. Denylist blocks listed fingerprints; a non-empty allowlist blocks everything NOT listed.
•
challenge (request phase): {"difficulty": "low"|"medium"|"high", "clearance_ttl": 300-86400, "bind_ip": bool, "skip_paths": ["/api/"], "allow_ips": ["203.0.113.0/24"], "message": string, "interaction": "automatic"|"checkbox"|"puzzle", "allow_verified_bots": bool, "mode": "always"|"smart", "pass_score": 1-99 (default 70), "block_score": 1-99 (default 10, below pass_score), "clearance_max_rpm": int (default 600; 0 = default)}. Visitors' browsers solve an automatic JavaScript check before reaching the endpoint, then get a clearance cookie (default 3600 s). Requests that can't show the check page and have no clearance get 403 with X-Ngris-Challenge: required, so list API and webhook prefixes in skip_paths. Verified search engines and AI crawlers (Google, Bing, Apple, DuckDuckGo, OpenAI, Perplexity, from the IP addresses each operator publishes) pass unless allow_verified_bots is false (default true). With mode smart only suspicious visitors are checked: a bot score at or above pass_score passes, below block_score gets 403 (never a verified crawler), the rest are checked. A visitor sending more than clearance_max_rpm requests a minute is checked again, more strongly. See Challenge.
•
redirect: {"url": string, "status": 301|302|307|308, "pattern": string (optional regex)}; restrict-ips: {"allow": [cidr...], "deny": [cidr...]}.
•
verify-webhook: {"provider": "github"|"slack"|"stripe"|"generic", "secret": string (required), "signature_header": string, "algorithm": "sha256"|"sha1", "signature_prefix": string}; jwt-validation: {"algorithm": string, "token_location": "header"|"cookie"|"query", "token_name": string, "token_prefix": string, "secret": string (HS* only), "jwks_url": string (RS*/ES*), "issuer": string, "audience": string, "required_claims": [...]}. Their secret is write-only: every response answers it as "****". A new rule needs the secret itself: **** is refused (see Errors).
Values in
add-headers, redirect and custom-response support ${cel-expression} interpolation (e.g. ${vars.tier}, ${conn.client_ip}), CRLF-sanitized.Response (201 Created)
The newly-created rule with its assigned uuid, its policy_uuid and the resolved config (a secret answers "****").
Deprecated: until the sunset this response also carries the legacy integer id and *_id fields. Don't use them; read uuid and the *_uuid fields.
Errors
phase, invalid rule_type, missing name, schema-invalid config, or AI safety check flagged user-controlled content. An integer policy id after the sunset (integer_id_unsupported).verify-webhook or jwt-validation rule's config.secret is ****: the placeholder a read answers for a saved secret, never a secret, and a new rule has none. Keyed to config.secret in fields: "****" is the masked placeholder of a saved secret, not a secret: send the secret (sending "****" keeps a saved secret only on an update of the same rule with the same rule_type).rule_type.Endpoint not found), or the policy isn't one of this endpoint's (Policy not found).