diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index d0cadd9..c831208 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -5,13 +5,13 @@
"url": "https://github.com/crowdsecurity"
},
"metadata": {
- "description": "CrowdSec skills and tooling for Claude Code — operational automation for installing, configuring, and debugging CrowdSec."
+ "description": "CrowdSec skills and tooling for Claude Code — operational automation for installing, configuring, and debugging CrowdSec, plus a Service API skill for the premium Console cloud API."
},
"plugins": [
{
"name": "crowdsec",
"source": "./",
- "description": "Operational skill for installing, configuring, operating, and debugging CrowdSec (cscli, LAPI/CAPI, hub, bouncers, WAF/AppSec) across bare-metal, Docker, and Kubernetes.",
+ "description": "CrowdSec skills for Claude Code: an operational skill (engine, cscli, hub, bouncers, WAF/AppSec across bare-metal, Docker, Kubernetes) and a Service API skill (premium Console cloud API — blocklists, allowlists, firewall integrations, metrics, decisions).",
"version": "0.2.3",
"author": {
"name": "CrowdSec",
@@ -30,7 +30,12 @@
"bouncer",
"fail2ban",
"devops",
- "sre"
+ "sre",
+ "console",
+ "service-api",
+ "sapi",
+ "blocklist",
+ "integration"
]
}
]
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index cd1d0d0..da7f746 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -2,9 +2,10 @@
"name": "crowdsec",
"version": "0.2.3",
"skills": [
- "./skills/crowdsec"
+ "./skills/crowdsec",
+ "./skills/crowdsec-service-api"
],
- "description": "Operational skill for installing, configuring, operating, and debugging CrowdSec (cscli, LAPI/CAPI, hub, bouncers, WAF/AppSec) across bare-metal, Docker, and Kubernetes.",
+ "description": "CrowdSec skills for Claude Code: an operational skill (install/configure/operate/debug the engine, cscli, bouncers, WAF across bare-metal/Docker/Kubernetes) and a Service API skill (drive the premium Console cloud API — blocklists, allowlists, firewall integrations, metrics, decisions).",
"author": {
"name": "CrowdSec",
"url": "https://github.com/crowdsecurity"
@@ -18,6 +19,12 @@
"waf",
"appsec",
"bouncer",
- "fail2ban"
+ "fail2ban",
+ "console",
+ "service-api",
+ "sapi",
+ "blocklist",
+ "integration",
+ "cloud"
]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index ece1767..2e01d92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,6 +33,13 @@ If you are uncertain about any fact, statistic, date, or piece of technical info
## Content structure
+The plugin ships **two skills** under `skills/`: `crowdsec` (operational — local
+engine/cscli/bouncers/WAF) and `crowdsec-service-api` (the premium Console cloud REST API). Each has
+its own router `SKILL.md` and `references/`. The layout below describes the `crowdsec` skill; the
+`crowdsec-service-api` layout follows in its own subsection.
+
+### `crowdsec` skill layout
+
`SKILL.md` is the router — a symptom/intent-indexed table that points into `references/`.
All depth lives in `references//`, organized by the axis that fits the area:
@@ -61,6 +68,31 @@ All depth lives in `references//`, organized by the axis that fits the are
area's organizing axis — update the table above in the *same* change. This section is the
authoritative map of the layout; let it drift and it stops being trustworthy.
+### `crowdsec-service-api` skill layout
+
+`SKILL.md` is the router **plus** the "acting on the user's behalf" operating contract (key
+resolution, `/info` validation, read-vs-mutate confirm gate). `references/` is organized by **API
+resource group** — the axis that fits a REST API — one file each:
+
+| File | Covers |
+|---|---|
+| `authentication.md` | Key creation, `x-api-key`, key resolution (env → `~/.config/crowdsec/sapi_key`), `/info`, Python SDK pointer. |
+| `blocklists.md` | CRUD, add/remove/bulk IPs + expiration, download, subscribers, shares, search. |
+| `allowlists.md` | CRUD, items + expiration, subscribers (cross-links the `crowdsec` skill's *local* allowlists). |
+| `integrations.md` | Firewall/appliance feeds: create (entity_type/output_format), Basic-auth content pull + pagination, decisions stream, vendor table. |
+| `decisions.md` | Org-level decisions + aggregated (short; prefer blocklists). |
+| `metrics.md` | `/metrics/remediation` ROI (raw vs computed). |
+
+CTI (the tracker surface — cves/vendors/fingerprints/tags/products/tracker-*) is **deferred to a
+future iteration**: it needs a CTI-scoped key (a blocklist/decision key gets `403`). When added,
+restore a `cti.md` row here.
+
+Conventions differ from the `crowdsec` skill in two ways: **no platform axis** (the "environment" is
+HTTPS, not systemd/docker/k8s — so no per-platform prefixes), and recipes are `curl` the skill runs
+on the user's behalf, every mutating one gated behind explicit confirmation. Verification uses
+`env: sapi` in the `verified:` block. When you add/rename a resource file here, update this table in
+the same change.
+
## Testing
- **Nothing ships unverified.** Every command and every expected outcome must have been
diff --git a/README.md b/README.md
index 1b2419d..c899900 100644
--- a/README.md
+++ b/README.md
@@ -15,15 +15,22 @@
---
-This is an [Agent Skill](https://docs.claude.com/en/docs/claude-code/skills) that turns Claude/Codex/... into a
-hands-on CrowdSec operator. Ask it to stand up an engine, wire a bouncer, enable
-the WAF, or figure out why nothing's getting blocked — it knows the `cscli`
-commands, the config layout, the failure modes, and the safe way through each of
-them across **bare-metal/systemd, Docker, OpnSense and Kubernetes/Helm**.
+This plugin bundles **two [Agent Skills](https://docs.claude.com/en/docs/claude-code/skills)**:
+
+- **`crowdsec`** — a hands-on CrowdSec operator. Stand up an engine, wire a
+ bouncer, enable the WAF, or figure out why nothing's getting blocked. It knows
+ the `cscli` commands, the config layout, the failure modes, and the safe way
+ through each across **bare-metal/systemd, Docker, OpnSense and Kubernetes/Helm**.
+- **`crowdsec-service-api`** — drives the premium **Console Service API** (cloud)
+ on your behalf with your API key: create and populate blocklists/allowlists,
+ wire firewall/appliance integrations, pull remediation ROI metrics, and manage
+ org-level decisions — every state change gated behind an explicit confirmation.
## What it covers
+**`crowdsec` (operational):**
+
| Area | Covered |
|---|---|
| **Install** | bare-metal/systemd · Docker · Kubernetes/Helm · OpnSense · Console enrollment |
@@ -34,6 +41,16 @@ them across **bare-metal/systemd, Docker, OpnSense and Kubernetes/Helm**.
| **Operate** | health checks & smoke tests · upgrades & rollback · multi-server / remote LAPI / mTLS |
| **Debug** | logs not parsing · no alerts firing · bouncer not blocking · specific errors |
+**`crowdsec-service-api` (premium cloud API):**
+
+| Area | Covered |
+|---|---|
+| **Blocklists** | create · add/remove/bulk IPs (with expiry) · download · share across orgs · subscribe engines/bouncers |
+| **Allowlists** | create · items with expiry · subscribe by engine/tag/org |
+| **Integrations** | firewall/appliance feeds (Palo Alto, Fortinet, Cisco, F5, Sophos, pfSense/OPNsense…) · paginated Basic-auth content pull |
+| **Metrics** | remediation ROI (traffic dropped, bytes/egress saved, attacks prevented) |
+| **Decisions** | org-level decisions + aggregated (read/manage) |
+
## 🚀 Install
The skill loads automatically once installed. Just talk to
@@ -80,6 +97,9 @@ Once installed, Claude picks the skill up whenever your prompt involves CrowdSec
- _"CrowdSec doesn't detect attacks on my nginx server, why?"_
- _"There's a decision for this IP but it's not being blocked."_
- _"Migrate my fail2ban jails to CrowdSec."_
+- _"Create a Console blocklist and push these IPs from my SIEM to it."_ (Service API)
+- _"Wire a Palo Alto external dynamic list to my CrowdSec blocklist."_ (Service API)
+- _"Show me the remediation ROI metrics for last month."_ (Service API)
## What it does **not** do
diff --git a/skills/crowdsec-service-api/SKILL.md b/skills/crowdsec-service-api/SKILL.md
new file mode 100644
index 0000000..7880601
--- /dev/null
+++ b/skills/crowdsec-service-api/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: crowdsec-service-api
+description: Use when the user wants to drive the CrowdSec Console **Service API (SAPI)** — the premium cloud REST API at admin.api.crowdsec.net — to programmatically manage blocklists (add/remove/bulk IPs, share, subscribe engines), allowlists, firewall/appliance integrations (Palo Alto, Fortinet, Cisco, F5, Sophos, pfSense/OPNsense…), remediation ROI metrics, and org-level decisions. Acts on the user's behalf with their API key. This is the cloud/API skill — for the local engine, cscli, and bouncers use the `crowdsec` skill.
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "key resolution + GET /info validation + list recipes against production SAPI"
+---
+
+# CrowdSec Service API (SAPI) — cloud blocklist / allowlist / integration automation
+
+SAPI is the **premium** REST API behind the CrowdSec Console. It manages
+**cloud-side** objects (private blocklists, allowlists, decisions, firewall integrations)
+that then push down to enrolled engines and bouncers. It is **not** the local
+engine API — there is no `cscli` here, only HTTPS.
+
+- **Base URL:** `https://admin.api.crowdsec.net/v1`
+- **Auth:** `x-api-key: ` header on every call. (One exception: the
+ integration *content* endpoint uses HTTP Basic with credentials minted at
+ integration creation — see [references/integrations.md](./references/integrations.md).)
+- **Interactive API docs:** · spec
+
+
+## Boundary — this skill vs the `crowdsec` skill
+
+| You want to… | Use |
+|---|---|
+| Create/manage a **private blocklist** in the cloud, push IPs to it via API | this skill |
+| Wire a firewall/appliance (Palo Alto, Fortinet…) to a cloud **integration** | this skill |
+| Manage **cloud allowlists**, subscribe engines/tags/orgs to lists | this skill |
+| Create/manage **org-level decisions** (targeted or ad-hoc bans, non-IP scopes, per tag/entity) | this skill |
+| Pull **remediation ROI metrics** | this skill |
+| Install / run / debug the **local engine**, `cscli`, bouncers, WAF | the `crowdsec` skill |
+| **Enroll** an engine into the Console (`cscli console enroll`) | the `crowdsec` skill → `references/install/console.md` |
+| Configure a **local** allowlist/whitelist on one engine | the `crowdsec` skill → `references/configure/allowlists.md` |
+
+Cloud allowlists/blocklists here only take effect on an engine once that engine
+is enrolled **and** subscribed (and `console_management` is enabled locally). The
+enrollment half lives in the `crowdsec` skill.
+
+## Acting on the user's behalf — operating contract
+
+This skill runs real cloud mutations. Follow this every session.
+
+**1 — Resolve the key** (never echo it, never write it into the repo):
+```bash
+KEY="${CROWDSEC_SAPI_KEY:-$(cat ~/.config/crowdsec/sapi_key 2>/dev/null)}"
+[ -n "$KEY" ] || echo "No key: export CROWDSEC_SAPI_KEY or write it to ~/.config/crowdsec/sapi_key (chmod 0600)"
+```
+
+**2 — Validate before acting** — one read call confirms the key and shows *which
+tenant* is about to change:
+```bash
+curl -s -H "x-api-key: $KEY" https://admin.api.crowdsec.net/v1/info
+# → {"organization_id":"…","subscription_type":"…","api_key_name":"…"}
+```
+
+**3 — Classify read vs mutate.** `GET` / download / `POST …/search` are safe —
+run them directly. Every **`POST` / `PATCH` / `DELETE` that changes state** needs
+**explicit user confirmation first**; show the exact URL and JSON body you will
+send, then wait.
+
+**4 — Extra-danger operations** — warn in plain words *before* the confirm,
+because subscribed engines **enforce** these lists, so a change can block or
+unblock real traffic and is hard to undo:
+
+| Operation | Why it's dangerous |
+|---|---|
+| `POST …/ips/bulk_overwrite` | Replaces the **entire** blocklist content. |
+| `DELETE /blocklists/{id}` · `/allowlists/{id}` · `/integrations/{id}` | Removes the object and everyone's subscription/feed to it. |
+| `POST …/ips/delete` | Un-blocks IPs fleet-wide. |
+| `POST /decisions` with `target.type: org` | Bans fleet-wide across every enrolled engine in the org. |
+| `…/shares` / unshare | Grants/revokes another **organization** access. |
+| any `…/subscribers` change | Changes which engines/bouncers enforce the list. |
+
+**5 — Clean up** any throwaway objects you created while testing, and remind the
+user to rotate a key that was pasted into chat.
+
+## Step — Detect the intent
+
+| Cue from user | Go to |
+|---|---|
+| "test my key", "what org / plan am I on" | [references/authentication.md](./references/authentication.md) |
+| "create a blocklist", "push IPs from my SIEM/SOAR", "expire IPs", "share a blocklist with another org", "subscribe my engine to a list" | [references/blocklists.md](./references/blocklists.md) |
+| "cloud allowlist via API", "allow my office/CDN across the fleet" | [references/allowlists.md](./references/allowlists.md) |
+| "connect Palo Alto / Fortinet / Cisco / F5 / Sophos / pfSense / OPNsense", "firewall integration", "pull IP list in vendor format", "paginate the feed" | [references/integrations.md](./references/integrations.md) |
+| "remediation metrics", "how much did CrowdSec save / block", "ROI dashboard" | [references/metrics.md](./references/metrics.md) |
+| "org-level decisions via API", "aggregated decisions" | [references/decisions.md](./references/decisions.md) |
+
+## Step — curl cheat sheet
+
+All assume `KEY` is set (see operating contract). `jq` optional for readability.
+
+| Purpose | Command |
+|---|---|
+| Who am I / validate key | `curl -s -H "x-api-key: $KEY" $B/info` |
+| List blocklists | `curl -s -H "x-api-key: $KEY" "$B/blocklists"` |
+| List allowlists | `curl -s -H "x-api-key: $KEY" "$B/allowlists"` |
+| List integrations | `curl -s -H "x-api-key: $KEY" "$B/integrations"` |
+| List / find decisions | `curl -s -H "x-api-key: $KEY" "$B/decisions"` · `…?ips=1.2.3.4` |
+| Remediation metrics | `curl -s -H "x-api-key: $KEY" "$B/metrics/remediation?start_date=$FROM&end_date=$TO"` |
+| Add IPs to a blocklist *(mutating — confirm)* | `curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$ID/ips" -d '{"ips":["1.2.3.4"]}'` |
+| Create a decision *(mutating — confirm)* | `curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/decisions" -d '{"duration":"4h","origin":"cscli","scenario":"manual","scope":"Ip","type":"ban","value":"1.2.3.4","target":{"type":"org","value":""}}'` |
+
+where `B=https://admin.api.crowdsec.net/v1`.
+
+## Hard don'ts
+
+- Don't run any mutating call without showing the user the URL + body and getting
+ a yes (see operating contract §3–4).
+- Don't use `…/ips/bulk_overwrite` when the user means "add a few IPs" — that's
+ `…/ips`. `bulk_overwrite` wipes the list first.
+- Don't print, log, or commit the API key. Resolve it from the env var or
+ `~/.config/crowdsec/sapi_key` only.
+- Don't assume a cloud allowlist/blocklist is enforced just because the API call
+ succeeded — the engine must be enrolled, subscribed, and have
+ `console_management` on (that's the `crowdsec` skill's job to verify).
+
+## Docs
+
+Canonical: . Each
+`references/` file cites the specific upstream page and the live OpenAPI operation
+it derives from.
diff --git a/skills/crowdsec-service-api/references/allowlists.md b/skills/crowdsec-service-api/references/allowlists.md
new file mode 100644
index 0000000..bd106cd
--- /dev/null
+++ b/skills/crowdsec-service-api/references/allowlists.md
@@ -0,0 +1,84 @@
+---
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "create, items create + list (scope/value fields); delete needs unsubscribe first (409 otherwise)"
+ - date: 2026-07-30
+ version: "1.70.52"
+ env: sapi
+ notes: "closed loop: subscribe org -> engine on crowdsec-vm shows it 'Managed by Console: yes', cscli allowlists check confirms the item allowlisted (~40s sync); unsubscribe then delete 204"
+---
+
+# SAPI — Allowlists
+
+Canonical docs:
+OpenAPI: the `/allowlists` group —
+
+A cloud **allowlist** is a named set of IPs/ranges (each with optional expiration and reason)
+that subscribed engines treat as "never act on this". Same subscription model as
+blocklists.
+
+> **Cloud vs local:** this is the *org-wide, Console-pushed* allowlist. A single
+> engine's local allowlist/whitelist is different — see the `crowdsec` skill,
+> `references/configure/allowlists.md`. A cloud allowlist only reaches an engine
+> that is enrolled, **subscribed here**, and has `console_management` enabled
+> locally (`crowdsec` skill → `references/install/console.md`).
+>
+> Mutating calls are marked ⚠. `B=https://admin.api.crowdsec.net/v1`, `KEY` set.
+
+## Lifecycle
+
+### Create ⚠ / list / delete ⚠
+`name` is required; `description` optional.
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/allowlists" -d '{
+ "name": "corp-egress", "description": "office + CDN egress IPs"
+}'
+curl -s -H "x-api-key: $KEY" "$B/allowlists" # list (paginated)
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/allowlists/$ID" # removes list + subscriptions
+```
+Delete returns **`409 Conflict`** while the list still has subscribers. Either
+unsubscribe first (see Subscribers below), or **force it** —
+`DELETE "$B/allowlists/$ID?force=true"`. On subscribed engines the allowlist shows
+in `cscli allowlists list` as *Managed by Console: yes* and syncs within ~40s.
+
+## Items — the IPs/ranges
+
+`items` and `description` are required; `expiration` optional (RFC3339).
+```bash
+# add items ⚠
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/allowlists/$ID/items" -d '{
+ "items": ["203.0.113.10", "198.51.100.0/24"],
+ "description": "office ranges"
+}'
+
+curl -s -H "x-api-key: $KEY" "$B/allowlists/$ID/items" # list items
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' \
+ -X PATCH "$B/allowlists/$ID/items/" -d '{"expiration":"2026-12-31T00:00:00Z"}' # ⚠ update
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/allowlists/$ID/items/" # ⚠ delete item
+```
+Items accept a single IP or a CIDR range.
+
+## Subscribers — who applies the allowlist ⚠
+
+`entity_type` is required, one of: `org`, `tag`, `engine`, `firewall_integration`,
+`remediation_component_integration`, `remediation_component`, `log_processor`. As
+with blocklists, `org` takes **no `ids`** (whole org); the other types take `ids`.
+```bash
+# a specific tag
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/allowlists/$ID/subscribers" -d '{
+ "entity_type": "tag", "ids": [""]
+}'
+# the entire org (no ids)
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/allowlists/$ID/subscribers" -d '{"entity_type":"org"}'
+curl -s -H "x-api-key: $KEY" "$B/allowlists/$ID/subscribers" # list
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/allowlists/$ID/subscribers/" # ⚠ unsubscribe
+```
+
+## Use case — fleet-wide "don't block us"
+
+Put office/CDN/monitoring ranges in one allowlist, subscribe by `tag` (or `org` for
+everything). New engines that inherit the tag pick it up automatically — no
+per-engine config. Set `expiration` on temporary items (a vendor's scan window,
+a short-lived NAT) so they clean themselves up.
diff --git a/skills/crowdsec-service-api/references/authentication.md b/skills/crowdsec-service-api/references/authentication.md
new file mode 100644
index 0000000..707b619
--- /dev/null
+++ b/skills/crowdsec-service-api/references/authentication.md
@@ -0,0 +1,73 @@
+---
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "GET /info key validation against production SAPI (ENTERPRISE key)"
+---
+
+# SAPI — Authentication & key handling
+
+Canonical docs: · quickstart · Python SDK
+OpenAPI: `GET /info` —
+
+SAPI is a **premium** feature. Every call authenticates with the `x-api-key`
+header. The only exception is the integration *content* endpoint, which uses HTTP
+Basic auth with credentials minted per integration — see
+[integrations.md](./integrations.md).
+
+## Create an API key
+
+In the Console (): **Settings → Service API Keys →
+Create API Key**. Name it, set permissions, create. **The key is shown once** —
+copy it immediately.
+
+## Key resolution (how this skill reads it)
+
+Resolve from the environment variable first, then an optional local file. Never
+echo, log, or commit the key.
+
+```bash
+B=https://admin.api.crowdsec.net/v1
+KEY="${CROWDSEC_SAPI_KEY:-$(cat ~/.config/crowdsec/sapi_key 2>/dev/null)}"
+```
+
+Store it out of the repo, readable only by you:
+```bash
+install -m 0600 /dev/stdin ~/.config/crowdsec/sapi_key <<<'YOUR-KEY'
+```
+
+## Validate — always the first call
+
+```bash
+curl -s -H "x-api-key: $KEY" "$B/info"
+# → {"organization_id":"…","subscription_type":"ENTERPRISE","api_key_name":"…"}
+```
+
+`subscription_type` confirms the plan; `organization_id` confirms **which tenant**
+subsequent mutations will change. A `401 {"message":"Unauthorized"}` means the key
+is wrong, revoked, or the header name is misspelled (it is `x-api-key`).
+
+## Typed alternative — Python SDK
+
+For scripted/automated use there is an official SDK instead of raw curl:
+
+```bash
+pip install crowdsec-service-api
+```
+```python
+from crowdsec_service_api import Info, Server, ApiKeyAuth
+client = Info(base_url=Server.production_server.value, auth=ApiKeyAuth(api_key=KEY))
+print(client.get_me_info())
+```
+
+SDK reference: . It
+exposes `Blocklists`, `Allowlists`, `Integrations`, `Metrics`, `Info` and raises
+`httpx.HTTPStatusError` on API errors.
+
+## Security notes
+
+- Treat the key like a password. If it lands in a shell history, a ticket, or a
+ chat, **rotate it** (Console → Settings → Service API Keys).
+- One key = one organization's blast radius. Mutations affect every engine/bouncer
+ subscribed to the lists in that org.
diff --git a/skills/crowdsec-service-api/references/blocklists.md b/skills/crowdsec-service-api/references/blocklists.md
new file mode 100644
index 0000000..7fec9e4
--- /dev/null
+++ b/skills/crowdsec-service-api/references/blocklists.md
@@ -0,0 +1,142 @@
+---
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "create/list/get, add IPs (201), ips/delete, download (204 eventual-consistency observed); delete needs unsubscribe first (409 otherwise); search not exercised"
+ - date: 2026-07-30
+ version: "1.70.52"
+ env: sapi
+ notes: "closed loop: subscribe org (remediation required) -> PAPI force_pull -> engine active decision origin=lists on crowdsec-vm; unsubscribe then delete 204"
+---
+
+# SAPI — Blocklists
+
+Canonical docs:
+OpenAPI: the `/blocklists` group —
+
+A **blocklist** is a named, cloud-hosted list of IPs (each with optional
+expiration). You add IPs via API, then **subscribe** engines / bouncers / firewall
+integrations / whole orgs to it — they pull and **enforce** it. You can also
+**share** a blocklist read/write with another organization.
+
+> Mutating calls below are marked ⚠ — show the user the URL + body and get a yes
+> before sending (see SKILL.md operating contract). All snippets assume
+> `B=https://admin.api.crowdsec.net/v1` and `KEY` set.
+
+## Lifecycle
+
+### Create ⚠
+`name` and `description` are required.
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists" -d '{
+ "name": "siem-high-confidence",
+ "description": "IPs pushed from our SIEM",
+ "label": "SIEM feed",
+ "tags": ["siem"]
+}'
+```
+The response `id` is what every other call uses (`ID=`).
+
+### List / get
+```bash
+curl -s -H "x-api-key: $KEY" "$B/blocklists?page=1&page_size=100" # add subscribed_only=true to see only what you consume
+curl -s -H "x-api-key: $KEY" "$B/blocklists/$ID"
+```
+Useful list filters: `subscribed_only=true`, `exclude_subscribed=true`,
+`include_filter=private,shared`, `category=…`.
+
+### Update ⚠ / Delete ⚠
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X PATCH "$B/blocklists/$ID" -d '{"description":"new desc"}'
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/blocklists/$ID" # removes the list AND every subscription/feed to it
+```
+Delete returns **`409 Conflict`** while the list still has subscribers. Either
+unsubscribe everyone first (see Subscribers below), or **force it** —
+`DELETE "$B/blocklists/$ID?force=true"` drops the list and all its subscriptions.
+
+## Content — the IPs
+
+| Action | Call | Notes |
+|---|---|---|
+| **Add** IPs | `POST /blocklists/$ID/ips` | Additive. `{"ips":[…],"expiration":"…"}`; `expiration` optional (RFC3339). |
+| **Remove** IPs ⚠ | `POST /blocklists/$ID/ips/delete` | `{"ips":[…]}` — un-blocks fleet-wide. |
+| **Replace all** ⚠⚠ | `POST /blocklists/$ID/ips/bulk_overwrite` | Wipes the list, then sets exactly these IPs. Not for "add a few". |
+| **Download** | `GET /blocklists/$ID/download` | Published content (raw, one IP/line). Safe. |
+
+A successful add returns `201`. Content is **eventually consistent**: right after
+an add, `GET …/download` can still return `204 No Content` and `stats.count` `0`
+for a short processing interval before the IPs are published. Don't treat an empty
+download immediately after an add as a failure — re-check after a moment.
+
+Add with a 24h expiry:
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$ID/ips" -d '{
+ "ips": ["1.2.3.4", "5.6.7.8"],
+ "expiration": "'"$(date -u -d tomorrow +%FT%TZ)"'"
+}'
+```
+
+## Subscribers — who enforces the list ⚠
+
+Subscribe entities so they pull this blocklist. Two fields are **required**:
+`entity_type` (one of `engine`, `firewall_integration`,
+`remediation_component_integration`, `tag`, `org`) and `remediation` (the action
+the subscribers apply, e.g. `ban`). `ids` depends on the type:
+
+| `entity_type` | `ids` |
+|---|---|
+| `engine` / `firewall_integration` / `remediation_component_integration` / `tag` | required — the specific entity ids to subscribe |
+| `org` | **must be omitted** — subscribes the whole org (sending `ids` errors with *"Organization type subscription can't have ids"*) |
+
+```bash
+# subscribe specific engines
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$ID/subscribers" -d '{
+ "entity_type": "engine",
+ "ids": ["", ""],
+ "remediation": "ban"
+}'
+
+# subscribe the entire org (no ids)
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$ID/subscribers" -d '{
+ "entity_type": "org", "remediation": "ban"
+}'
+
+curl -s -H "x-api-key: $KEY" "$B/blocklists/$ID/subscribers" # list
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/blocklists/$ID/subscribers/" # ⚠ unsubscribe
+```
+
+An enrolled engine only enforces a subscribed blocklist once it has
+`console_management` enabled locally (`crowdsec` skill → `references/install/console.md`);
+it then pulls the decisions over PAPI within a poll cycle.
+
+To feed a **firewall appliance**, subscribe the *integration* (`entity_type:
+firewall_integration`, `ids:[]`) — the device then pulls the
+blocklist's IPs in its vendor format. See [integrations.md](./integrations.md).
+
+## Sharing across organizations ⚠
+
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$ID/shares" -d '{
+ "organizations": [ {"organization_id": "", "permission": "read"} ]
+}'
+# permission: "read" (subscribe/view) or "write" (also add/remove IPs)
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/blocklists/$ID/shares/" # unshare
+```
+
+## Discover existing blocklists — search (read-only)
+
+`POST /blocklists/search` finds catalog blocklists (CrowdSec/third-party/custom)
+you can subscribe to, filtered by pricing tier, country, classification, etc.
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/search" -d '{
+ "query": "ssh", "pricing_tiers": ["premium"], "min_ips": 100, "page": 1, "page_size": 20
+}'
+```
+
+## Use case — SIEM/SOAR feed
+
+1. Create one blocklist (once). 2. Subscribe your engines/integrations (once).
+3. On each detection, `POST …/ips` with a short `expiration`. Expired IPs drop off
+automatically — no cleanup job needed. Use `…/ips/delete` only to pull an IP early;
+reserve `bulk_overwrite` for a full authoritative re-sync from the source of truth.
diff --git a/skills/crowdsec-service-api/references/decisions.md b/skills/crowdsec-service-api/references/decisions.md
new file mode 100644
index 0000000..b972442
--- /dev/null
+++ b/skills/crowdsec-service-api/references/decisions.md
@@ -0,0 +1,98 @@
+---
+verified:
+ - date: 2026-07-30
+ version: "1.70.52"
+ env: sapi
+ notes: "list + aggregated reads; create org-targeted ban (POST -> 200 {uuid}) confirmed present in /decisions. DELETE of an org decision NOT confirmed: /decisions/{uuid} and /decisions/aggregated/{id} both return 204 but the decision persisted (it isn't in the aggregated set); relies on duration to expire"
+---
+
+# SAPI — Decisions (org-level)
+
+Canonical docs:
+OpenAPI: the `/decisions` group —
+
+Org-scoped decisions in the cloud (distinct from a single engine's *local*
+decisions, which live behind `cscli` in the `crowdsec` skill). A decision is one
+remediation (`ban`, `captcha`…) on a `scope`/`value` (an IP, range, country, AS),
+`target`ed at your `org`, a `tag`, or a single `entity`, for a `duration`.
+
+**Decisions vs blocklists** — both end up as enforced decisions on subscribed
+engines; pick by shape of the task:
+
+| Use **decisions** for | Use **[blocklists](./blocklists.md)** for |
+|---|---|
+| A targeted / ad-hoc ban (one IP, right now) | A maintained IP feed (SIEM/SOAR, threat intel) |
+| Non-IP scopes — range, country, AS | Bulk IP sets you add/expire/bulk-overwrite |
+| Aiming at one `tag`/`entity` instead of a whole subscription | Sharing a curated list across orgs / vendor integrations |
+| Org-wide decision **visibility** (list + aggregate) | — |
+
+> **Access:** the `/decisions` group needs a **decision-scoped key**. A key scoped
+> only to blocklist/allowlist management gets `403 {"message":"Forbidden"}` on
+> every call here — an entitlement gap, not a bad request. A blocklist-only key
+> silently fails.
+>
+> Mutating calls marked ⚠. `B=https://admin.api.crowdsec.net/v1`, `KEY` set.
+
+## List (read-only)
+
+```bash
+curl -s -H "x-api-key: $KEY" "$B/decisions?page=1&size=50"
+curl -s -H "x-api-key: $KEY" "$B/decisions?ips=1.2.3.4" # find decisions for an IP
+```
+Returns the paginated envelope `{items, total, page, size, links}`; each item is a
+full decision (`uuid`, `origin`, `scenario`, `scope`, `type`, `value`, `duration`,
+`target`, geo fields). Note `id` is `0` for org-targeted decisions — the
+identifier that matters is `uuid` (see delete). Filters: `ips=`, `instance_ids=`,
+`tag_ids=`, `remediation_types=`, `alert_ids=`, `decision_ids=`, `created_at_from=`,
+`sort_by=` (default `created_at`), `sort_order=` (default `desc`) — repeatable
+where plural.
+
+## Aggregated (read-only)
+
+```bash
+curl -s -H "x-api-key: $KEY" "$B/decisions/aggregated"
+```
+Collapses decisions by `scope`/`type`/`value`. Each item's `id` is a
+**URL-encoded JSON composite key** — decoded, `{"organization_id":…,"scope":"ip","type":"ban","value":"1.2.3.4"}`.
+In testing this view listed only CAPI/community-origin decisions, not a freshly
+`POST`ed org decision — so don't rely on it to find something you just created.
+
+## Create ⚠
+
+Required: `duration`, `origin`, `scenario`, `scope`, `type`, `value`, and `target`
+(`type` ∈ `org`/`tag`/`entity`, `value` = the org id / tag id / entity id). `scope`
+uses the usual CrowdSec values (`Ip`, `Range`, `Country`, `AS`); `type` is `ban`,
+`captcha`, etc. Get your org id from [`/info`](./authentication.md).
+
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/decisions" -d '{
+ "duration": "4h",
+ "origin": "cscli",
+ "scenario": "manual",
+ "scope": "Ip",
+ "type": "ban",
+ "value": "1.2.3.4",
+ "target": { "type": "org", "value": "" }
+}'
+# → {"uuid":"…"}
+```
+An `org` target pushes to **every** enrolled engine in the org (each enforces it
+within a poll cycle) — treat it like a blocklist mutation and confirm first.
+
+## Delete ⚠ — and its caveat
+
+The API exposes two delete paths:
+
+```bash
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/decisions/" # per decision
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/decisions/aggregated/" # per aggregated key
+```
+
+**Caveat (observed, v1.70.52):** for an **org-targeted** decision, *neither*
+reliably removed it — both returned `204`, but the decision stayed in
+`GET /decisions` (its list `id` is `0`, and it never appeared under
+`/decisions/aggregated`, so the aggregated id can't be built). Treat org-decision
+deletion as **unconfirmed**: prefer a bounded `duration` and let it expire, rather
+than assuming the delete took. Per-`entity`/`tag` decision deletion by `uuid` is
+plausible but was **not** verified here — check the response *and* re-read
+`GET /decisions` before telling the user it's gone.
diff --git a/skills/crowdsec-service-api/references/integrations.md b/skills/crowdsec-service-api/references/integrations.md
new file mode 100644
index 0000000..20adad9
--- /dev/null
+++ b/skills/crowdsec-service-api/references/integrations.md
@@ -0,0 +1,98 @@
+---
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "create (plain_text) returns endpoint + basic-auth credentials{username,password}, delete 204; content pull / update / stream not exercised (no subscribed content)"
+---
+
+# SAPI — Integrations (firewall / appliance feeds)
+
+Canonical docs:
+OpenAPI: the `/integrations` group —
+
+An **integration** is a pull endpoint that exposes the IPs of the blocklists
+subscribed to it, **rendered in a firewall vendor's format**. A device (Palo Alto
+EDL, Fortinet threat feed, etc.) polls it on a schedule. The content endpoint uses
+**HTTP Basic auth** with credentials minted at creation — the only non-`x-api-key`
+call in SAPI.
+
+> Mutating calls marked ⚠. `B=https://admin.api.crowdsec.net/v1`, `KEY` set.
+
+## Create ⚠
+
+Required: `name`, `entity_type`, `output_format`.
+
+- `entity_type` ∈ `firewall_integration`, `remediation_component_integration`.
+- `output_format` ∈ `plain_text`, `paloalto`, `fortigate`, `checkpoint`, `cisco`,
+ `f5`, `juniper`, `mikrotik`, `pfsense`, `opnsense`, `sophos`, `remediation_component`.
+
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/integrations" -d '{
+ "name": "edge-paloalto",
+ "description": "Palo Alto external dynamic list",
+ "entity_type": "firewall_integration",
+ "output_format": "paloalto"
+}'
+```
+The response contains `endpoint` (the content URL) and `credentials`
+(`username`/`password` for Basic auth) — **shown at creation**; regenerate later if
+lost (see Update).
+
+## Wire a blocklist to the integration ⚠
+
+The integration is empty until you subscribe lists to it. From
+[blocklists.md](./blocklists.md), subscribe the integration to a blocklist:
+```bash
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X POST "$B/blocklists/$BLID/subscribers" -d '{
+ "entity_type": "firewall_integration", "ids": [""]
+}'
+```
+
+## Pull the content (Basic auth) — what the appliance does
+
+```bash
+curl -s -u ":" "$B/integrations//content?page=1&page_size=1500"
+```
+Query params: `page` (default 1), `page_size`, `pull_limit`, `enable_ip_aggregation`.
+**Paginate** for appliances with a max-entries cap: fetch `page=1,2,…` until a
+short/empty page. Point the device's feed/EDL URL at this endpoint with the Basic
+credentials.
+
+There is also a decisions **stream** (deltas rather than the full list), for
+remediation components that maintain state:
+```bash
+curl -s -u ":" "$B/integrations//v1/decisions/stream"
+```
+
+## Update ⚠ / Delete ⚠
+
+```bash
+# rotate the Basic-auth credentials
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X PATCH "$B/integrations/" -d '{"regenerate_credentials": true}'
+# change format or add an entry cap
+curl -s -H "x-api-key: $KEY" -H 'Content-Type: application/json' -X PATCH "$B/integrations/" -d '{"output_format":"fortigate","pull_limit":50000}'
+curl -s -H "x-api-key: $KEY" -X DELETE "$B/integrations/" # device feed goes empty/401
+```
+
+## Vendor format cheat sheet
+
+| Appliance | `output_format` | Consumes via |
+|---|---|---|
+| Palo Alto | `paloalto` | External Dynamic List (EDL) |
+| Fortinet FortiGate | `fortigate` | External/Threat Feed connector |
+| Check Point | `checkpoint` | Custom threat feed |
+| Cisco | `cisco` | — |
+| F5 | `f5` | — |
+| Juniper | `juniper` | — |
+| MikroTik | `mikrotik` | address-list |
+| pfSense / OPNsense | `pfsense` / `opnsense` | firewall alias / URL table |
+| Sophos | `sophos` | — |
+| Anything / scripts | `plain_text` | one IP per line |
+
+## Use case — appliance without a native CrowdSec bouncer
+
+For a firewall that can't run a bouncer, an integration turns any subscribed
+blocklist into a vendor-native feed the device already knows how to poll — no agent
+on the box. Curate the IPs once (blocklist), expose them many ways (one integration
+per appliance).
diff --git a/skills/crowdsec-service-api/references/metrics.md b/skills/crowdsec-service-api/references/metrics.md
new file mode 100644
index 0000000..7354af5
--- /dev/null
+++ b/skills/crowdsec-service-api/references/metrics.md
@@ -0,0 +1,41 @@
+---
+verified:
+ - date: 2026-07-29
+ version: "1.70.52"
+ env: sapi
+ notes: "GET /metrics/remediation returns raw/computed/stats; units request/byte/packet; computed.saved keys log_lines/storage/egress_traffic"
+---
+
+# SAPI — Remediation metrics (ROI)
+
+Canonical docs:
+OpenAPI: `GET /metrics/remediation` —
+
+Read-only. Returns what your remediation actually did over a window: traffic
+dropped vs processed, plus computed savings — the numbers for an ROI dashboard or
+report. `B=https://admin.api.crowdsec.net/v1`, `KEY` set.
+
+## Call
+
+```bash
+FROM=2026-07-01T00:00:00Z ; TO=2026-07-29T00:00:00Z
+curl -s -H "x-api-key: $KEY" "$B/metrics/remediation?start_date=$FROM&end_date=$TO"
+```
+Dates are ISO 8601 / RFC3339. Optional filters narrow the scope:
+`engine_ids=`, `integration_ids=`, `tags=` (repeatable).
+
+## What you get
+
+| Group | Fields | Meaning |
+|---|---|---|
+| **raw.dropped** | requests, bytes, packets | Blocked at the remediation layer. |
+| **raw.processed** | requests, bytes, packets | Total seen (dropped + allowed). |
+| **computed.saved** | log_lines, storage, egress_traffic | Estimated resources not spent because traffic was dropped. |
+| **computed** | dropped, prevented | Aggregate blocked / attacks prevented. |
+
+## Use case — monthly ROI report
+
+Query month-over-month, chart `computed.saved.egress_traffic` and
+`raw.dropped.requests`, and attribute per team with `tags=`. Turn "we blocked N
+requests" into a concrete savings figure (log lines, storage, egress) for the
+report.
diff --git a/skills/crowdsec/SKILL.md b/skills/crowdsec/SKILL.md
index f5f75e8..8d38070 100644
--- a/skills/crowdsec/SKILL.md
+++ b/skills/crowdsec/SKILL.md
@@ -19,7 +19,8 @@ thing. This skill uses both interchangeably.
| Deploy the WAF (AppSec component) | this skill |
| Debug "logs not parsing" / "no alerts" / "bouncer not blocking" | this skill |
| Migrate from fail2ban | this skill |
-| **Write** a parser, scenario, or WAF (AppSec) rule | out of scope — this skill is operational only |
+| **Write** a parser, scenario, or WAF (AppSec) rule | the `crowdsec-local-mcp` mcp |
+| Drive the **cloud Service API** (manage blocklists / allowlists / firewall integrations / metrics / decisions programmatically) | the `crowdsec-service-api` skill |
## Step 1 — Detect the environment
diff --git a/skills/crowdsec/references/install/console.md b/skills/crowdsec/references/install/console.md
index fedc37c..b7bf942 100644
--- a/skills/crowdsec/references/install/console.md
+++ b/skills/crowdsec/references/install/console.md
@@ -111,6 +111,15 @@ local-vs-Console allowlist distinction.
register bouncers normally with `cscli bouncers add` and they surface in the
Console via the enrolled LAPI.
+## Managing the Console programmatically
+
+This page covers **enrolling** an engine. To manage Console objects by API —
+create blocklists/allowlists, push IPs, wire firewall integrations, pull
+remediation metrics — use the **`crowdsec-service-api`** skill (the premium cloud
+Service API at `admin.api.crowdsec.net`). Enroll here first; a cloud list only
+reaches this engine once it's enrolled, subscribed there, and has
+`console_management` enabled (§2).
+
## Next step
Confirm detection + sharing end-to-end with
diff --git a/skills/crowdsec/scripts/check-verification.py b/skills/crowdsec/scripts/check-verification.py
index 5703d81..4190788 100644
--- a/skills/crowdsec/scripts/check-verification.py
+++ b/skills/crowdsec/scripts/check-verification.py
@@ -21,12 +21,10 @@
import sys
from pathlib import Path
-# skills/crowdsec/scripts/check-verification.py -> skills/crowdsec
+# skills/crowdsec/scripts/check-verification.py -> skills/crowdsec (the default --root)
SKILL_ROOT = Path(__file__).resolve().parent.parent
-REFERENCES = SKILL_ROOT / "references"
-SKILL_MD = SKILL_ROOT / "SKILL.md"
-CANONICAL_ENVS = {"systemd", "docker", "k8s"}
+CANONICAL_ENVS = {"systemd", "docker", "k8s", "sapi"}
REQUIRED_KEYS = ("date", "version", "env")
OPTIONAL_KEYS = ("notes",)
ALLOWED_KEYS = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
@@ -114,14 +112,25 @@ def main() -> int:
default=180,
help="flag verified entries older than this many days (default: 180)",
)
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=SKILL_ROOT,
+ help="skill directory to check (default: this script's own skill). "
+ "Point it at a sibling skill, e.g. --root skills/crowdsec-service-api",
+ )
args = parser.parse_args()
+ skill_root = args.root.resolve()
+ references = skill_root / "references"
+ skill_md = skill_root / "SKILL.md"
+
today = dt.date.today()
- docs = sorted(REFERENCES.rglob("*.md"))
- if SKILL_MD.exists():
- docs.append(SKILL_MD)
+ docs = sorted(references.rglob("*.md"))
+ if skill_md.exists():
+ docs.append(skill_md)
if not docs:
- print(f"No reference docs found under {REFERENCES}", file=sys.stderr)
+ print(f"No reference docs found under {references}", file=sys.stderr)
return 1
rows: list[tuple[str, str, str, str, str]] = []
@@ -130,7 +139,7 @@ def main() -> int:
errors: list[str] = []
for doc in docs:
- rel = doc.relative_to(SKILL_ROOT).as_posix()
+ rel = doc.relative_to(skill_root).as_posix()
try:
frontmatter = split_frontmatter(doc.read_text(encoding="utf-8"))
entries = parse_verified(frontmatter) if frontmatter is not None else None