Skip to content

API

The API is a FastAPI application. It reads the database and returns JSON. It also serves a live WebSocket feed, and the web UI once one is built. The API has no Redis access and cannot change data: it connects as the read-only role suckerpot_api.

Every example below uses the dashboard port on the host. With the default DASHBOARD_PORT=8443, and with the default bind address 127.0.0.1, run the examples on the host itself:

http://127.0.0.1:8443

Every /api/v1 path needs a session, except /api/v1/auth/*. /healthz and the static web app stay public. There are two kinds of account: the owner from .env, and users with the role admin or viewer (see Users and roles). A viewer can call every GET that an admin can, except the user and audit endpoints, and nothing that changes data: any other method needs an admin, unless it is sign in, sign out or changing one’s own password, and the API answers 403 with {"code": "admin_required"}.

MethodPathPurpose
POST/api/v1/auth/loginBody {"username": "...", "password": "..."}. Returns 204 and sets the cookies. 401 for wrong credentials, 403 for a cross-origin request, 429 with Retry-After when rate limited.
POST/api/v1/auth/logoutRevokes the session and clears the cookie. Needs the CSRF token. Returns 204.
GET/api/v1/auth/meWho is signed in: username, display_name, role, is_owner, must_change_password, read_only and last_login_at (the sign-in before this one). 401 otherwise. The web app uses it to decide what to show.
POST/api/v1/auth/passwordBody {"current_password": "...", "new_password": "..."}. Changes the signed-in user’s own password and keeps this browser signed in; every other session of the user ends. 403 with reauth_failed for a wrong current password, 422 with weak_password for a rejected new one, 403 with owner_password_in_env for the owner. Allowed while must_change_password is true.
GET/api/v1/auth/csrf{"token": "..."} and sets the CSRF cookie.
CookieFlagsContent
__Host-sp_sessionHttpOnly, Secure, SameSite=Strict, path /, no DomainA signed token with an expiry (SESSION_TTL_HOURS, default 12). No server-side session table.
__Host-sp_csrfReadable by JavaScript, Secure, SameSite=Strict, path /A signed anti-CSRF token, bound to the session

The __Host- prefix makes the browser refuse the cookie unless it is Secure, set over HTTPS, for the exact host and without a Domain. That stops a sibling subdomain from planting or overwriting it. The prefix exists only when COOKIE_SECURE=true (the default). With COOKIE_SECURE=false the cookies are named sp_session and sp_csrf and lose the Secure flag. Use that only for local development over plain HTTP. In production the dashboard must be served over HTTPS (for example by the tunnel).

The session signing key is derived from SESSION_SECRET, the password hash and a random value created at each API start. So changing the password signs everybody out, and so does every restart of the API (including docker compose up -d api after a config change and a container restart). Logging out puts the session into a small in-memory revocation list, and logging in again revokes the previous session.

Every POST, PUT, PATCH and DELETE must send the value of the CSRF cookie in an X-CSRF-Token header. The server checks that the two match and that the token carries a valid signature for the current session, so a cookie planted by another site, or a token from another session, is refused (403). Login is the one exception, because there is no session yet. It requires an Origin (or Referer) check:

  • When API_ALLOWED_ORIGINS is set, the full origin (scheme, host and port, such as https://dash.example.com) must be in the list.
  • When it is empty, the origin’s host must equal the request’s Host header, and the scheme is ignored.

A 401 means “sign in”. A 403 means “bad or missing CSRF token”, or a login from an origin that is not allowed. Call GET /api/v1/auth/csrf once and send the token on every unsafe request.

Login attempts are counted per client address and for the whole server, over a sliding 60 seconds: LOGIN_RATE (default 5) per address and LOGIN_RATE_GLOBAL (default 30) overall. IPv6 addresses are counted per /64, because one subscriber owns a whole /64. Beyond the limit the answer is 429 with a Retry-After header. A wrong password also costs a short fixed delay.

The password hash is always checked, whether or not the user name exists, so timing does not reveal the account name. Argon2 is expensive on purpose, so the API checks one hash at a time. When more than two checks are already waiting, further logins get 429 with Retry-After at once. A flood of login attempts can therefore slow down sign-in, but not the rest of the API.

Failed and successful logins are logged with the client address only. A submitted user name, password, hash or cookie is never logged.

Behind a reverse proxy, every request seems to come from the proxy. Set AUTH_TRUSTED_PROXIES to the proxy’s addresses as a comma-separated list of CIDR ranges. The API then reads CF-Connecting-IP, or else the rightmost X-Forwarded-For entry that is not itself a trusted proxy, but only when the connection comes from one of those addresses. From anyone else the headers are ignored, so nobody can fake their address. With the default (empty), the headers are never read.

The API answers only to host names it expects (a “trusted host” check), which blocks Host-header tricks. API_ALLOWED_HOSTS (comma-separated host names) sets the list. Without it, the hosts of API_ALLOWED_ORIGINS are used. localhost and 127.0.0.1 are always allowed. When both variables are empty the API allows any host and logs a warning. In production, set one of them, for example to the tunnel’s host name.

The live WebSocket checks the session cookie in its handshake. Without a valid session the connection is closed with code 1008. While it is open, the server rechecks the session every 5 seconds and closes with 1008 when it has expired or been revoked. The browser sends the cookie on its own for a same-site WebSocket.

The revocation list, the per-boot signing key and the login counters live in the memory of one process. Run the API with one uvicorn worker (the image’s default command does). With several workers, a logged-out session could still work on another worker, and the login limits would multiply.

  1. Generate the password hash. The command prompts twice without echo and prints the hash:

    Terminal window
    docker compose run --rm api python -m api.hashpw
  2. Put it in .env, in single quotes. An argon2 hash contains $ characters, which Compose would otherwise try to expand as variables:

    ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$...'
  3. Set SESSION_SECRET to at least 32 random bytes (openssl rand -base64 48).

  4. Start the stack. The API refuses to start when the hash or the secret is missing or weak.

See Configuration for every variable.

The OpenAPI pages are off by default. To turn them on while you develop, set API_ENABLE_DOCS=true in .env and restart the API. Then the following paths are served:

PathContent
/docsSwagger UI
/redocReDoc
/openapi.jsonRaw OpenAPI schema

These pages are generated from the code, so they match the running version. Use them for the exact field list. Turn them off again before you expose the port.

  • Times are ISO 8601 in UTC, for example 2026-10-24T18:02:11Z.
  • Session lists use keyset pagination. Each list response has items and next_cursor. Pass next_cursor back as cursor to get the next page. When next_cursor is null, there are no more items.
  • The cursor is opaque. Do not build it yourself. An invalid cursor returns 400.
  • limit must be between 1 and 200.
  • Session IDs match ^[A-Za-z0-9_-]{1,64}$. Other values are rejected.
  • IP address filters are normalised. An invalid address returns 422.
  • Errors use the FastAPI shape: {"detail": "..."}.
MethodPathPurpose
GET/healthzHealth check. Checks the database.
GET/api/v1/events/recentThe newest events, newest first
GET/api/v1/sessionsList sessions, newest first
GET/api/v1/sessions/{session_id}One session
GET/api/v1/sessions/{session_id}/eventsEvents of one session, oldest first
GET/api/v1/stats/summaryTotals for the last 24 hours
GET/api/v1/todayThe cards of the Today page (M19)
GET/api/v1/searchGlobal search (M19)
GET/api/v1/stats/benignSessions and addresses from research scanners in the last 24 hours (M19)
GET/api/v1/settings/systemSensor status, retention policy, disk guard state and database size. See System settings.
GET/api/v1/samplesFiles fetched by the collector, with filters and cursor paging. See Samples and egress.
GET/api/v1/samples/{sha256}One sample with its analysis, sources and links
GET/api/v1/samples/{sha256}/chainThe chain session, URL, sample and C2 of one sample
POST/api/v1/samples/{sha256}/archiveDownload a sample as a password-protected zip (administrators only)
GET/api/v1/egressState of the egress gate, collector quota and quarantine use
GET/api/v1/abuse/...Abuse reports, recipients, settings, self terms and exports (M22). See Abuse reports.
GET, PUT, DELETE/api/v1/abuse/smtpThe optional SMTP profile for sending reports (administrators only). See Sending abuse reports.
GET/api/v1/abuse/send-queueReady reports, one page at a time, for the To send view (administrators only). See Sending abuse reports.
GET, PUT, DELETE/api/v1/settings/vpnThe WireGuard configuration of the vpn service and the trusted exits (administrators only). See VPN settings.
GET/api/v1/setup/statusThe setup checklist (administrators only). See Setup checklist.
GET/api/v1/settings/enrichmentGeoIP database status and reputation quotas. See Enrichment status.
PUT/api/v1/settings/providers/{provider}Set or replace an enrichment API key. See Provider keys.
DELETE/api/v1/settings/providers/{provider}Remove a key set in Settings. See Provider keys.
POST/api/v1/settings/providers/{provider}/testAsk ingest to test the key with one real call. See Provider keys.
WebSocket/api/v1/liveLive feed of new events and statistics. See Live feed.

Returns 200 when the API can reach the database. Returns 503 when it cannot. Docker uses this endpoint for the container health check.

{
"status": "ok",
"db": "ok"
}

When the database is unreachable, the response has status 503 and this body:

{
"status": "degraded",
"db": "error"
}

The newest events, newest first. This is the REST version of the WebSocket backfill. It returns the live view of an event, without the raw data object.

Query parameterDefaultMeaning
limit50Number of events, 1 to 200

Example request:

Terminal window
curl -s "http://127.0.0.1:8443/api/v1/events/recent?limit=2"

Example response (fake values, documentation addresses only):

{
"items": [
{
"id": 1842,
"ts": "2026-10-24T18:02:14Z",
"sensor": "ssh",
"session_id": "k3J9x2_a7Qe",
"type": "login_attempt",
"src_ip": "198.51.100.23",
"src_port": 51422,
"dst_port": 22,
"summary": "login root/123456 failed",
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"tags": []
},
{
"id": 1841,
"ts": "2026-10-24T18:02:11Z",
"sensor": "ssh",
"session_id": "k3J9x2_a7Qe",
"type": "connect",
"src_ip": "198.51.100.23",
"src_port": 51422,
"dst_port": 22,
"summary": "connected",
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"tags": []
}
]
}

summary is a one-line description built by the API. Attacker-controlled text is cleaned (control characters are replaced) and shortened.

Lists sessions, newest first. Order: started_at descending, then session_id.

Query parameterDefaultMeaning
limit50Items per page, 1 to 200
cursornoneCursor from the previous page
sensornoneOnly sessions from one sensor: ssh, telnet, adb, web, docker, redis, mysql, mssql, rdp, smb, sip or synology. Repeat the parameter or separate names with commas for several sensors.
src_ipnoneOnly sessions from one source address
tagnoneOnly sessions that carry this tag. Lower-case letters, digits and -, up to 32 characters (for example crypto-miner).
countrynoneOnly sessions from this country. Two letters (ISO 3166-1), case-insensitive.
asnnoneOnly sessions from this autonomous system number (an integer)
sincenoneOnly sessions that started at or after this time (ISO 8601, with a time zone)
untilnoneOnly sessions that started before this time

Example request:

Terminal window
curl -s "http://127.0.0.1:8443/api/v1/sessions?sensor=ssh&limit=2"

Example response (values are fake, and use documentation addresses):

{
"items": [
{
"session_id": "k3J9x2_a7Qe",
"sensor": "ssh",
"src_ip": "198.51.100.23",
"src_port": 51422,
"dst_port": 22,
"started_at": "2026-10-24T18:02:11Z",
"ended_at": "2026-10-24T18:02:19Z",
"last_event_at": "2026-10-24T18:02:19Z",
"event_count": 4,
"login_attempts": 3,
"login_success": false,
"commands": 0,
"downloads": 0,
"http_requests": 0,
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"client_tool": null,
"tags": []
},
{
"session_id": "Q8mW1-zT4bA",
"sensor": "ssh",
"src_ip": "203.0.113.77",
"src_port": 40018,
"dst_port": 22,
"started_at": "2026-10-24T17:48:03Z",
"ended_at": "2026-10-24T17:49:40Z",
"last_event_at": "2026-10-24T17:49:40Z",
"event_count": 12,
"login_attempts": 1,
"login_success": true,
"commands": 6,
"downloads": 1,
"http_requests": 0,
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"client_tool": null,
"tags": []
}
],
"next_cursor": "WyIyMDI2LTEwLTI0VDE3OjQ4OjAzKzAwOjAwIiwiUThtVzEtelQ0YkEiXQ"
}

The cursor in the example is opaque and only shows the format. Do not decode or edit it.

The enrichment fields (country, city, asn, org, abuse_score, known_scanner) are null and tags is empty until ingest has enriched the session. Enrichment and tagging run in ingest (M4). Ingest has to download a GeoIP database first, so right after a first start the fields stay null for a short while. Sessions that were stored before enrichment was running are not enriched afterwards.

client_tool is a best-effort guess at the client software of an RDP session (see RDP sensor). It is null for every other sensor, and for RDP when the guess is unknown.

Filters combine with AND. since and until need a time zone (2026-10-24T00:00:00Z). An invalid value returns 422.

Returns one session, with the same fields as a list item. Returns 404 when the session does not exist.

{
"detail": "session not found"
}

Returns the events of one session, oldest first. Order: ts ascending, then id.

Query parameterDefaultMeaning
limit100Items per page, 1 to 200
cursornoneCursor from the previous page

Example response:

{
"items": [
{
"id": 1841,
"ts": "2026-10-24T18:02:11Z",
"sensor": "ssh",
"session_id": "k3J9x2_a7Qe",
"type": "connect",
"src_ip": "198.51.100.23",
"src_port": 51422,
"dst_port": 22,
"data": {
"client_version": "SSH-2.0-libssh_0.9.6"
},
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"tags": []
},
{
"id": 1842,
"ts": "2026-10-24T18:02:14Z",
"sensor": "ssh",
"session_id": "k3J9x2_a7Qe",
"type": "login_attempt",
"src_ip": "198.51.100.23",
"src_port": 51422,
"dst_port": 22,
"data": {
"username": "root",
"password": "123456",
"success": false,
"method": "password"
},
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"tags": []
}
],
"next_cursor": null
}

The data object follows the recommended keys in Event schema. An event cursor is bounded: an id outside 0 to 2^63-1 returns 400.

Totals for the last 24 hours (window_hours is 24). The by_sensor object has one entry per sensor that has data.

Example response (fake values):

{
"window_hours": 24,
"events": 18342,
"sessions": 1260,
"unique_ips": 417,
"sessions_with_commands": 88,
"download_attempts": 14,
"by_sensor": {
"rdp": {"events": 1204, "sessions": 150},
"redis": {"events": 412, "sessions": 38},
"ssh": {"events": 12010, "sessions": 910},
"telnet": {"events": 3580, "sessions": 280},
"web": {"events": 1548, "sessions": 120}
}
}

Read-only data for the Settings page. Every block becomes null or empty when its source is not available, so a partly migrated database never breaks the page.

FieldContent
sensorsOne entry per sensor: sensor, last_event_at, events_last_hour and status: active (an event in the last 15 minutes), quiet (in the last 24 hours) or silent
retentionraw_days (when raw events are dropped), compression_after_days, and the names of the aggregates that are kept forever
disk_guardThe state written by the disk guard job (see Data retention), or null
ingest_diskThe state ingest reports for its own payload trimming, or null
databasesize_bytes and max_bytes (from DB_MAX_SIZE_GB)

The status of the enrichment providers and today’s quota use. It never returns an API key. The Settings page will use it.

{
"geoip": {
"provider": "dbip",
"available": true,
"files": {
"city": {"file": "dbip-city-lite.mmdb", "bytes": 131072000, "updated_at": "2026-10-01T03:00:00Z", "age_days": 23.6}
},
"last_error": null,
"refresh_days": 30,
"attribution": "IP geolocation by DB-IP.com (https://db-ip.com), CC BY 4.0"
},
"abuseipdb": {
"configured": true, "source": "settings", "last4": "a1b2", "updated_at": "2026-10-25T09:00:00Z",
"secret_error": null, "pending_apply": false,
"test": {"ok": true, "error": null, "checked_at": "2026-10-25T09:01:00Z"},
"test_pending": false, "test_requested_at": "2026-10-25T09:00:50Z",
"daily_quota": 1000, "used_today": 12, "disabled": null, "paused": false, "last_error": null
},
"greynoise": {"configured": false, "source": null, "last4": null, "updated_at": null, "secret_error": null,
"pending_apply": false, "test": null, "test_pending": false, "test_requested_at": null,
"daily_quota": null, "used_today": 0, "disabled": null, "paused": false, "last_error": null},
"maxmind": {"configured": false, "source": null, "last4": null, "updated_at": null, "secret_error": null,
"pending_apply": false, "test": null, "test_pending": false, "test_requested_at": null},
"key_editing": {"enabled": true, "reason": null},
"reported_at": null
}

For each provider, source is env (the key comes from .env), settings (set on the Settings page) or null (no key). last4 and updated_at are set only for keys saved in Settings. secret_error is cannot_decrypt or no_encryption_key when a saved key cannot be used; ingest then falls back to the .env key. pending_apply is true for the few seconds before ingest picks up a change. test is the last “Test key” result (error is one of no_key, quota_exhausted, auth, rate_limited, error). key_editing.enabled is false when SETTINGS_ENCRYPTION_KEY is not set, with the reason in reason.

provider is maxmind, dbip, off, or unknown when ingest has not reported yet. The attribution text must be shown wherever DB-IP data is displayed.

Every request below needs an administrator and, when it changes something, the administrator’s own password in the body (password), the same re-authentication as the provider keys. Each change writes an audit entry. A user who must still choose a password gets 403 with password_change_required from every other endpoint.

MethodPathPurpose
GET/api/v1/usersowner (read-only), items (users by id) and next_cursor. Query: limit (1 to 200, default 50), cursor.
POST/api/v1/usersBody username, display_name, role, password. 201 with the user and a one-time temporary_password (Cache-Control: no-store). 409 with username_taken (also for the owner’s name).
PATCH/api/v1/users/{id}Body role, disabled or display_name (at least one) and password. A role change or a disable ends the user’s sessions.
POST/api/v1/users/{id}/reset-passwordBody password. Returns a new temporary_password, ends the user’s sessions and makes them choose a password again.
POST/api/v1/users/{id}/unlockBody password. Clears the lock after repeated wrong passwords.
POST/api/v1/users/{id}/revoke-sessionsBody password. Ends every session of the user. 204.
DELETE/api/v1/users/{id}Body password. 204.
GET/api/v1/auditNewest first. Query: actor (exact name, any case), action (a name such as user.create, or a prefix such as user.), limit (1 to 200, default 50), cursor.

An administrator cannot disable, demote, reset or delete their own account (409, self_action). Audit actions: auth.login, user.create, user.role, user.disable, user.enable, user.rename, user.reset_password, user.unlock, user.locked, user.revoke_sessions, user.delete, password.change, provider_key.set, provider_key.remove, provider_key.test.

provider is abuseipdb, greynoise or maxmind. A key is never returned by any endpoint.

RequestBodyAnswer
PUT{"key": "...", "password": "<admin password>"}, plus "account_id": "<digits>" for maxmind200 with provider, source, last4, updated_at
DELETE{"password": "<admin password>"}204
POST .../testnone202 with requested_at. Poll GET /api/v1/settings/enrichment for test.

Changes need the session cookie, the X-CSRF-Token header, a same-origin request and the admin password again. Errors carry a code: editing_disabled (503, no SETTINGS_ENCRYPTION_KEY), invalid_key (422, wrong length or characters), reauth_failed (403, wrong password), origin (403), rate_limited (429, shared with the login limit), no_key (409), cooldown (429, one test per 20 seconds), daily_cap (429, 10 tests per provider per day) and session_revoked (401, five wrong passwords in a row). A 422 never repeats the submitted values.

All analytics endpoints are read-only, run as the suckerpot_api role with the 5 second statement timeout, and use TimescaleDB time_bucket queries. Lists are paginated or capped (limit at most 200). Times are ISO 8601 UTC.

Common optional filters: since and until (with a time zone; the default window is the last 24 hours for stats, with 30 days as the maximum) and sensor (any of the twelve sensor names: ssh, telnet, adb, web, docker, redis, mysql, mssql, rdp, smb, sip, synology).

MethodPathPurpose
GET/api/v1/stats/timeseriesEvents and sessions per time bucket and sensor. bucket is 5m, 1h or 1d. Empty buckets are filled with zeros. At most 500 points.
GET/api/v1/stats/topThe most frequent values of one field: country, asn, org, username, password, command, url, user_agent, tag or src_ip. Returns value, count, share, first_seen and last_seen. limit defaults to 10.
GET/api/v1/stats/heatmap168 cells (day of week, Monday is 0, by hour, in UTC) with an event count each
GET/api/v1/attackersAttacker profiles, one per source address. Sort by last_seen, sessions, events or first_seen. Filters: country, asn, sensor, tag, q (address prefix). Cursor pagination.
GET/api/v1/attackers/{ip}One profile, with the 20 most recent sessions, the top usernames and commands, and a 30-day timeline
GET/api/v1/asnsNetworks, sorted by sessions, ips or events: asn, org, country, ips, sessions, events, last_seen

An attacker profile has: ip, first_seen, last_seen, sessions, events, sensors, login_attempts, login_success, commands, downloads, country, city, asn, org, abuse_score, known_scanner and tags.

Example, a top list (fake values):

{
"field": "username",
"total": 1280,
"items": [
{"value": "root", "count": 540, "share": 0.42, "first_seen": "2026-10-01T02:11:00Z", "last_seen": "2026-10-24T17:58:00Z"}
]
}

The target is under 300 ms per endpoint on the default demo data set (about 3,000 sessions and 25,000 events).

Ingest labels sessions from known research scanners (ingest/rules/scanners.yaml) with benign=true and scanner_name. Every list, chart and summary endpoint leaves them out unless the request has include_benign=true: /sessions, /attackers, /asns, /stats/summary, /stats/timeseries, /stats/top, /stats/heatmap, /events/recent, /insights, /trends/new, /trends/tags, /today and the WebSocket /live (?include_benign=1). /sessions also returns them when src_ip names one address. Sessions carry benign and scanner_name; live events carry benign. The continuous aggregates are not split by benign.

{"window_hours": 24, "sessions": 1204, "sources": 31}: what the “hide research scanners” switch hides.

Query q (1 to 512 characters, cut to 128). Under two characters the answer is empty. The answer is {"q": "...", "groups": [{"kind": "ip|campaign|cve|hash|domain", "hits": [...], "more": false}]} with at most five hits per group. Every lookup uses an index: an exact or dotted-prefix address (203.0.113. stands for the /24), a complete CVE id, a file-hash prefix of eight characters or more, a domain or URL prefix, a campaign-name prefix or id. A hit has kind, value (address, CVE id, indicator id or campaign id), label (indicators are defanged), detail, count, last_seen and benign. Research scanners are included on purpose. Statements time out after 3 seconds.

{"generated_at", "window_hours": 24, "hidden_benign": 0, "cards": [...]}. Each card has id (happened, campaigns, act, new_week, exposure), a sentence, up to three items (kind, value, label, detail, count, to = a web route with its filters), one action (label, to) and empty. The exposure card is only present once the service inventory has rows. The answer is cached for a minute per include_benign value.

All of these are read-only and need a session.

Up to three short findings, written by fixed rules (no language model). Each is one plain sentence plus the numbers behind it and a link to the filtered dashboard view.

Query parameterDefaultMeaning
since, untilthe last 7 daysThe window to describe, with a time zone. At most 30 days. The previous period of the same length is the comparison.
{
"generated_at": "2026-10-24T18:05:00Z",
"items": [
{
"id": "surge-tag-php2shell",
"kind": "surge",
"severity": "severe",
"sentence": "PHP2Shell exploit attempts tripled since Tuesday (312 vs 104).",
"evidence": {"metric": "sessions_by_tag", "current": 312, "previous": 104, "ratio": 3.0},
"link": "/attacks?tag=php2shell&since=2026-10-20T00:00:00Z&until=2026-10-24T18:05:00Z"
}
]
}

kind is surge, new_value, campaign, exploit, success or geo_shift. severity is info, notice, warning or severe.

The rules. A rule produces a finding only when the volume is large enough, so a quiet host never says “tripled (3 vs 1)”. Candidates are ranked by severity first, then by size, and the top three are returned. All times are UTC, so “since Tuesday” and “midnight” are UTC days.

KindFires whenWording
exploitA session tagged php2shell or malware-drop appears for the first time, with none in the 90 days before“PHP2Shell exploit attempts appeared for the first time: 12 sessions in the last 7 days, none in the 90 days before.” Severity severe.
surgeSessions for a tag or a sensor: at least 20 now, at least 5 before, and at least 1.5 times the previous period”
successSessions with a successful login: at least 5 now, at least 2 before, and at least twice the previous period“Successful logins doubled since yesterday (12 vs 5).” Severity warning.
campaignAt least 50 sessions in total, and one network (ASN) has 40% or more of them, or one address has 30% or more“AS64496 (Example Net) accounts for 62% of all sessions in the last 7 days (620 of 1,000).”
geo_shiftAt least 50 sessions in both periods, and a country with at least 20 sessions, 20% or more of the traffic, and at least double its previous share“Sessions from NL rose from 8% to 25% of all traffic in the last 7 days.”
new_valueA username, command, URL or ASN never seen in the 90 days before, seen at least 10 times“A command never seen before showed up 14 times in the last 7 days: …” Attacker-controlled text has control characters removed and is cut to 60 characters.

Values that appeared in the window and not in the 90 days before it.

Query parameterDefaultMeaning
fieldrequiredusername, password, command, url, asn or user_agent
days7Window length, 1 to 30 days, ending now
limit501 to 200

Returns {"field": "...", "items": [{"value": "...", "count": 14, "first_seen": "..."}]}, most frequent first.

Sessions per tag over time, for the “tags over time” chart.

Query parameterDefaultMeaning
bucket1d5m, 1h or 1d
since, untilthe last 7 daysWindow, at most 30 days and 500 buckets

Returns series (the eight most frequent tags, plus other when there are more), and points, one per bucket, each with a ts and a count per series entry. Empty buckets are zero.

Read-only and passive: nothing is fetched from an indicator. Values come both raw (value) and defanged (defanged, such as hxxp://files[.]host[.]tld/a.sh).

EndpointWhat it returns
GET /api/v1/indicatorsKeyset-paginated list (cursor, limit 1 to 200). Filters: type (url, domain, ip, hash), role (download_host, contacted_host, hash), known (true or false), family, since, hours (seen in the last N hours, 1 to 2160, the graph’s window), q (literal substring). IPv6 values are defanged too (2001[:]db8[:][:]1). Each item has sightings, sessions, distinct source addresses, sensors and the intel verdict (checked, known, family).
GET /api/v1/indicators/c2Probable command-and-control addresses of malware dropped here, both roles, read-only. Keyset-paginated (cursor, limit 1 to 200, default 50). Filters: min_confidence (medium, the default, or high), kind (ip or domain), hours (hashes dropped in the last N hours, 1 to 2160, default 720). Each item has address, defanged, kind, port, confidence, sources, families, hashes (up to 10 of {sha256, family}), hash_count, sessions (our sessions that dropped those hashes), first_seen, last_seen and references (links to VirusTotal and ThreatFox only). Best confidence first, then most recent. See Probable C2 addresses.
GET /api/v1/indicators/{id}One indicator with the intel answers (source, family, threat type, tags, first reported, reference link), recent sessions, source addresses and indicators seen in the same sessions.
GET /api/v1/infrastructure/graphNodes and edges linking source addresses, indicators (hosts and hashes) and malware families for the last hours (1 to 2160, default 168), with a summary and truncated. At most 300 nodes.
GET /api/v1/indicators/exportA download of up to 5,000 indicators matching the same filters, as format=csv or format=stix (a STIX 2.1 bundle of indicator objects).

Download URLs recorded from download_attempt events, one row per URL, most recently seen first. Nothing is fetched.

Query parameterDefaultMeaning
limit501 to 200
cursornoneFrom the previous page
toolnoneOnly URLs seen with this tool (wget, curl, tftp, …)
qnoneCase-insensitive text search in the URL

Each item has url (cut to 512 characters), host, first_seen, last_seen, attempts, sessions, ips, tools, sensors, and sha256, size and virustotal_url. The last three are null until payload fetching exists, which is off and not implemented.

?url=<the exact URL>. Returns the same item plus up to 20 recent sessions that used the URL. 404 when the URL is unknown. The URL is a query parameter, not a path part, so it needs no special escaping rules.

The home point of the Live map, where the attack arcs land, as a country-level point.

{"home": {"lat": 46.8, "lon": 8.2, "label": "Switzerland"}}

It comes from SITE_HOME_LAT, SITE_HOME_LON and SITE_HOME_LABEL. The values are rounded to one decimal place (about 11 km) on purpose, so the public page never shows an exact location. Do not put the real address of the host in them.

Samples are the files the collector fetched from captured download URLs and the analyzer examined. The API reads the database and the quarantine volume read-only. It never serves a raw file. All routes need a signed-in user; the archive route needs an administrator.

Cursor pagination: cursor and limit (up to 200, default 50). Sorted by last_seen, newest first.

ParameterMeaning
kindFile kind, such as elf, pe, script (case-insensitive)
archArchitecture, such as arm, mips, x86-64
familyFamily set by a YARA rule
yaraName of a YARA rule that matched
statusAnalysis state: pending, done, failed or timeout
qA hash prefix or part of a source URL (1 to 128 characters)
sinceOnly samples seen since this time

Metadata (kind, architecture, size, sha256, sha1, md5, TLSH, family, severity, whether the file is still stored), the analysis (YARA hits, capped strings, deobfuscated script text and the steps taken), the sources (URLs, first and last fetch, session count, sensors and addresses), the links found inside it (next-stage URLs and C2 addresses), the latest sessions that dropped it, and virustotal_url. That is a link only: the API makes no call to VirusTotal. Defang URLs when you paste them anywhere.

{nodes, edges} for the story session, then URL, then sample, then C2. Bounded to three levels and 100 nodes. Node kinds: session, url, sample, c2.

Administrators only. The body is {"password": "<your dashboard password>"}: the password is asked again. The request also needs the CSRF header and an allowed origin like every write. At most 10 downloads per hour per user (429 with Retry-After beyond that). The answer is an application/zip attachment: AES-256, password infected (the convention that keeps mail scanners quiet, not a secret), one entry named after the sha256, with Cache-Control: no-store and X-Content-Type-Options: nosniff. Each download is written to the audit log. 404 if the file is no longer stored.

The state of the anonymous egress, for the Settings card.

FieldMeaning
ingest, collector{state, reason, checked_at} of each gate. state is open or closed. reason is a constant code such as no_vpn, tunnel_down, echo_failed, echo_disagree, ip_mismatch or ip_not_allowed.
exit_ip_allowedWhether the VPN exit address is in the allowed network
exit_ipThe VPN exit address (never the home address). The web app hides it until you ask.
collector_quota{used, limit} of fetches today
quarantine{used_bytes, max_bytes}

GET /api/v1/malware and /api/v1/malware/detail now fill sha256, size and virustotal_url from the fetched samples, and add fetch_status (for example fetched, pending, failed, refused, unsupported).

The API prepares reports and never talks SMTP itself: optional sending (M25) queues a request that ingest delivers through the VPN. Every GET is open to both roles, except settings and self terms (administrators only). Every write needs an administrator, the CSRF header and an allowed origin. Lists use cursor pagination (limit up to 100).

Every render, download and export runs the identity guard before it returns. A failure answers 409 with {"detail", "code": "identity_guard_failed", "reasons": [...]}. Reasons are constant codes, never the matched value (see Security). When the reply contact is empty the answer is 409 with code: "settings_incomplete" and missing: ["reply_contact"]. It is the only required setting: the introduction, closing, note, signature, subject template and X-ARF user agent are optional (an empty part is left out; an empty subject template gives Abuse report for {value}, an empty user agent gives Mozilla/5.0).

MethodPathPurpose
GET/api/v1/abuse/statusWorker on or off, gate state, RDAP quota, whether a self address is known (never the value), counts per status
GET/api/v1/abuse/reportsReports, newest change first. Filters: status, kind, recipient, demo.
GET/api/v1/abuse/reports/{public_id}One report: subjects, guard result, redaction counters, revision, history and the send state (send_state, M25)
GET/api/v1/abuse/reports/{public_id}/renderformat=text, xarf or eml, optional subject. Returns To, Subject, body, attachments and revision.
GET/api/v1/abuse/reports/{public_id}/downloadA file: format=txt, eml, evidence_csv or xarf_yaml. Attachment, no-store, nosniff. Audited.
POST/api/v1/abuse/reports/{public_id}/statusBody {to, revision, sent_at?}. 409 on a stale revision or an invalid change. Audited.
PATCH/api/v1/abuse/reports/{public_id}Notes and options of an open report
POST/api/v1/abuse/reports/{public_id}/regenerateAsk ingest to rebuild the draft. A reviewed report goes back to draft.
GET, PATCH/api/v1/abuse/recipients, /{id}List recipients. Mute one or set an override mailbox.
GET, PUT/api/v1/abuse/settingsReply contact, signature, wording, X-ARF user agent, default options. Administrators only. Every text field is checked by the guard. The audit log records field names, never values.
GET, POST, PATCH, DELETE/api/v1/abuse/self-terms, /{id}The “never in a report” list. Administrators only. Values are masked unless reveal=true. Confirm a learned term with PATCH.
GET/api/v1/abuse/exports/stix, /mispIndicators of one report, one campaign or a since/until window, with a tlp (default TLP:GREEN). Audited.
POST/api/v1/abuse/exports/samplesUp to 10 stored samples in one AES-256 zip (password infected) with a hash table. Administrators only, password asked again, 5 per hour. A sample that contains a self term is excluded and counted in X-Excluded.

Statuses: draft, reviewed, sent, closed, discarded, superseded. Kinds: attack_source, payload_host, c2.

Sending is optional and off by default. An administrator saves an SMTP profile and picks a mode in Settings > Reporting > Sending. The API never opens a connection to a mail server: it stores the profile and writes rows in abuse_send_requests. Ingest sends the mail through the VPN. Setup steps are in Deployment.

Every route in this section is administrator only, including the GET routes. A viewer gets 403 with admin_required. Writes need the CSRF header and an allowed origin. Some routes also ask for the administrator’s password again:

RoutePassword in the body
PUT /api/v1/abuse/smtpcurrent_password
DELETE /api/v1/abuse/smtpcurrent_password
POST /api/v1/abuse/smtp/testcurrent_password
POST /api/v1/abuse/reports/{public_id}/sendnone
POST /api/v1/abuse/reports/{public_id}/send/cancelnone

The re-authentication rules are the same as for the provider keys (see Provider keys): a wrong password is 403 with reauth_failed, and five wrong passwords in a row end the session (401, session_revoked). Send and cancel do not ask for the password. The web app asks for a confirmation instead, and ingest runs the identity guard again before any mail leaves.

The profile. The password is never returned. password_set says whether one is stored.

{
"configured": true,
"password_set": true,
"host": "smtp.example.org",
"port": 587,
"security": "starttls",
"username": "[email protected]",
"from_address": "[email protected]",
"from_name": "",
"mode": "click",
"auto_hold_minutes": 60,
"daily_cap": 20,
"sent_today": 2,
"last_test": {"status": "sent", "error_code": null, "requested_at": "2026-10-30T09:00:00Z", "done_at": "2026-10-30T09:00:41Z"},
"updated_at": "2026-10-30T08:55:00Z",
"updated_by": "admin"
}
  • configured is true when a host and a sender address are saved, and either a password is stored or the username is empty.
  • sent_today counts the reports sent since midnight UTC. The daily cap applies to automatic sends only.
  • last_test is the latest test message, or null. Read it here after you ask for a test.
  • Without a saved profile the answer is the same shape with configured and password_set set to false, host and from_address empty, and mode set to off.

Saves the profile. The answer is the new GET shape.

FieldRule
hostA DNS name such as smtp.example.org. Not an IP address, not a single label, not an internal name (local, lan, internal and similar).
port1 to 65535
securitystarttls or ssl. Plain text is not offered. The certificate is always checked.
usernameOptional, up to 254 characters, no control characters
from_addressA mailbox on a public domain, for example [email protected]
from_nameOptional, up to 100 characters: letters, digits, spaces and .,'()_-
passwordThe SMTP password, up to 256 characters. Leave it out to keep the stored one. Required on the first save. Stored encrypted and never returned.
modeoff (default), click or auto. See Sending reports.
auto_hold_minutes10 to 1440, default 60
daily_cap1 to 200, default 20
current_passwordThe administrator’s own password

from_name and from_address go through the identity guard. A value that points to the owner is refused.

StatusCodeMeaning
422invalid_hostThe host is not a public DNS name
422invalid_from_addressThe sender address is not a valid mailbox
422invalid_from_nameThe sender name has characters that are not allowed
422invalid_credentialsA control character in the username or the password
422identity_guard_failedThe name or address failed the identity guard. The answer lists the constant reasons, never the value.
422password_requiredFirst save without a password
503editing_disabledSETTINGS_ENCRYPTION_KEY is not set, so no password can be stored
409already_sendingA report mail is being sent right now. Try again in a minute.
403reauth_failedWrong current_password

Changing the mode to anything but auto makes ingest cancel the automatic requests still waiting, with the error code mode_changed. The audit log records the changed field names, never the values.

Body {"current_password": "..."}. Removes the profile and the stored password. Every pending request is cancelled. Answers 204. Also 409 with already_sending while a mail is in flight.

Body {"current_password": "..."}. Queues a short neutral message to from_address itself. Answers 202:

{"id": 4123, "status": "pending", "scheduled_at": null}
StatusCodeMeaning
409smtp_not_configuredSave the profile first
409already_queuedA test is already waiting. Only one test at a time.
403reauth_failedWrong password

If the VPN gate is closed, a test waits for up to two minutes and then fails with egress_closed. Read the result in last_test of GET /api/v1/abuse/smtp.

The ready reports, oldest first, one page at a time. This feeds the To send view. A report is ready when it is a draft or reviewed, it is not a demo, its recipient is known and not muted, and the identity guard passes right now.

ParameterMeaning
limit1 to 25, default 10
cursorThe next_cursor of the previous page. Opaque. An invalid cursor returns 400 with invalid_cursor.
{
"items": [
{
"id": "3f2c9a1e-5b7d-4e8a-9c21-7d4b6e0f8a12",
"recipient": "[email protected]",
"subject": "Report: login attempts from 198.51.100.7",
"body_text": "Plain-text body of the report ...",
"scheduled_at": null,
"last_request": null
}
],
"next_cursor": null,
"mode": "off",
"missing": []
}
  • id is the report’s public ID, the same value as in the report routes.
  • last_request is the newest send request of the report, or null. scheduled_at is set while an automatic send waits for its hold time.
  • A report that fails the guard when it is read is left out. A page can therefore hold fewer than limit items while next_cursor is still set. Keep paging until next_cursor is null.
  • When the reply contact is still empty, items is empty and missing lists it.
  • mode tells the web app whether the “Send now” button is shown.

POST /api/v1/abuse/reports/{public_id}/send

Section titled “POST /api/v1/abuse/reports/{public_id}/send”

Queues one report for sending. The mode must be click or auto. The body is optional. Send {"revision": 3} to make sure the draft you read is still the current one. Answers 202 with the new request, for example {"id": 4124, "status": "pending", "scheduled_at": null}. Ingest sends it later, through the VPN.

StatusCodeMeaning
404not_foundNo such report
409smtp_not_configuredNo profile saved
409smtp_offThe mode is off
409revision_mismatchThe draft changed since you read it
409identity_guard_failedThe guard refused the report. The answer lists reasons.
409demo_reportDemo reports are never sent
409report_not_readyThe report is not a draft or reviewed, it is muted, or it has no mailbox
409already_queuedA request for this report is already waiting or sending
409settings_incompleteThe reply contact is empty. The answer lists missing.
409evidence_invalidThe stored evidence could not be rendered

POST /api/v1/abuse/reports/{public_id}/send/cancel

Section titled “POST /api/v1/abuse/reports/{public_id}/send/cancel”

Cancels the requests of one report that have not started. Answers 200 with {"cancelled": 1}.

StatusCodeMeaning
404not_foundNo such report
409nothing_to_cancelNothing is waiting for this report
409already_sendingIngest has started the connection. The mail may already be on its way, so it cannot be cancelled.

GET /api/v1/abuse/reports/{public_id} adds send_state:

"send_state": {
"scheduled_at": "2026-10-30T10:00:00Z",
"last_request": {"status": "pending", "error_code": null, "requested_at": "2026-10-30T09:00:00Z", "done_at": null}
}

last_request is null when the report was never queued. scheduled_at is set only while an automatic send waits for its hold time.

StatusMeaning
pendingWaiting for its hold time, for the VPN gate, or for ingest
sendingIngest has started the connection. It cannot be cancelled any more.
sentThe server accepted the mail. The report is now sent, with sent_via set to smtp.
failedNot sent. error_code says why. The report stays ready.
cancelledCancelled by the owner, cancelled because the mode changed, or the profile was removed.

These are stored in error_code. The values are constants. No server message, address or body text is ever stored.

CodeMeaning
auth_failedThe server refused the login
tls_failedTLS could not be set up, or the certificate did not match the host
refusedThe server refused the sender or the recipient
timeoutThe server did not answer in time
networkA connection problem
unknown_outcomeThe connection broke after the message data started. The server may have accepted the mail. It is never retried. Check the recipient’s mailbox before you send again.
egress_closedThe VPN gate is closed. A send stays pending and waits. A test fails after two minutes.
guard_failed:<codes>The identity guard refused the complete message, headers included. The codes after the colon are the guard’s constant reason codes (see Security).
not_readyThe report changed and is no longer ready
not_configuredNo profile was found when ingest looked
mode_offThe mode is off
mode_changedAn automatic request was cancelled because the mode is no longer auto
internalAn unexpected error before anything was sent

Each change is audited with the actor and the client address: abuse.smtp.update, abuse.smtp.delete, abuse.smtp.test, abuse.send (report ID and mode) and abuse.send.cancel. Entries never hold the password or the message body.

Administrators only, for every route below. Writes need the CSRF header and an allowed origin. The WireGuard file itself is never returned or logged. The answers show only the endpoint, the addresses and a short fingerprint of the peer key. The design is in M23 and M24.

MethodPathBodyAnswer
GET/api/v1/settings/vpnnoneThe settings (see below)
PUT/api/v1/settings/vpn{"conf": "...", "password": "..."}200 with the settings after the upload
DELETE/api/v1/settings/vpn{"password": "..."}204. The vpn service goes back to the values in .env.
POST/api/v1/settings/vpn/checknone202 with requested_at
POST/api/v1/settings/vpn/reconnect{"password": "..."}202 with requested_at
POST/api/v1/settings/vpn/trusted-exits{"password": "..."}201 with the new range
DELETE/api/v1/settings/vpn/trusted-exits/{exit_id}{"password": "..."}204

The password field is the administrator’s own password. In this section, “Check now” is the only write that does not ask for it.

FieldContent
sourceenv (the values from .env) or upload (a file uploaded here)
uploaded_at, uploaded_byWhen and by whom the file was uploaded. null for env.
endpoint_ip, endpoint_port, addresses, peer_key_fingerprintThe parts of the configuration that are safe to show
gate{state, reason, egress_ip, egress_asn, egress_org, checked_at} of the ingest egress gate. state is open or closed. egress_ip is the VPN exit address.
trusted_exits[{id, cidr, created_at, created_by}], at most 8
trust_candidateThe range the gate would trust now, or null
trust_blockedexit_is_self or exit_asn_denied when the candidate must not be trusted, otherwise null
config_writablefalse when the configuration volume is missing or read-only. Uploads then answer 503.
last_check_request{requested_at, done_at} of the latest “Check now”, or null. done_at is null until ingest has checked.

Uploads a wg-quick file. The parser is strict. It accepts only the keys it knows, and it refuses anything that runs a command or changes routing. Errors:

StatusCodeMeaning
413too_largeThe file is larger than 4 KiB
422invalid_wireguard_configThe answer lists problems, codes such as forbidden_key, preshared_key_refused, bad_private_key, endpoint_not_ip or not_full_tunnel (the peer must route 0.0.0.0/0)
503vpn_config_unavailableThe configuration volume cannot be written
403reauth_failedWrong password

Asks ingest to check the egress gate now. It changes nothing else and needs no password. Ingest picks the request up within a few seconds. Read the answer in last_check_request. At most one request per 10 seconds: a second one answers 429 with too_soon and a Retry-After header.

Restarts the tunnel. The vpn service watches a marker file and restarts when it changes. Outbound lookups pause until the tunnel is back. At most one reconnect per 60 seconds (429, too_soon). Audit action: vpn.reconnect.

Trusts the range in trust_candidate. The API stores the /24 (IPv4) or /48 (IPv6) that contains the exit address the gate saw. A client cannot send its own range. Once the range is trusted, the gate accepts exits in it. Errors:

StatusCodeMeaning
409no_candidateThere is no exit range to trust right now
409exit_is_self or exit_asn_deniedThe exit must not be trusted. See trust_blocked.
409too_manyEight ranges are already trusted. Remove one first.

DELETE /api/v1/settings/vpn/trusted-exits/{exit_id}

Section titled “DELETE /api/v1/settings/vpn/trusted-exits/{exit_id}”

Removes one trusted range. Answers 204.

GET /api/v1/setup/status is for administrators only. It returns the checklist of the Settings page and the last tour step. Every item is computed from real state. detail is a short English sentence. It never contains a key, an address or a mailbox. href is the dashboard path that fixes the item.

{
"items": [
{"id": "sensors", "state": "ok", "detail": "All 12 sensors sent events in the last 24 hours.", "href": "/settings"},
{"id": "vpn", "state": "warning", "detail": "The egress gate is closed (tunnel_down). Outbound lookups are paused.", "href": "/settings?tab=vpn"}
]
}

state is one of ok, todo, warning or off. The items always come in the same order:

idok whenOther states
sensorsEvery sensor sent an event in the last 24 hourswarning names the silent sensors
vpnThe ingest egress gate is opentodo: the gate has not reported yet. warning: the gate is closed, with its reason.
collectorPayload fetching is on and its gate is openoff: COLLECTOR_ENABLED is false. warning: on, but its gate is closed.
reporting_settingsThe reply contact is set (the report texts are optional)todo
self_addressThe honeypot’s own address is knowntodo
abuse_workerABUSE_REPORTS_ENABLED is trueoff
alertsAt least one alert channel is enabledoff
provider_keysThe AbuseIPDB and GreyNoise keys are setwarning names the missing key. off when neither is set.

If one check cannot read its data, that item becomes warning with the detail “This could not be checked right now.”. The other items still answer. The host firewall cannot be seen from inside the stack, so it is not an item. Use just egress-check to test it.

A campaign is a group of sessions that share a script, a payload host, a file hash, a malware family, an exploit or a credential list. Ingest finds them (CAMPAIGNS_ENABLED) and labels what each session did with MITRE ATT&CK techniques (ATTACK_ENABLED). Everything here is read-only and open to both roles. Research scanners are never clustered.

RouteWhat it returns
GET /api/v1/campaignsKeyset-paginated list, most recently seen first (cursor, limit 1 to 200). Merged campaigns are hidden unless status=merged. Filters: status (active, quiet, merged), sensor, technique (T1059 or T1059.004), family, hours (1 to 8760) or days (1 to 365) since the campaign was last seen, q (literal substring of the name), include_benign (accepted, no effect).
GET /api/v1/campaigns/{id}One campaign: counters, countries, networks, sensors, techniques, stages, tags, families, severity and the representative session. A merged campaign carries merged_into.
GET /api/v1/campaigns/{id}/storyThe campaign told in stages (looking around, getting in, running code, staying in, bringing tools, the goal), each with a plain-English sentence and its techniques; up to 12 lines from the representative session; counts; and up to four next actions (what_to_do).
GET /api/v1/campaigns/{id}/timelineSessions per hour for the last days (1 to 30, default 7), from the campaign’s links. Links are capped at CAMPAIGN_LINK_CAP; linked says how many sessions the points are made of.
GET /api/v1/campaigns/{id}/sessionsThe campaign’s sessions, keyset-paginated like /sessions.
GET /api/v1/campaigns/{id}/indicatorsThe keys that tie it together (payload hosts, hashes, families, script and exploit fingerprints). Hosts also come defanged (dl[.]example[.]net).
GET /api/v1/attack/techniquesThe technique catalogue with what was seen in the last days (1 to 365, default 30): sessions, events, sources, last day. Keyset-paginated by id (cursor, limit up to 400); sensor filters.
GET /api/v1/attack/matrixSessions per technique per UTC day for the last days (default 30), only techniques seen in the window, in story order. Bounded by the catalogue (400 techniques at most), so it is not paginated. sensor filters.

GET /api/v1/sessions also accepts technique and campaign_id, and every session carries techniques and campaign_id.

/api/v1/live is a WebSocket. It pushes new events as they are stored, so a page does not need to poll.

  • The API checks the database for new events about once per second. It does not read Redis.
  • On connect, the client receives a backfill of the 50 newest events, then the latest statistics.
  • Each new event is sent once to every client, oldest first.
  • Statistics are sent every 5 seconds.
  • Each client has a queue of 200 messages. A slow client loses its oldest messages and is told how many were lost.
  • The hub accepts at most 50 clients in total and 5 per source address. A connection beyond either limit is closed with code 1013.
  • A client that is slow to receive is dropped after a send timeout of 10 seconds.
  • Reconnect politely: the dashboard backs off exponentially and falls back to polling /api/v1/events/recent. See Dashboard.

All messages are JSON text frames from the server.

kindPayloadWhen
backfillevents: list of live events, newest firstOnce, right after connecting
statsstats: window of 60 seconds, with totals and by_sensorAfter the backfill, then every 5 seconds
eventevent: one live eventFor each new event, oldest first
droppedcount: messages skipped for this clientWhen the client was too slow
pongnoneReply to a client text frame ping

A live event has the same fields as an item of /api/v1/events/recent.

Example event message:

{
"kind": "event",
"event": {
"id": 1843,
"ts": "2026-10-24T18:02:16Z",
"sensor": "web",
"session_id": "Lr4pD0c_9Ks",
"type": "http_request",
"src_ip": "192.0.2.45",
"src_port": 38812,
"dst_port": 80,
"summary": "GET /wp-login.php → 200",
"country": null,
"city": null,
"asn": null,
"org": null,
"abuse_score": null,
"known_scanner": null,
"tags": []
}
}
  • A client with no Origin header (a script or a command-line tool) is accepted.
  • A browser must send an Origin that matches the Host header, or a host listed in API_ALLOWED_ORIGINS. Otherwise the connection is closed with 1008.
  • The handshake needs a valid session cookie (see Authentication). Without it the connection is closed with 1008, and a session that ends while the socket is open closes it with 1008 too.
CodeMeaning
1000Normal close, for example the client left
1001The server is shutting down
1008The origin is not allowed, there is no valid session, or the session ended
1011Internal error on the server
1013Too many clients connected (in total or from one address), or the session cannot be checked (database unavailable)

The node reads the community lists that its ingest stored. These routes read only. Any signed-in user can call them, and they never contact the feed host. The public files are described in Community blocklist feeds.

The state of the feed, and the public address of each list file. The file addresses are built as text, from the fixed file names. They exist even while fetching is off.

{"enabled_env": true, "configured": true, "state": "ok", "error_code": null, "serial": 4812,
"generated_at": "2026-11-01T13:00:00Z", "valid_until": "2026-11-04T13:00:00Z",
"last_ok_at": "2026-11-01T13:02:11Z", "signature": "verified", "member": false,
"public_base_url": "https://feeds.example.org/v1",
"lists": [{"id": "outbound", "direction": "outbound", "entries": 812, "high": 301,
"seen_here": 3, "helped": null,
"files": [{"name": "outbound.txt", "format": "txt",
"url": "https://feeds.example.org/v1/outbound.txt"}]}],
"local_mirror": {"feeds_enabled": false}}
FieldMeaning
statedisabled (fetch off), never (no index yet), ok, stale (past valid_until) or error (the last fetch failed; error_code says why)
error_codeSet for error and stale. Codes include not_configured, egress_closed, bad_signature, feed_rollback, feed_expired, feed_invalid, too_large and http_<code>
signatureverified when the stored index passed the check, otherwise null
lists[].seen_hereEntries also seen by this sensor in the last 30 days (local data only)
lists[].helpedEntries that this node helped confirm. Members only. null for everyone else.
local_mirror.feeds_enabledWhether the local mirror under /feeds is switched on (FEEDS_ENABLED)

Stored entries, one page at a time. Empty while the feed is off.

QueryMeaning
listinbound, outbound or outbound-domains
tierstandard or high
qText search on the value (up to 64 characters)
seen_here1 for entries this sensor also saw
cursorCursor from the previous page (next_cursor)
limit1 to 100, default 50

Answer: {"items": [...], "next_cursor": "..."}. Each item has list, value, family, tier, categories, services, tags, families, ports, first_seen, last_seen, expires, reported, seen_here and helped. An unknown list or tier answers 422 with invalid_filter.

The mirror serves the verified entries to a firewall. It is part of the feed routes, so it needs FEEDS_ENABLED=true. It sits outside /api/v1 and works like the sensor feeds in Defend. The token must have the kind network.

RouteAnswer
GET /feeds/{token}/network/{list}.{txt|csv}Token in the path
GET /feeds/network/{list}.{txt|csv} with Authorization: Bearer <token>Token in a header (preferred)

{list} is inbound, inbound-high, outbound, outbound-high or outbound-domains. The text form has one value per line. The CSV form has a header and CRLF line ends. Each answer has an ETag, an X-Entry-Count header, and a 304 when the caller sends the matching If-None-Match.

  • The mirror is empty when the stored state is stale, never or disabled. It fails toward not blocking.
  • The administrator’s always-allow overrides are removed from the answer. Your own hosts stay off the lists.
  • Every value is checked again before it is written out. Demo entries are never served.
  • A wrong or revoked token, a token without the network kind, and an unknown list all get the same plain 404. Too many requests from one address or token get 429 with Retry-After.

Create a network token with POST /api/v1/defend/feed-tokens and "kinds": ["network"], as the admin, with the password again. The min_score field does not apply to network tokens.

FeatureMilestone
API rate limits beyond the login limitsM8