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, pihole, adguard, inventory, model — pihole and adguard are a DNS query the Pi-hole or AdGuard Home connector retained, always of kind dns, with sensor_id connector:<instance> and trust firewall_app. 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, internal_endpoint, private_endpoint, local_broadcast, local_multicast, link_local, reserved_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.

Every node carries address_class — one of internal, private_unmapped, broadcast, multicast, link_local, loopback, unspecified, reserved, documentation, external or unknown — and a nullable address_label (mdns, ssdp, llmnr, igmp, ospf, vrrp, ntp or broadcast). The kind is derived from the class and is a rendering hint; the class is the authority. external is true exactly when the class is external, and the geo fields are null for every other class. A client must show only external endpoints on the globe, in the country list, in the arc groups and in the external-endpoint list. See address classes.

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 list and the detail return the resolved name with name_source (manual, netbox, hypervisor, authoritative_map, dhcp, mdns, resolver, reverse_dns or address, in that priority — hypervisor is the guest name a Proxmox VE cluster holds for the address, resolver a client name held by the network’s own Pi-hole or AdGuard Home), name_confidence and aliases, and the device’s NetBox record as netbox — name, dns_name, role, site, tenant, description, status, url, each nullable — or null when the NetBox connector knows none of its addresses, or knew them only in a record it has not re-emitted for 72 hours.

The detail additionally returns dhcp: what the DHCP server’s lease store records about the device through the DHCP leases connector. It is an object with nullable hostname, mac, client_id, vendor_class, subnet_id, lease_start, valid_until and via (the connector instance the lease was read through) plus the constant source: "dhcp-leases", or null when no lease that still binds is known for any of the device’s addresses. Only a live lease counts — valid_until in the future, or no end and observed within the last 48 hours; an expired lease is not reported, and the hostname it gave stays in aliases marked stale. Several addresses are described by the freshest live lease. The list does not carry the object.

{
"dhcp": {
"hostname": "nas-01", "mac": "00:00:5e:00:53:06", "client_id": null,
"vendor_class": "udhcp 1.36.1", "subnet_id": "1",
"lease_start": "2026-09-11T09:00:00.000Z", "valid_until": "2026-09-11T21:00:00.000Z",
"via": "kea-home", "source": "dhcp-leases"
}
}

The detail additionally returns proxmox: what the hypervisor records about the guest at one of the device’s addresses through the Proxmox VE connector. It is an object with nullable name, vmid (a number), node, type (qemu or lxc), status, pool, os, url and via (the connector instance), the list tags, and the constant source: "proxmox", or null when no hypervisor knows any of the device’s addresses — or knew them only in a record the connector has not re-emitted for three full snapshots (72 h): a guest that was destroyed stops being reported, and the name it gave stays in aliases marked stale. A device holding several addresses is described by the first address the hypervisor knows. url is a link to the guest in the Proxmox VE interface on the connector’s own origin and is only ever an absolute https URL. A backend without the connector sends null or nothing; the list does not carry the object.

{
"proxmox": {
"name": "web-01", "vmid": 100, "node": "pve-01", "type": "qemu",
"status": "running", "tags": ["prod", "web"], "pool": "prod",
"os": "Debian GNU/Linux 13 (trixie)",
"url": "https://pve.example.invalid:8006/#v1:0:=qemu%2F100",
"via": "pve", "source": "proxmox"
}
}

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, a threat-intelligence marker and their address_class, services, and bounded domain, fingerprint and user-agent projections. Each domains row carries the boolean resolver_blocked: true when the network’s own resolver (a Pi-hole or AdGuard Home connector) refused that name for the device at least once in the window — the resolver’s verdict, not a finding; an installation without a resolver connector sends false. Since 0.28 the flag, and the resolver’s names themselves, are served from the hourly device_context_stats aggregate (migration 010); history from before that migration carries them only once the one-time backfill has run. local_noise collapses the window’s broadcast, multicast and link-local counterparts into one object — bytes, peer_count, address_classes, protocols, first_seen — and is null when there were none. telemetry_truncated becomes true when the bounded traversal reached its flow limit.

warnings (0.27, additive) is an array of stable strings naming the parts of the detail that could not be read. It is empty when the detail is complete and absent on an older backend. The detail is still HTTP 200 with the inventory, the flow statistics and the score, and the parts named are present but empty — earlier releases answered 503 for the whole device instead. Currently defined: telemetry_unavailable, meaning the raw-event contexts (domains, ja4, user_agents, ssh, alerts, protocols) could not be read, for example while a migration rewrites their views; they come back on the next request that succeeds. A detail with warnings is never served from the cache. Treat an unknown warning as informational.

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. Both src and dst carry address_class and address_label, as a graph node does, and geo is null unless dst.address_class is external. threat_intel_match is true when the local indicator list matched.

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.

GET /api/v1/destinations?from=…&to=…&limit=&sort=bytes|flows|sources&country=&asn=&proto=&vlan=&q=
GET /api/v1/destinations/{ip}?from=…&to=…

A ranked page of the external destinations of a window — at most 500 rows, default 100, no cursor, because the answer is a ranking and not a scrollable collection. Only an address of class external has a row or a detail; any other class is 404 not_found. proto and vlan (the VLAN of the internal side) are applied in the database; country, asn and q are applied by the API on a ranked candidate page of at most 2,000 destinations, and a response whose candidate page was full says so with truncated.

Each row carries the address, hostname with hostname_source (tls_sni or http_host — never a reverse-DNS record), country and ASN, bytes_out and bytes_in (null, not zero, when no flow of the window reported a counter), bytes_exact (false while duplicate flow versions are still being merged), flow_count, device_count with device_count_exact and the exact source_addresses, top_protocols, first and last seen, incident_count and threat_score of the devices that reached it, and threat_intel_match.

The detail is exact and adds hostnames (every observed name with source and counts), a per-UTC-hour timeline, the complete protocols and devices breakdowns up to 500 entries each with truncated, and the incidents and model_results of those devices in the window. Coordinates come from the local GeoIP databases; there is no WHOIS lookup and no outbound request. See external destinations.

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.

Every incident carries kind since 0.37 (additive): detection for what the analysis engine wrote, canary for a synthetic chain check an administrator created. A reader that does not know a kind treats it as a detection.

An analyst run returns 202 with a queued state. Analyst state is not_requested, queued, running, complete, failed, provider_disabled, budget_exhausted or, since 0.39, quota_exhausted — the licence’s monthly quota per installation, a different limit from the daily budget with a different reset, answered 429. A disabled provider and an exhausted budget or quota are explicit errors with no automatic paid fallback. See the licence quota.

The daily brief is local metadata with delivery=local_only: retrieving it sends nothing.

Since 0.33 a model result evaluated during the device’s learning phase carries its real score with suppressed: true, and its reason reads learning baseline; <measurement>; the device’s score_history leaves suppressed results out. Results written by an older release carry score 0 while suppressed and cannot be told apart from a measured zero.

Since 0.38 the measurement of the beaconing and scan detectors is comma-separated key=value tokens: for beaconing interval_cv, period_seconds, periodicity, intervals, span_seconds, destination; for scan unique_ports, window_seconds, hosts, destination. When a policy withdrew the verdict, policy=<suppress|shared_destination|baseline> follows with its detail (shared_devices=N; baseline_since=<date>, baseline_days=N). A beaconing result with too few gaps reads too few intervals,intervals=N,destination=…. The robust-baseline and rarity measurements are unchanged. Nothing in the schema changed; a reader that shows the reason as text shows the measurement.

Since 0.38 the incident of a scan is keyed by the destination’s /24 (IPv6 /64), and a device that opens more incidents on one UTC day than the model file’s daily cap gets one incident inc_overflow_… titled “Further findings beyond the daily cap” whose summary reads “and N more on …” and whose model_ids and evidence_ids merge the folded candidates. Both are ordinary detection incidents on the wire — kind: detection, the usual workflow — and a reader needs nothing new for them; see the incident page.

GET /api/v1/analysis/learning-forecast
GET /api/v1/analysis/learning-forecast?model=candidate.yaml

Reader role, additive since 0.33. The read-only forecast of the learning phase: per model, the suppressed results of the last 24 hours that would have been incident candidates. It changes nothing; a suppressed result stays out of every incident, listing, sink and metric whatever it scored.

Its one optional query parameter, model, is additive since 0.38: a bare file name in the appliance’s model directory ([a-z0-9][a-z0-9._-]*.yaml, no path) whose titles, severities and detectors the counts are joined with instead of the configured file’s. The response then carries "model_file": "<file>" in data (absent otherwise). A name with a path, a file that is not there or one that does not parse is 400 invalid_request. The counts are the stored verdicts either way — this is the cheap view of a candidate’s severities; the re-evaluation against a candidate is ndr analysis replay --model on the appliance.

{
"data": {
"tenant_id": "example",
"window": { "from": "2026-09-17T12:00:00.000Z", "to": "2026-09-18T12:00:00.000Z" },
"learning_days": 14,
"totals": { "suppressed_results": 40107, "would_fire": 212,
"by_severity": { "high": 180, "medium": 32 } },
"models": [
{ "model_id": "periodic_destination_v1", "title": "Periodic destination",
"severity": "high", "detector": "beaconing",
"suppressed_results": 12040, "would_fire": 180, "devices": 4,
"top_devices": [
{ "device_id": "dev_01J...", "display_name": "cam-01", "would_fire": 96 },
{ "device_id": "dev_01K...", "display_name": null, "would_fire": 40 }
] },
{ "model_id": "peer_isolation_v1", "title": "Peer isolation",
"severity": "medium", "detector": "peer_isolation",
"suppressed_results": 0, "would_fire": 0, "devices": 0, "top_devices": [] }
],
"note": "Results stored before this release carry score 0 and no verdict during the learning phase and cannot be counted; …"
},
"meta": { "request_id": "req_01J...", "generated_at": "2026-09-18T12:00:00.000Z" }
}

Every model of the model file is listed, at zero when the window holds nothing for it, ordered by would_fire descending and then by model_id; a model the file no longer knows is listed with severity and detector unknown. suppressed_results counts every suppressed result of the model in the window, would_fire those whose verdict was positive, devices the distinct devices behind would_fire. top_devices holds at most five devices, ordered by would_fire descending; display_name is the device’s current name and null when the inventory does not know the device. totals.by_severity carries every severity the model file uses, at zero when nothing would fire. note is a sentence for the operator and may change. A failed read is 503 source_unavailable.

ndr analysis forecast --json prints the same object per tenant, and accepts --tenant and --hours (1 to 168) where the endpoint is fixed at 24 hours; --model FILE (any readable path, since 0.38) is the command’s form of the model parameter.

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=
GET /api/v1/response-actions/{id}
POST /api/v1/incidents/{incident_id}/response-actions
POST /api/v1/response-actions/{id}/decision
POST /api/v1/response-actions/{id}/release
POST /api/v1/incidents/{incident_id}/response-feedback
GET /api/v1/response/calibration
POST /api/v1/response/canary

Response action kinds are quarantine_device, block_destination, notify_only. Status is proposed, approved, rejected, cancelled, executing, reconciling, executed, rollback_requested, rolling_back, rolled_back, expired or failed. A person can create or transition only through approved, rejected, cancelled and rollback_requested; every other status is written by the executor, and a reader that does not know a status keeps and displays it verbatim.

Approval never activates a model or changes a threshold. Without a response target the API records and reviews and executes nothing; with one that is configured and licensed (response), an approved quarantine_device or notify_only action is executed — see automatic response.

Since 0.31 every action carries three additive fields, always present:

{
"execution": {
"detail": "held on 2 node(s) until 2026-09-12T12:15:00.000Z; b: 3/3 states killed; a: 3/3 states killed",
"updated_at": "2026-09-12T12:00:04.000Z"
},
"lease_expires_at": "2026-09-12T12:15:00.000Z",
"policy_decision": {
"mode": "proposal", "policy_revision": 3, "independent_classes": 1,
"observed_score": 82, "confidence": 0.82, "identity": "none",
"reasons": ["inventory binds the address to dev_01J… (observed, not proven)",
"score 82 below automatic threshold 95"],
"evaluated_at": "2026-09-12T12:00:00Z"
}
}
Field Meaning
execution.detail, execution.updated_at The executor’s last explanation and when it was written
lease_expires_at The membership deadline the action holds; null when it holds none. On the firewall target the alias expires on its own at the configured alias expiry even when the appliance is down
policy_decision The gate’s verdict — null until evaluated. mode is proposal for every human approval and for a proposal the producer held, automatic for an action the appliance approved itself (then also producer: "automatic"). Since 0.32 confidence is the evidence confidence (0..1, a noisy-OR over the independent evidence families, versioned as confidence_revision), not the score ÷ 100, and its basis is in reasons; reopened_from names the failed automatic action a proposal was reopened from

The detail (GET /api/v1/response-actions/{id}, reader) adds execution.nodes: one entry per companion call —

{"node": "b", "operation": "add_member", "correlation_id": "creq_…",
"started_at": "…", "finished_at": "…", "readback_present": true,
"states_seen": 3, "states_killed": 3, "error": "", "detail": ""}

readback_present is null when the readback itself failed and false when the entry is absent; the two are different. Node labels are the operator’s own (a, b, unifi); alias names, node addresses and state ids never leave the server, and error is a failure class, never an error text.

Release (POST /api/v1/response-actions/{id}/release, responder role, If-Match, Idempotency-Key of at least eight characters) accepts {"comment": "…"} and moves an executed, reconciling or executing action to rollback_requested; any other status answers 409 revision_conflict. The action becomes rolled_back only after every node confirmed the absence; a node that could not confirm leaves it rolling_back with the reason in execution.detail. expired is the executor’s end after the deadline. Release is not licence-gated. See response and proposals.

Every action carries canary since 0.37 (boolean, additive): true for the action of a synthetic canary incident, false otherwise.

Two more additive fields are on every action, null until the executor or the producer resolved them — and they are resolved for every apply, human or automatic:

{
"identity_proof": {
"grade": "dhcp_lease_and_mac", "proven_at": "2026-09-12T11:00:00Z",
"holder": "anchor:mac:00:1a:2b:00:00:42", "anchors": 3,
"reasons": ["fresh lease: stable MAC 00:1a:2b:00:00:42 from dhcp-leases seen 2026-09-12T11:00:00Z"],
"evaluated_at": "2026-09-12T12:00:00Z"
},
"evidence": {
"classes": ["behavior_model", "suricata", "zeek"], "markers": ["beaconing"],
"resolved": 4, "unresolved": 0, "analyst_verdict": null, "note": null,
"evaluated_at": "2026-09-12T12:00:00Z"
}
}
Field Meaning
identity_proof.grade none, dhcp_lease_and_mac, static_binding or enrolled — the ladder. A reader keeps an unknown value
identity_proof.holder An opaque label — a cluster id or an anchor — never a node or an alias
identity_proof.anchors, proven_at How many fresh anchors the grade was judged on, and the last observation of the one that decided it
evidence.classes The independent classes: zeek, suricata, netflow, dns, threat_intel, behavior_model, mail. One record is one class; a threat-intel match counts as threat_intel instead of its family
evidence.markers Explanatory tags that do not count: threat_intel_match, resolver_blocked, beaconing, scan, first_sighting, rare_recurrence, baseline_deviation, analyst_false_positive
evidence.resolved, unresolved How much of the incident’s evidence could be classified; the ratio scales the confidence
evidence.analyst_verdict, note The latest feedback verdict on the incident, when there is one

An action the appliance produced itself has requested_by: "nyxtrace:automatic"; with approved_by: "nyxtrace:automatic" as well it runs in the automatic mode, otherwise it is a proposal the producer held for a person, with the reason as the last entry of policy_decision.reasons. A person’s approval always runs with policy_decision.mode: "proposal".

Response feedback and calibration (since 0.32)

Section titled “Response feedback and calibration (since 0.32)”

Feedback (POST /api/v1/incidents/{incident_id}/response-feedback, responder or admin role — the analyst role answers 403, because a false positive releases held actions —, If-Match = the incident’s revision, Idempotency-Key) accepts

{"verdict": "false_positive", "note": "Expected vendor telemetry"}

with verdict one of true_positive, false_positive, unknown and an optional note of at most 2000 characters, and answers 201 with the stored verdict, its outcome and weight (block_appropriate +5, missed_should_block +10, release_expected −15; null and 0 for unknown), released_action_ids and cancelled_action_ids, and the tenant’s calibration object. A false_positive moves every executed, reconciling or executing action of the incident to rollback_requested and cancels an automatic action that was approved but not applied; it never claims the rollback happened. The incident’s revision increases and response_feedback_<verdict> lands in its workflow_history; a stale If-Match answers 409 revision_conflict.

Calibration (GET /api/v1/response/calibration, reader) returns the tenant’s gate, read-only: configured, learning_until, learning_active, window_days, min_labels, min_precision, the incident counts true_positives, false_positives, unknown, labels, distinct_actors, precision (null without weighted labels), gate_open, gate_reason, calibration_revision, last_feedback_at, updated_at. An incident counts once, by its latest verdict inside the window. Both bodies, field by field, are on the feedback page.

POST /api/v1/response/canary — admin role, Idempotency-Key — creates a synthetic incident of kind canary plus a quarantine_device proposal for an address the operator owns, so the response chain can be walked end to end while no real incident exists. It is how activation is finished.

{"address": "198.51.100.250", "severity": "high", "ttl": "15m", "note": "activation step 5"}

address is a canonical IPv4 or IPv6 address and must pass the tenant’s response gate exactly like a detection’s target — inside allowed_targets, outside every protected scope, the configured ones, both firewall nodes and the appliance itself; a refused address answers 422 invalid_request with the gate’s reason, and nothing is written. severity is info, low, medium, high or critical (default high) and is what the sinks and the list show; ttl is a duration between 1m and 24h (default 15m), how long the canary lives. Without a configured response target for the tenant the call answers 409 conflict. The reply is 201:

{
"incident": { "id": "inc_01J...", "kind": "canary",
"title": "Canary: synthetic response chain check",
"severity": "high", "status": "open", "score": 100, "...": "..." },
"action": { "id": "resp_01J...", "canary": true, "kind": "quarantine_device",
"target": { "ip": "198.51.100.250" }, "status": "proposed", "...": "..." },
"address": "198.51.100.250",
"expires_at": "2026-09-19T10:15:00.000Z", "ttl_seconds": 900,
"membership_ttl_seconds": 900, "kill_switch": false,
"policy_decision": { "mode": "proposal", "policy_revision": 1,
"reasons": ["canary target inside the allowed ranges and outside every protected scope; runs in proposal mode only"] }
}

The action then takes the normal chain — decision (approve), the executor’s apply on both nodes, release, expiry — in proposal mode only. The kill switch is reported, not checked: a canary approved under kill_switch: true fails visibly at apply time, which is the dry run. A canary is never counted in the calibration (POST /incidents/{id}/response-feedback on it answers 400 invalid_request), is never a candidate of the automatic mode, and is never announced to a sink as an incident or as a real response event: every step goes out as response.canary. After ttl the worker withdraws an open proposal, releases a held membership and closes the incident as resolved by nyxtrace:canary.

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" }
}

ingest additionally carries events_last_5m_by_feed since 0.29: an object of sensor id to the events that feed delivered in the last five minutes, the number /metrics reports as nyxtrace_feed_events_5m. It is additive; an older backend does not send it.

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. A connector component is degraded, with the reason in its admin-only message, when its last poll refused rows for a stated reason — a resolver whose clock runs more than five minutes ahead of the appliance, for example — and returns to ok on the first poll that refuses nothing. See health monitoring.

A response target (opnsense-response, unifi-response) never collects; since 0.37 the service probes it instead — every ten minutes the companion’s health operation on both nodes, the controller session for UniFi — and its row follows the probe: ok with last_event_at = the last successful probe, degraded with the probe’s warnings as the admin-only message, unavailable after a failed probe (its failure class as the message), stale after three missed probes. A declared instance whose runtime published its line but has not yet succeeded once is unavailable with the message No successful collection or health probe recorded yet.

The update component carries four additive fields since 0.36 — last_attempt_at, last_attempt_outcome (available, applied, failed, rolled_back, rolled_back_unhealthy, skipped), last_attempt_stage and last_attempt_error — the most recent history row, so a client reading the component sees what the updater last did without the update object. last_attempt_error is null for non-admin callers like message; all four are null on an appliance that has never attempted an update. A failed attempt that a later successful check superseded is stale for 24 hours after it finished (a later update check succeeded) and no longer affects the status after that; rolled_back_unhealthy never fades. See the update component.

Since 0.39 the threat-intel enrichment component additionally carries the refresh’s provenance — source (keyserver, local, unknown), bundle_version and signing_key_id (strings, null for a local or unknown source), pulled_at (time or null) and the admin-only pull_error (null for readers and after a successful pull). The status words are unchanged; provenance never changes them. Two rows are new in 0.39, both additive and neither changing the overall status:

{ "id": "models", "kind": "analysis", "status": "ok", "last_event_at": "…", "lag_seconds": null,
"message": null, "built_in": 8,
"packs": [ { "id": "network-core", "version": "1.2.0", "channel": "stable",
"signing_key_id": "nyx-release-2026a", "models": 9, "pulled_at": "…", "loaded": true } ] }
{ "id": "analyst", "kind": "analyst", "status": "ok", "last_event_at": null, "lag_seconds": null,
"message": null, "quota_microusd": 5000000, "spent_microusd": 1250000, "runs": 17,
"period": "2026-09", "resets_at": "2026-10-01T00:00:00.000Z" }

models is unavailable when no pack is installed (the built-in model runs alone — the free core), ok when every installed pack loads, stale when a pack’s last pull failed while the installed version runs, degraded when an installed pack does not load or the built-in catalogue is broken; built_in is the size of the built-in catalogue and packs is always a list. analyst is unavailable with the provider disabled, ok otherwise, degraded when the licence’s monthly quota is spent (the admin-only message names the reset); the five quota fields are null without a licence quota. The admin-only message rule applies to both. kind gains analyst as one more value of its open vocabulary. GET /api/v1/license carries the two token fields analyst_quota_microusd and analyst_quota_period (month) since 0.39, null without a quota. See the content rows.

GET /healthz

Since 0.36: the appliance’s own view of itself, for the updater’s health gate and for an operator’s curl on the box. It is internal — not part of the signed /api/v1 surface, not tenant-scoped, and not promised to any client other than the updater of the same release. It is unauthenticated and instead bound to the caller: the request’s own peer address must be a loopback address (127.0.0.1, ::1, an IPv4-mapped loopback) and no proxy header may be present (X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP, Forwarded, Via) — either failure is 403 forbidden. It is rate limited to five requests a second with a burst of ten (429 rate_limited, Retry-After: 1). GET only; Cache-Control: no-store.

The body is the envelope with exactly three keys, and the status code is the tenant health API’s: 200 for ok and degraded, 503 for unavailable.

{
"data": {
"status": "degraded",
"version": "0.36.0",
"components": [
{ "id": "clickhouse", "kind": "store", "status": "ok" },
{ "id": "sensor_example_vsensor", "kind": "sensor", "status": "stale" },
{ "id": "connector:unifi", "kind": "connector", "status": "ok" },
{ "id": "license", "kind": "license", "status": "unavailable" },
{ "id": "update", "kind": "update", "status": "ok" }
]
},
"meta": { "request_id": "req_01J...", "generated_at": "2026-09-18T16:00:00.000Z" }
}

It is the fold of the per-tenant reports of /api/v1/health over every configured tenant: the worst overall status, every component id once with its worst status, in first-seen order, with the same vocabularies. It carries no message, no last_event_at, lag_seconds or counters, and none of the license, telemetry, update, ingest, capture or analysis objects — a route without a credential carries no operator text and no host path. A client that needs any of that uses the signed route.

The threat-intel enrichment component carries two additive fields since 0.33: loaded_at, the time the running process read the indicator file (also its last_event_at), and entries, the number of indicators in the set in use. It is ok while the set in use is the file on disk, degraded when a newer file could not be read or was rejected — the previous set keeps matching, and the admin-only message says which — and unavailable when no path is configured or nothing was ever read (loaded_at null, entries 0). The process re-reads the file when it changes, checked at most once a minute; see threat intelligence.

GET /metrics
Authorization: Bearer <NDR_METRICS_TOKEN>

A Prometheus text exposition (text/plain; version=0.0.4) of the whole appliance, and deliberately not part of the signed /api/v1 surface: it exists only while NDR_METRICS_TOKEN is set in the service environment (404 otherwise), accepts exactly Authorization: Bearer <token> (401 otherwise, 405 for anything but GET), is served on the same loopback bind as the rest of the API, and does not accept the signed service credential. Every series is a gauge read from the same reports GET /api/v1/health renders, with a tenant label instead of a tenant scope, and the body is cached for ten seconds. The families and their bounded labels are listed under metrics.