Skip to content

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.

{
"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.

{
"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_required event 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-After before stream headers.
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}/evidence

Results 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.

POST /api/v1/search/interpret
POST /api/v1/search

interpret 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.

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-runs
GET /api/v1/analyst-runs/{run_id}
GET /api/v1/briefs/daily?date=YYYY-MM-DD

The 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.

POST /api/v1/devices/{device_id}/classification-suggestions
POST /api/v1/model-proposals
POST /api/v1/triage-proposals
POST /api/v1/models/feedback
GET /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-actions
POST /api/v1/response-actions/{id}/decision

Response 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.

GET /api/v1/health

200 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.