REST API
Kapkan exposes a small JSON REST API for observing detection and driving manual mitigation.
It is served on the address in api.listen, which defaults to 127.0.0.1:8080. The same
listener also serves the embedded dashboard and the Prometheus
/metrics endpoint.
The API is read-mostly: status, active and recent attacks, the tracked-host snapshot, and
the ban table are all GETs. Three POST endpoints mutate state — manual ban, manual
unban, and config reload.
!Authenticate before exposing it
The default api.listen binds to 127.0.0.1, so the API is safe unauthenticated only on
localhost. Before binding beyond loopback, set a bearer token. See Authentication.
All /api/v1 routes pass through the token check when auth is configured; once configured,
every request must carry Authorization: Bearer <token>. Tokens carry a role: the GET
routes need the viewer role, and the mutating routes (ban, unban, config/reload)
need the operator role. config/reload goes further: it requires an unscoped (admin)
token, so even an operator token that is tenant-scoped is refused with 403. The
scrub-node channel takes the agent role — a scrub node's
credential that reaches those routes and nothing else. A read with a
viewer token works; a mutation with a viewer token returns 403; an unknown token returns
401. The POST endpoints additionally require a JSON content type — send
Content-Type: application/json. A POST without that header is rejected with
HTTP 415 Unsupported Media Type, so if a curl ban fails with a 415, that missing
header is almost always why. Note that /metrics and the
static dashboard shell are served without a token; the data the dashboard loads is fetched
through the guarded API. See Authentication for the token roles.
A token may also be tenant-scoped: it then sees only its tenant's rows on every read
endpoint (/status is rebuilt to its own hostgroups and counts), and may ban/unban only
within its tenant — an out-of-tenant target returns a uniform 403. A tenant-scoped token
cannot reload config at all: config/reload is restricted to unscoped (admin) tokens and
returns 403 otherwise. See Multi-tenancy.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/status | Mode, uptime, protected networks, thresholds, hostgroups, active attack/ban counts. |
| GET | /api/v1/attacks | Active attacks plus the last 100 that ended, with samples and classification. |
| GET | /api/v1/hosts | Tracked-host snapshot: per-direction rates, learned baselines, attack state. |
| GET | /api/v1/bans | All bans, active and historical. |
| GET | /api/v1/traffic | Persisted per-host rate history (viewer); {available:false, points:[]} when storage is disabled. |
| GET | /api/v1/audit | Operator-attributed audit trail of mutations (who banned/reloaded, when, outcome). |
| POST | /api/v1/ban | Manually ban an address. |
| POST | /api/v1/unban | Manually withdraw a ban. |
| POST | /api/v1/config/reload | Re-read the config file (same as SIGHUP). |
| GET | /api/v1/dataplane/rules | The scrub-node rule feed (agent/unscoped operator); ETag long-poll, held up to 30 s. |
| POST | /api/v1/dataplane/nodes/{name}/report | A scrub node's advisory self-report (agent/unscoped operator). |
| GET | /api/v1/dataplane/nodes | Managed-node inventory: liveness, config, per-node ban counts (unscoped viewer+). |
| GET | /metrics | Prometheus metrics. |
| GET | /healthz | Unauthenticated liveness/readiness probe: 503 until fully started, then 200. |
All responses are JSON. Errors return an object of the form {"error": "..."} with an
appropriate HTTP status.
GET /api/v1/status
Returns the current operating mode and a summary of what Kapkan is protecting. To read it
(drop the Authorization header if no token is configured):
curl -fsS localhost:8080/api/v1/status \
-H "Authorization: Bearer $TOKEN" | jq
{
"dry_run": true,
"uptime_seconds": 8123,
"version": "v1.2.0 · a1b2c3d",
"update_available": false,
"role": "operator",
"unscoped": true,
"networks": ["203.0.113.0/24", "198.51.100.0/24"],
"active_attacks": 1,
"active_bans": 1,
"thresholds": {
"pps": 80000,
"mbps": 1000,
"flows_per_sec": 20000
},
"hostgroups": [
{ "name": "web", "calculation": "per_host", "mitigation": "blackhole", "ban": true }
]
}
dry_run reports the global mode: when true, no blackhole is announced to your routers.
version is the running build, always present with zero egress. update_available is false
unless the opt-in update_check is enabled and finds a newer release —
then it is true and latest_version, latest_is_security and latest_url are included
alongside it. See Upgrading.
thresholds and hostgroups mirror the active configuration; see
Detection and Hostgroups for their full shape.
role and unscoped are always present — they tell the dashboard which token it is
using. The networks, thresholds, bgp, scrubbing and notify fields are
deployment-wide config and are returned only to an unscoped (admin) token; a
tenant-scoped token receives a status object without them (just dry_run, version,
uptime_seconds, update_available, role, unscoped, its own hostgroups, and counts). An admin token additionally gets
bgp, scrubbing and notify objects — notify exposes only which channels are
enabled, never tokens or URLs. So if a field looks "missing", check whether your token is
tenant-scoped rather than assuming Kapkan is broken.
GET /api/v1/attacks
Returns currently active attacks plus the last 100 that ended (newest first). Both arrays
hold Attack objects.
| Field | Type | Notes |
|---|---|---|
scope | string | host or group. Group-scoped attacks carry no target. |
target | string | The attacked address (host scope). |
group | string | Hostgroup name (group scope, or the host's group). |
tenant | string | The owning group's tenant, when one is labeled (see Multi-tenancy). |
direction | string | incoming or outgoing. |
metric | string | The metric that tripped, e.g. pps, mbps, tcp_syn_pps. |
rate | number | The rate of the tripping metric, in its unit: the engine's current measurement while the attack is active, the last one before it ended afterwards. |
threshold | number | The threshold that was crossed. |
rates | object | Full per-protocol rate breakdown (see below), from the same measurement as rate. |
active | bool | true while ongoing. |
ban_state | string | active, withdrawn, or rejected. Omitted when no ban. |
method | string | Mitigation method: blackhole, flowspec, or divert. Omitted when no ban. |
route | string | The route string (blackhole … / divert …), or a flowspec: ... summary, when a ban exists. |
flowspec | array | The generated FlowSpec rules, when method is flowspec. |
dry_run | bool | Whether the ban was virtual. |
started_at | string | RFC 3339 timestamp. |
ended_at | string | RFC 3339 timestamp; omitted while active. |
sample | object | Flow sample captured at detection; omitted when sampling is off. |
classification | object | Inferred attack vector; omitted when unclassified. |
reason | object | Why the detection fired — threshold provenance, warm-up, protocol shares. Attached at start. |
{
"active": [
{
"scope": "host",
"target": "203.0.113.66",
"group": "web",
"direction": "incoming",
"metric": "pps",
"rate": 412000,
"threshold": 80000,
"rates": {
"pps": 412000,
"mbps": 3100,
"flows_per_sec": 9800,
"udp_pps": 405000,
"udp_mbps": 3080
},
"active": true,
"ban_state": "active",
"route": "blackhole 203.0.113.66/32 next-hop 192.0.2.1 community 65000:666",
"dry_run": false,
"started_at": "2026-06-13T09:41:07Z",
"sample": {
"top_sources": [
{ "key": "198.51.100.23", "packets": 1240000, "bytes": 1612000000 }
],
"top_src_ports": [
{ "key": "123", "packets": 1180000, "bytes": 1534000000 }
],
"top_dst_ports": [
{ "key": "443", "packets": 1240000, "bytes": 1612000000 }
],
"protocols": [
{ "key": "udp", "packets": 1240000, "bytes": 1612000000 }
],
"total_packets": 1240000
},
"classification": {
"type": "ntp_amplification",
"confidence": 0.95,
"src_port": 123
}
}
],
"recent": []
}
While an attack is active, rate and rates are re-read from the engine on every
request, so they follow the attack instead of reporting the instant it was detected —
when the sliding window held a single second and understated a sustained flood
several-fold. metric and threshold stay as captured at detection: they name what
tripped, and the engine judges the attack's end against the thresholds frozen at its start.
The rates object carries the base trio (pps, mbps, flows_per_sec) plus the
per-protocol fields that are nonzero: tcp_pps, tcp_mbps, udp_pps, udp_mbps,
icmp_pps, icmp_mbps, tcp_syn_pps, tcp_syn_mbps, frag_pps, frag_mbps. The
sample object summarizes the buffered flows behind the detection — top_sources,
top_src_ports, top_dst_ports, protocols (each a list of {key, packets, bytes}
counters), an optional raw flows list, and total_packets. The classification
type is one of the vectors documented in Detection; confidence
is the share (0..1) of attack traffic matching the winning signature, and src_port
is the reflected service port for amplification vectors.
The reason object explains why the detection fired — captured once at the start, off
the hot path. threshold_source is static or baseline: whether the crossed limit came
from the static config or a warmed-up learned baseline. When it is
baseline, a baseline object carries the effective math
(min(ceiling, max(floor, normal × factor))). baseline_configured, warming_up and
warmup_remaining_seconds explain a static threshold that applied only because the baseline
had not warmed up yet. shares is the per-protocol fraction of total PPS that drove
classification, and dominant_share_gate is the share one protocol needs to win a vector
(otherwise the attack is mixed). See Detection for
how to read it.
{
"reason": {
"threshold_source": "baseline",
"baseline": { "normal": 1200, "factor": 8, "floor": 5000, "ceiling": 80000 },
"baseline_configured": true,
"shares": { "udp": 0.98, "syn": 0, "tcp": 0.01, "icmp": 0, "frag": 0 },
"dominant_share_gate": 0.5
}
}
GET /api/v1/hosts
Returns a snapshot of every tracked host — the top-talkers data. Each entry is a HostStat.
| Field | Type | Notes |
|---|---|---|
target | string | The host address. |
group | string | The host's hostgroup name. |
rates | object | Current incoming windowed rates. |
rates_out | object | Outgoing rates; only nonzero when outgoing detection is on. |
in_attack | bool | Whether the host is in any active attack. |
metric | string | The metric of the active attack; omitted when not in attack. |
direction | string | The active attack's direction; omitted when not in attack. |
baseline | object | Learned incoming baseline; present when baselines are configured. |
baseline_out | object | Learned outgoing baseline; present when baselines are configured. |
{
"hosts": [
{
"target": "203.0.113.66",
"group": "web",
"rates": {
"pps": 412000,
"mbps": 3100,
"flows_per_sec": 9800,
"udp_pps": 405000
},
"rates_out": {
"pps": 120,
"mbps": 2
},
"in_attack": true,
"metric": "pps",
"direction": "incoming",
"baseline": {
"pps": 950,
"mbps": 7,
"flows_per_sec": 60
},
"baseline_out": {
"pps": 110,
"mbps": 2,
"flows_per_sec": 14
}
}
]
}
The baseline and baseline_out objects carry the learned-normal base trio (pps,
mbps, flows_per_sec) and appear only while EWMA baselines are configured for the host's
group. See Baselines.
GET /api/v1/bans
Returns the full ban table — active and historical. Each entry is a Ban.
| Field | Type | Notes |
|---|---|---|
target | string | The banned address. |
prefix | string | The blackhole prefix (/32 or /128). |
metric | string | The metric that triggered the ban; omitted for manual bans. |
rate | number | Observed rate at ban time; omitted when zero. |
threshold | number | Threshold crossed; omitted when zero. |
next_hop | string | The discard next-hop. |
community | string | The community set attached to the route — the RTBH community for a blackhole, the divert (scrubbing) community for a divert ban — space-joined when more than one. |
local_pref | number | The LOCAL_PREF attached to the route; omitted when zero. |
node | string | The managed scrubbing node this divert ban is frozen to; omitted when the ban does not divert to a managed node. |
route | string | The full route string, or a flowspec: ... summary for FlowSpec bans. |
state | string | active, withdrawn, or rejected. |
dry_run | bool | Whether the ban was virtual. |
manual | bool | true for operator-requested bans. |
started_at | string | RFC 3339 timestamp. |
expires_at | string | TTL expiry; bans are never permanent. |
withdrawn_at | string | When the route was withdrawn; omitted while active. |
reason | string | Why a ban was rejected or withdrawn; omitted otherwise. |
method | string | Mitigation method: blackhole, flowspec, or divert. |
flowspec | array | The generated FlowSpec rules, when method is flowspec. |
escalation | array | The configured escalation ladder, when one is set. |
escalation_step | number | Index of the ladder's current rung. |
{
"bans": [
{
"target": "203.0.113.66",
"prefix": "203.0.113.66/32",
"metric": "pps",
"rate": 412000,
"threshold": 80000,
"next_hop": "192.0.2.1",
"community": "65000:666",
"route": "blackhole 203.0.113.66/32 next-hop 192.0.2.1 community 65000:666",
"state": "active",
"dry_run": false,
"manual": false,
"started_at": "2026-06-13T09:41:07Z",
"expires_at": "2026-06-13T10:41:07Z"
}
]
}
See Mitigation for how TTL, hysteresis and the ban cap shape this lifecycle.
GET /api/v1/audit
Returns the operator-attributed audit trail — who issued each ban, unban or
config_reload, when, and the outcome (including refused actions). Records are
newest-first and the endpoint is tenant-scoped server-side. It takes optional from/to
(RFC 3339), action (ban/unban/config_reload) and target (an IP) query params, and
defaults to the last hour:
{
"available": true,
"events": [
{
"event_time": "2026-06-22 03:14:09",
"action": "ban",
"result": "rejected",
"operator": "alice",
"role": "operator",
"tenant": "customerA",
"target": "203.0.113.66",
"target_type": "host",
"reason": "whitelisted",
"source": "api",
"ban_state": "rejected",
"dry_run": 0
}
]
}
When storage is disabled the response is {"available": false, "events": []}. Note that
audit records encode dry_run as an integer (0/1), not the JSON boolean used elsewhere
on this page. See the dedicated Audit log page for the full field list, the
query rules, and the operator-identity and tenant-scoping model.
POST /api/v1/ban
Manually blackholes an address. The body is a single IP:
POST /api/v1/ban
Content-Type: application/json
{"ip": "203.0.113.66"}
As a runnable command (drop the Authorization header if you have not configured a token yet):
curl -fsS -X POST localhost:8080/api/v1/ban \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ip":"203.0.113.66"}'
The response is the resulting Ban object. A manual ban honors every safety rule that an
automatic ban does:
- A whitelisted target is refused with HTTP
409 Conflictandreason: "whitelisted"; it is never announced. - A target outside the configured
networksis refused with HTTP409andreason: "outside configured networks". - A request that would exceed
max_active_bansis refused with HTTP409andreason: "max_active_bans reached".
In each 409 case the body is still a Ban object with state: "rejected" and the
reason field set. An invalid or unparseable IP returns HTTP 400.
!Dry-run still applies
A manual ban respects the global mode. While dry_run is true, the ban is recorded and
returned with dry_run: true but no route is announced. See the Safety model.
POST /api/v1/unban
Withdraws an active ban for the given address. The body is the same {"ip": "..."} shape
and the same JSON content type is required:
POST /api/v1/unban
Content-Type: application/json
{"ip": "203.0.113.66"}
On success it returns the withdrawn Ban. If there is no active ban for the address, it
returns HTTP 404.
POST /api/v1/config/reload
Re-reads the config file from disk and applies it — the same effect as sending SIGHUP.
The body is empty; the JSON content type is still required. Unlike ban/unban, this
endpoint requires an unscoped (admin) token: a reload swaps the whole config, so a
tenant-scoped token is refused with 403 and config reload is restricted to unscoped (admin) tokens.
{
"reloaded": true,
"dry_run": false,
"thresholds": {
"pps": 80000,
"mbps": 1000,
"flows_per_sec": 20000
}
}
If the new config fails to parse or validate, the running config is left untouched and the
endpoint returns HTTP 400 with the error. See Configuration.
The scrub-node channel
These three endpoints exist for managed scrubbing nodes
— boxes running kapkan scrub. They take the agent role (a scrub node's
credential; see authentication) or an unscoped operator token, never a
viewer or a tenant-scoped token: the documents span every tenant, so scoping them is a fleet
concern, not a per-request one.
GET /api/v1/dataplane/rules
The rule table a node enforces: every active diverted victim, with the
FlowSpec rules to drop and the TTL to mirror. A plain GET returns the current
document with a content-hash ETag; a GET whose If-None-Match names that ETag is held
until the table changes or up to 30 s, then answered 304 — a long-poll that gives sub-second
rule updates over ordinary HTTP. A node identifies itself with ?node=<name>, and that poll is
the node's liveness signal — so the name must be a configured node (an unknown name is 404)
and, outside token-less local mode, the request must carry a real token.
{
"version": 1,
"bans": [
{
"target": "203.0.113.10",
"prefix": "203.0.113.10/32",
"method": "divert",
"expires_at": "2026-08-13T10:00:00Z",
"flowspec": [ { "dst": "203.0.113.10/32", "proto": 17, "action": "discard" } ]
}
]
}
POST /api/v1/dataplane/nodes/{name}/report
A node's advisory self-report — version, XDP mode, node-side dry-run, load and drop totals —
stored for the console. It is never a liveness signal (the poll is): a
report can never keep a dead node marked up. The body is capped at 64 KiB (413 beyond) and an
unknown {name} is 404. Success is 204 No Content.
GET /api/v1/dataplane/nodes
The node inventory the console's Nodes view renders: each configured node with what the brain
knows (config, poll liveness, how many bans divert to it) joined with its last report. A
viewer-rank read, but unscoped tokens only — the inventory names next-hops and hostgroups,
which is deployment topology. The count alone (nodes_total) rides on /api/v1/status for every
role, so a console can decide whether to show node affordances without this call.
GET /metrics
Serves Prometheus metrics in the standard text exposition format. This endpoint is not
under /api/v1 and is served without the bearer token so a scraper can reach it. See
Metrics for the full metric list.
GET /healthz
A liveness/readiness probe, not under /api/v1 and served without a bearer token (it
leaks nothing) so a supervisor or the update.sh upgrade script can
confirm the daemon is up after a restart. It returns 503 starting until every component
has started, then 200 ok. Because the API listener only begins accepting once the daemon
has started, any 200 here means the config parsed, the components are up, and Kapkan is
serving.
curl -fsS localhost:8080/healthz # exits non-zero (503) until ready, then prints "ok"
Related
- Authentication — set a bearer token before exposing the listener.
- Audit log — the operator-attributed trail of mutations.
- Dashboard — the embedded web UI served on the same address.
- Metrics — the Prometheus
/metricsendpoint.