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,
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.
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,
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_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 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}/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. 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.
Destinations
Section titled “Destinations”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.
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.
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 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.
Analysis: the learning-phase forecast
Section titled “Analysis: the learning-phase forecast”GET /api/v1/analysis/learning-forecastGET /api/v1/analysis/learning-forecast?model=candidate.yamlReader 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.
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=GET /api/v1/response-actions/{id}POST /api/v1/incidents/{incident_id}/response-actionsPOST /api/v1/response-actions/{id}/decisionPOST /api/v1/response-actions/{id}/releasePOST /api/v1/incidents/{incident_id}/response-feedbackGET /api/v1/response/calibrationPOST /api/v1/response/canaryResponse 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.
Identity proof and evidence (since 0.32)
Section titled “Identity proof and evidence (since 0.32)”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.
Response canary (since 0.37)
Section titled “Response canary (since 0.37)”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.
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" }}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 (internal)
Section titled “GET /healthz (internal)”GET /healthzSince 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.
Metrics
Section titled “Metrics”GET /metricsAuthorization: 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.