JSON payload templates and the safe GET → modify → PUT workflow for both rule types. Pair with 405-cloudflare-waf-rules.mdc § "Anti-patterns" - API-specific.
Why API authoring needs extra care:
The API supports concurrent edits but does not enforce coordination. Without If-Match ETag handling, two writers silently clobber each other. Without test-endpoint validation, you discover syntax errors at PUT time - after the destructive operation has been attempted.
Conventions:
<zone-id>,<ruleset-id>,<api-token>,<host>,<your-app>,<endpoint>,<ticket-id>are placeholders - replace before calling.- The API token needs the
Zone WAF:Editpermission scoped to the target zone. - All examples use
curl; equivalent calls in your preferred client (Pythonrequests, Nodefetch, a wrapper script) follow the same shape.
# 1. Fetch the current ruleset AND its ETag in one call
RESPONSE=$(curl -sSL -D headers.txt \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/<ruleset-id>")
ETAG=$(grep -i '^etag:' headers.txt | sed -e 's/etag: //i' -e 's/[[:space:]]*$//')
echo "Current ETag: $ETAG"
# 2. Save the current ruleset for diffing and rollback
echo "$RESPONSE" | jq '.result' > current-ruleset.json
# 3. Modify in your local editor (add your new rule to the .rules array)
cp current-ruleset.json proposed-ruleset.json
# ... edit proposed-ruleset.json ...
# 4. Validate the expression with the test endpoint BEFORE the destructive PUT
EXPR=$(jq -r '.rules[-1].expression' proposed-ruleset.json)
curl -sSL \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/json" \
-X POST "https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/test" \
--data "{\"expression\": $(jq -Rs . <<<"$EXPR")}"
# Confirm "success": true in the response. If false, fix the expression and re-test.
# 5. PUT with If-Match to detect concurrent writes
curl -sSL \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/json" \
-H "If-Match: $ETAG" \
-X PUT "https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/<ruleset-id>" \
--data @proposed-ruleset.json \
| tee response.json
# 6. Check for 412 Precondition Failed (concurrent write)
HTTP_CODE=$(jq -r '.errors[0].code // "ok"' response.json)
if [[ "$HTTP_CODE" == "412" ]]; then
echo "Concurrent write detected. Re-fetch (step 1) and re-apply intent. NEVER blind-retry."
exit 1
fi
# 7. Log the change to your run-log
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | <ticket-id> | PUT <ruleset-id> | new rule id: $(jq -r '.result.rules[-1].id' response.json)" >> waf-changes.logAppend two rules (allow + block) to the existing ruleset's rules array. Position the allow above the block using position.before.
{
"action": "skip",
"description": "<your-app>-allow-trusted-egress",
"enabled": true,
"expression": "(http.host eq \"<host>\") and (ip.geoip.country in {\"US\" \"CA\" \"GB\"} or ip.src in $trusted_egress_ips)",
"action_parameters": {
"ruleset": "current"
},
"logging": {
"enabled": true
}
}{
"action": "block",
"description": "<your-app>-block-non-allowlisted",
"enabled": true,
"expression": "http.host eq \"<host>\"",
"logging": {
"enabled": true
}
}If you're calling the per-rule create endpoint instead of PUT-ing the whole ruleset:
# Create the block rule first (no positioning needed - it goes at the end)
BLOCK_ID=$(curl -sSL \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/json" \
-X POST "https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/<ruleset-id>/rules" \
--data @block-rule.json \
| jq -r '.result.id')
# Create the allow rule with position.before pointing at the block rule's stable ID
jq --arg ref "$BLOCK_ID" '. + {position: {before: $ref}}' allow-rule.json > allow-with-position.json
curl -sSL \
-H "Authorization: Bearer <api-token>" \
-H "Content-Type: application/json" \
-X POST "https://api.cloudflare.com/client/v4/zones/<zone-id>/rulesets/<ruleset-id>/rules" \
--data @allow-with-position.jsonImportant
Never use position.index - it breaks the moment anyone else inserts a rule above. Always position by stable rule ID via position.before or position.after.
Before drafting the JSON, query Security Events on your zone:
curl -sSL \
-H "Authorization: Bearer <api-token>" \
"https://api.cloudflare.com/client/v4/zones/<zone-id>/security/events?action=block&host=<host>&period=10080" \
| jq -r '.result.events[] | select(.matched_rules != null) | .matched_rules[] | .rule_id' \
| sort -uNote every rule ID returned. Skip ONLY those, not the whole ruleset.
{
"action": "skip",
"description": "<your-app>-<endpoint>-skip",
"enabled": true,
"expression": "(http.host eq \"<host>\") and (http.request.method eq \"POST\") and (http.request.uri.path eq \"<path>\") and (any(lower(http.request.headers[\"content-type\"][*])[*] contains \"multipart/form-data\")) and (http.request.body.raw contains \"filename=\") and (any(http.request.headers[\"origin\"][*] eq \"https://<browser-host>\")) and (ip.src in $trusted_egress_ips)",
"action_parameters": {
"rules": {
"<owasp-managed-ruleset-id>": [
"<owasp-rule-id-1>",
"<owasp-rule-id-2>"
]
}
},
"position": {
"before": "<execute-rule-id>"
}
}Important
The OWASP child-rule IDs and managed-ruleset ID are placeholders. The actual IDs are zone-specific and change per ruleset version. Source them from Security Events (above) and from GET /client/v4/accounts/<account-id>/rulesets/{ruleset_id} for the managed-ruleset ID.
For every JSON file committed to the repo, create a sibling .md with the same name:
rules/
├── <your-app>-<endpoint>-skip.json # the API payload
└── <your-app>-<endpoint>-skip.md # the provenance
Keep the sibling record concise:
# <your-app>-<endpoint>-skip
- **Ticket:** <ticket-id>
- **Rule:** <zone-name> (<zone-id>) / <phase> / <ruleset-id>
- **Scope:** <host> / <path> / <method> / <source>
- **Action:** skip <id-1>, <id-2>
- **Evidence:** <Security Events date/filter and Ray ID when available>
- **Non-obvious decision:** <only when a risk, exception, or unusual scope needs explanation>
- **Soak plan:** Log mode for <N> hours via a parallel `action: "log"` rule with the same expression; promoted to `skip` after validation.
- **API call:** `PUT /zones/<zone-id>/rulesets/<ruleset-id>` on <YYYY-MM-DDTHH:MM:SSZ>
- **Run-log entry:** see `waf-changes.log` line <N>
- **Expiry/review:** <temporary rules only>PR review of the JSON + .md together enforces the provenance discipline.
From 405-cloudflare-waf-rules.mdc § "Anti-patterns" - API-specific:
- Partial
PATCHwithout fetching current state first (race conditions with concurrent edits). - Hard-coding
position.indexinstead ofposition.before/position.afterwith stable rule IDs. - Calling the API without recording the change in a versioned JSON repo or run-log.
- Validating the JSON payload only at
PUTtime (use the expression test endpoint first). - Skipping ETag /
If-Matchon writes (lets concurrent writers silently clobber). - Blind-retrying on 412 Precondition Failed (always re-fetch and re-apply intent).