Endpoints
A curated tour of the v1 surface. Every route is tenant-scoped, every read needs a role, every write needs an idempotency key. See the overview for the common rules.
Shared types
Section titled “Shared types”Evidence reference
Section titled “Evidence reference”{ "evidence_id": "ev_01J...", "kind": "flow_observation", "source": "zeek", "sensor_id": "sensor_example_vsensor", "observed_at": "2026-09-10T11:59:58.220Z", "trust": "packet", "summary": "TLS connection to 203.0.113.8:443", "flow_id": "flow_01J..."}kind is one of flow_observation, dns, tls, http, ssh, alert,
model_result. source is one of zeek, suricata, netflow, unbound,
inventory, model. trust is one of packet, firewall_app, exporter,
inventory, derived — and describes provenance, not malice.
Directional counters
Section titled “Directional counters”{ "src": { "ip": "192.0.2.12", "port": 51544, "device_id": "dev_01J..." }, "dst": { "ip": "203.0.113.8", "port": 443, "device_id": null }, "counters": { "src_to_dst": { "bytes": 1240, "packets": 8 }, "dst_to_src": { "bytes": 8960, "packets": 12 }, "semantics": "session" }}semantics is session, unidirectional_export or unknown. Missing source
counters are null, never zero. An exporter-only record keeps the exporter’s
direction and the API never invents the reverse.
GET /api/v1/graph?from=…&to=…&scene=network|globe&focus_device_id=…A bounded snapshot: at most 500 nodes, 2,000 links and 300 globe arcs.
Aggregation is explicit — a node’s kind is device, external_endpoint,
vlan_cluster or asn_cluster, and a cluster carries a member_count. The
response also returns a layout_seed, which is why the map is stable across
loads, a watermark, and a truncated flag.
GET /api/v1/graph/stream?since=<opaque_cursor>A server-sent event stream of delta, heartbeat and resync_required events.
A delta carries a monotonically ordered cursor plus node and link upserts and
removals. Reconnect with Last-Event-ID, which takes precedence over since.
The interesting parts are the failure modes:
- For an in-buffer cursor, the server reconstructs the prior snapshot from durable telemetry before emitting changes — including removals that happened while you were disconnected.
- A cursor outside the 15-minute buffer, or an authentic cursor that has
expired, gets a
resync_requiredevent with a reason. - A forged cursor, or one bound to another tenant, kind or scope, gets a JSON 400 before any stream headers are written.
- A heartbeat arrives at least every 15 seconds.
- An admitted stream holds a five-minute lease and ends with a plain EOF at routine expiry; the client reconnects. The signed request’s expiry governs admission only and is not later reported as lost stream state.
- At most 64 concurrent streams; beyond that, a JSON 429 with
Retry-Afterbefore stream headers.
Devices
Section titled “Devices”GET /api/v1/devices?query=&vlan_id=&device_type=&risk_min=&seen_after=&limit=&cursor=GET /api/v1/devices/{device_id}?from=…&to=…PATCH /api/v1/devices/{device_id}The list returns identity, type, how the type was decided and with what confidence, addresses, VLANs, tags, first and last seen, score, incident count and revision. An observed device’s classification confidence defaults to 0; a human confirmation sets it to 1.
The detail adds MAC addresses, the score history, the hour-by-weekday pattern
grid (ISO weekday numbering, 1 = Monday, UTC hourly buckets), top peers, top
destinations with first-seen and a threat-intelligence marker, services, and
bounded domain, fingerprint and user-agent projections. telemetry_truncated
becomes true when the bounded traversal reached its flow limit.
It also returns identities and merge_events from the
identity model — each anchor with its class, value, source,
weight, first and last seen, observed days, and the randomized and confirming
markers. Both lists are empty while the identity job is off, and an unavailable
identity store leaves them empty with a logged failure rather than failing the
whole device detail.
PATCH requires the analyst role, If-Match and an idempotency key, and accepts
display_name, device_type, owner and tags. A successful patch records
classification_source=human_confirmed, confidence 1, a new revision and a
workflow history entry.
GET /api/v1/flows?from=…&to=…&device_id=&peer_ip=&proto=&service=&source=&incident_id=&limit=&cursor=GET /api/v1/flows/{flow_id}/evidenceResults sort by start time descending with an identifier tie-breaker. Each
canonical flow retains its provenance: observation_count, the list of
sources, and a primary_evidence reference. Counters come from one preferred
observation and are never summed across overlapping sensors.
The evidence route returns every observation with its sensor, trust, observed and
received times, source event identifier, normalisation warnings and directional
counters. community_id is nullable; the canonical flow identifier is
application-generated and does not depend on it.
Search
Section titled “Search”POST /api/v1/search/interpretPOST /api/v1/searchinterpret takes { "query": "...", "mode": "field|natural|auto" } and returns a
structured filter, a result kind, an interpretation marker, an informational
select_preview and any warnings. search takes that filter back, recompiles
it, injects the tenant scope and returns flows, devices or incidents.
No endpoint accepts executable SQL. Model text never reaches a SQL parser or a query. See search.
Incidents and the analyst
Section titled “Incidents and the analyst”GET /api/v1/incidents?status=&severity=&device_id=&from=&to=&limit=&cursor=GET /api/v1/incidents/{incident_id}PATCH /api/v1/incidents/{incident_id}POST /api/v1/incidents/{incident_id}/analyst-runsGET /api/v1/analyst-runs/{run_id}GET /api/v1/briefs/daily?date=YYYY-MM-DDThe detail adds ordered evidence, model results, score history, workflow history
and the analyst object. Each model result is linked to the specific persisted
analysis result that produced it. unresolved_evidence_ids lists identifiers
whose retained source record is unavailable — never relabelled as model output.
PATCH accepts exactly one status change plus a comment. Statuses are open,
investigating, resolved, false_positive.
An analyst run returns 202 with a queued state. Analyst state is
not_requested, queued, running, complete, failed, provider_disabled
or budget_exhausted. A disabled provider and an exhausted budget are explicit
errors with no automatic paid fallback.
The daily brief is local metadata with delivery=local_only: retrieving it
sends nothing.
Proposals and response actions
Section titled “Proposals and response actions”POST /api/v1/devices/{device_id}/classification-suggestionsPOST /api/v1/model-proposalsPOST /api/v1/triage-proposalsPOST /api/v1/models/feedbackGET /api/v1/proposals?kind=&target_id=&status=GET /api/v1/proposals/{id}POST /api/v1/proposals/{id}/decision
GET /api/v1/response-actions?status=&incident_id=&limit=&cursor=POST /api/v1/incidents/{incident_id}/response-actionsPOST /api/v1/response-actions/{id}/decisionResponse action kinds are quarantine_device, block_destination,
notify_only. Status is proposed, approved, rejected, cancelled,
executed, rolled_back or failed; v1 can create and transition only through
approved, rejected and cancelled.
v1 records and reviews; it does not execute. Approval never activates a model or changes a threshold. Execution routes are outside this contract. See response and proposals.
Health
Section titled “Health”GET /api/v1/health200 for ok and degraded, 503 for unavailable.
{ "data": { "status": "degraded", "watermark": "2026-09-10T11:59:57.000Z", "components": [ { "id": "clickhouse", "kind": "store", "status": "ok", "last_event_at": null, "lag_seconds": 0, "message": null }, { "id": "sensor_example_vsensor", "kind": "sensor", "status": "stale", "last_event_at": "2026-09-10T11:45:00.000Z", "lag_seconds": 897, "message": "No packet-source event within 10 minutes" } ], "ingest": { "events_last_5m": 820, "unparsed_last_5m": 2, "rejected_last_5m": 1, "oldest_buffered_at": null }, "capture": { "loss_percent": 0.03, "sampled_at": "2026-09-10T11:59:00.000Z" } }, "meta": { "request_id": "req_01J...", "generated_at": "2026-09-10T12:00:00.000Z" }}Component status is ok, stale, degraded or unavailable. A non-admin gets
the same shape with host-local detail redacted. Freshness and the watermark use
the server’s receipt timestamps, so a skewed source clock cannot forge
health.