Add Rule to Traffic Policy

POST /v1/endpoints/{uuid}/policies/{policy_uuid}/rules

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 string (uuid) Req
The endpoint's uuid from /endpoints.
policy_uuid string (uuid) Req
The policy's 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

phase string Req
When the rule runs. One of: on_tcp_connect, on_udp_connect, on_http_request, on_http_response.
rule_type string Req
What the rule does. See /traffic-policy/rule-types for the catalog. One of (hyphenated): 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.
name string Req
Free-form label.
expression string
Optional CEL filter. The rule only fires when the expression evaluates truthy. Variables: 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.
config object
Rule-type-specific JSON. Schema depends on 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.
priority int
Within-policy ordering (lower runs first).
enabled boolean
Whether the rule is active.

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

400 Bad Request
Strict decoding rejected unknown fields, invalid 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).
400 masked secret
A 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).
401 Unauthorized
Missing auth.
403 Forbidden
Plan doesn't include the entitlement for this rule_type.
404 Not Found
Endpoint doesn't exist (Endpoint not found), or the policy isn't one of this endpoint's (Policy not found).
Request — rate limit
curl -X POST "https://api.ngris.com/v1/endpoints/019bb1e4-e785-77d2-9966-c3cff31d0a0e/policies/019b74e7-6fe8-7784-9d8a-9b7f9cb99a89/rules" \ -H "X-API-KEY: <your_api_key>" \ -H "Content-Type: application/json" \ -d '{ "phase": "on_http_request", "rule_type": "rate-limit", "name": "100rps-per-ip", "config": {"limit": 100, "window": 1, "scope": "ip"}, "priority": 0, "enabled": true }'
Request — redirect /old to /new
curl -X POST "https://api.ngris.com/v1/endpoints/019bb1e4-e785-77d2-9966-c3cff31d0a0e/policies/019b74e7-6fe8-7784-9d8a-9b7f9cb99a89/rules" \ -H "X-API-KEY: <your_api_key>" \ -H "Content-Type: application/json" \ -d '{ "phase": "on_http_request", "rule_type": "redirect", "name": "old-to-new", "expression": "req.path.startsWith(\"/old/\")", "config": {"url": "/new/", "status": 301}, "priority": 5, "enabled": true }'
Python
from ngris import Ngris client = Ngris(api_key="<your_api_key>") rule = client.traffic_policies.create_rule( "019bb1e4-e785-77d2-9966-c3cff31d0a0e", "019b74e7-6fe8-7784-9d8a-9b7f9cb99a89", { "phase": "on_http_request", "rule_type": "rate-limit", "name": "100rps-per-ip", "config": {"limit": 100, "window": 1, "scope": "ip"}, "priority": 0, "enabled": True, }, )
Response — 201 Created
{ "uuid": "019b7fe5-336a-7789-aeab-182ddfcd5711", "policy_uuid": "019b74e7-6fe8-7784-9d8a-9b7f9cb99a89", "phase": "on_http_request", "rule_type": "rate-limit", "name": "100rps-per-ip", "config": {"limit": 100, "window": 1, "scope": "ip"}, "priority": 0, "enabled": true, "created_at": "2026-04-29T13:00:00Z", "updated_at": "2026-04-29T13:00:00Z" }
Was this helpful?
Was this helpful?