Skip to content

Webhook

The webhook connector is the first sink of the catalogue: one signed HTTP POST per event to a receiver you name — an alert router, a chat bridge, a ticket system, a script on the same host. Without an outbound path an NDR is a web page nobody opens. It is Free and needs no licence key. For a message a person reads in Telegram, Discord, Slack or Matrix, see the chat notifications (Pro), which share this page’s event path, subscription fields and delivery semantics.

Four things leave the appliance through it: a new incident, an incident whose score rose, an incident an operator closed, and a health component that changed state. Evidence, model results and the analyst narrative are deliberately not in the envelope — a receiver is a second system with its own audience, and the link is the way into the detail.

https:// by default. A receiver with a self-signed certificate is pinned like every other connector endpoint (tls_fingerprint_sha256, read with the openssl s_client line of UniFi Network); there is no option to skip verification. Plain http:// works only for a receiver on the same host or an isolated management network and has to be asked for with allow_insecure_http: true, because the body carries incident summaries. Redirects are never followed: configure the final URL.

It is an HMAC key of at least 16 bytes; the receiver needs the same value. The file follows the secret rules of every connector:

Terminal window
umask 077
openssl rand -hex 32 > /etc/ndr/secrets/webhook-secret
chgrp nyxtrace /etc/ndr/secrets/webhook-secret
chmod 0640 /etc/ndr/secrets/webhook-secret

In /etc/ndr/connectors.yaml:

connectors:
- id: webhook
instance: hook
tenant: example
enabled: true
config:
url: https://hooks.example.invalid/nyxtrace
# tls_fingerprint_sha256: "<sha256 of the receiver's leaf, self-signed only>"
# allow_insecure_http: true # plain http, same host or management network only
# events: incident.created, incident.escalated, incident.closed, health.transition,
# response.proposed, response.executed, response.failed, response.released
min_severity: medium # floor for incident and response events
timeout: 5s # per request, 1s..30s
max_pending: 1000 # 10..10000 undelivered events kept
# public_url: https://nyxtrace.example.invalid # defaults to NDR_PUBLIC_URL
secrets:
secret: file:/etc/ndr/secrets/webhook-secret
Field Meaning
events The subscription, comma-separated. Empty means every type.
min_severity Holds back incident and response events below the floor (info, low, medium, high, critical) — a response event carries its incident’s severity. A health transition and the test event are never held back by it.
timeout Per HTTP request, default 5 s.
retry_delay Wait before the second attempt on a 5xx or transport failure, default 1 s; doubled for the third, each plus jitter.
max_pending How far the instance may fall behind while the receiver is down. Beyond it the oldest events are dropped and counted.
public_url The dashboard origin for the links in every event. Defaults to NDR_PUBLIC_URL.

The links in every event use NDR_PUBLIC_URL from /etc/ndr/ndr.env (an https:// origin) or public_url on the instance; without either the events carry no links object. Set it once for the service:

/etc/ndr/ndr.env
NDR_PUBLIC_URL=https://nyxtrace.example.invalid

Validate as the service user, then send a signed test event:

Terminal window
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors validate
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors test hook --live

This is the one connector whose test writes, by design: a receiver is proven by receiving. The event says test in its type and in its X-Nyx-Event header so the receiver can route it to a log instead of a pager. A failure names its class: auth (the receiver refused the signature — the secrets differ), forbidden, tls (wrong or missing pin), unreachable, timeout, rate_limited, rejected (the receiver answered a 4xx it will answer again — usually a wrong path), unexpected_shape (a redirect).

systemctl restart ndr-api with NDR_CONNECTORS_ENABLED=true, as for every connector. From then on every new incident, every score that rises, every closure and every health component that changes state — ClickHouse, a sensor, a connector, the licence, the updater, the appliance as a whole — is delivered within about five seconds. Events from before the instance existed are not delivered: a new instance starts at the head of the log.

Every delivery is one request:

POST /nyxtrace HTTP/1.1
Host: hooks.example.invalid
Content-Type: application/json
User-Agent: nyxtrace-webhook/1
X-Nyx-Event: incident.created
X-Nyx-Delivery: 3f1c9a2e-7b4d-4c6e-9a0b-2d5e8f7a1c3b
X-Nyx-Timestamp: 1789900000
X-Nyx-Signature: sha256=4d1c…e9
Header Meaning
X-Nyx-Event The event type, identical to type in the body. Route on it before parsing.
X-Nyx-Delivery A random UUID (version 4) per HTTP request. A retry of the same event carries a new one.
X-Nyx-Timestamp Unix seconds at the moment of the request, from the appliance clock. Part of the signed string.
X-Nyx-Signature sha256= followed by the lowercase hex HMAC-SHA256 of <timestamp> + "." + <body bytes> under the instance secret.

The schema string is nyxtrace.webhook.v1. It changes only with a breaking change; every field added later is additive, and a receiver must ignore a field or an event type it does not know rather than fail.

{
"schema": "nyxtrace.webhook.v1",
"id": "evt_7c0b1e2f9a3d4c5e8f6a7b8c9d0e1f2a",
"type": "incident.created",
"occurred_at": "2026-09-12T10:15:03.000Z",
"tenant": "example",
"severity": "high",
"appliance": { "version": "0.29.0", "instance": "hook" },
"incident": {
"id": "inc_01J...",
"title": "Periodic TLS destination",
"severity": "high",
"score": 82,
"status": "open",
"device_ids": ["dev_01J..."],
"device_names": { "dev_01J...": "nas-01" },
"model_ids": ["periodic_destination_v1"],
"summary": "One device reached the same external endpoint every 300 s for 6 hours.",
"first_seen": "2026-09-12T09:15:00.000Z",
"last_seen": "2026-09-12T10:15:00.000Z",
"revision": 1
},
"links": {
"dashboard": "https://nyxtrace.example.invalid/",
"incident": "https://nyxtrace.example.invalid/incidents/inc_01J..."
}
}
Field Type Meaning
schema string nyxtrace.webhook.v1.
id string Stable event identifier, evt_ plus 32 hex characters. The deduplication key.
type string One of the event types.
occurred_at RFC 3339 UTC When the event happened on the appliance.
tenant string The tenant identifier of the site configuration. Never derived from a foreign system.
severity string, optional For incident events: info, low, medium, high, critical. Absent otherwise.
appliance.version string Product version of the sending appliance.
appliance.instance string The instance name of the sink in connectors.yaml. No host name is sent.
incident object, optional Present for incident events and response events: the fields of the incident list of the API.
incident.previous_score number, optional For incident.escalated: the score before the rise.
incident.device_names object, optional Device id → the name the appliance shows for it at delivery time (a manual name first, then the resolved name, then the inventory’s own). Present when at least one device could be resolved; a device the inventory does not know is absent from it, so always fall back to device_ids. Additive since 0.30.
health object, optional Present for health.transition.
response object, optional Present for the four response.* events. Additive since 0.32.
links object, optional Present when the appliance knows its dashboard origin. incident is present for incident events and response events.
type When Body
incident.created The analysis engine wrote a new incident. incident with status: "open", revision: 1.
incident.escalated An existing incident’s score rose. New evidence at the same score is not an escalation. incident after the change, with previous_score.
incident.closed An operator set the status to resolved or false_positive. A later change between those two is not a second closure. incident with the closing status.
health.transition A component of GET /api/v1/health changed its status (ok, stale, degraded, unavailable), or the overall status did (component id appliance). health.
test ndr connectors test <instance> --live. Neither incident nor health; links.dashboard when known.
response.proposed The producer of the automatic mode held a candidate for a person: a proposal awaits approval — because the calibration gate was closed, a gate was not met, or the executor refused an automatic action for the cap or the cooldown and it was reopened. incident and response with status: "proposed".
response.executed An automatic response action holds on every node. incident and response with lease_expires_at.
response.failed An automatic response action could not be applied, or was applied on part of the nodes only. incident and response with status: "failed" or "reconciling"; the reason in detail.
response.released An automatic response action was released or expired. incident and response with status: "rolled_back" or "expired".

The four response events exist since 0.32 and announce what the appliance did on its own. A human approval is not an event — the person who approved it already knows — nor is a proposal a person created. An instance written before 0.32 with an explicit events list does not receive them until the list names them; an instance with an empty events receives every type, including these.

Investigating (openinvestigating) and reopening are not events. Health is observed every 30 s and compared against the last recorded state, so a transition during a restart of the service is reported once it is back; the first observation of a tenant is a baseline and produces nothing.

device_names is looked up when the event is rendered for delivery, not when it was written: a week-old event that is finally delivered carries today’s name, and a lookup failure costs the names, never the event. The chat sinks print the same names.

The response object:

{
"response": {
"action_id": "resp_01J...",
"incident_id": "inc_01J...",
"kind": "quarantine_device",
"mode": "automatic",
"status": "executed",
"device_id": "dev_01J...",
"address": "198.51.100.42",
"identity": "dhcp_lease_and_mac",
"detail": "held on 2 node(s) until 2026-09-12T12:15:00Z",
"lease_expires_at": "2026-09-12T12:15:00Z"
}
}
Field Meaning
action_id, incident_id, kind The action, its incident, and quarantine_device or notify_only
mode automatic for an action the appliance approved itself, proposal for one it held for a person
status The action’s status at the time of the event — proposed, executed, failed, reconciling, rolled_back, expired
device_id, address The target: the device and the action’s own target address, the only address in the body
identity The identity proof grade the decision was taken on
detail The executor’s last explanation, or the gate’s reason on a proposal
lease_expires_at The membership deadline while one is held; absent otherwise

Alias names, node addresses and state ids are never sent, as on the API. The evidence classes and the full policy decision stay behind the link, like the incident’s evidence.

The health object:

{
"health": {
"component_id": "connector:unifi",
"kind": "connector",
"from": "ok",
"to": "unavailable",
"message": "controller is not reachable"
}
}

component_id and kind are the values of the health report; from and to are its status vocabulary; message is the admin-level diagnostic and may be absent. It carries no credential, but it can name a host-local address — which is why the receiver should be as trusted as an administrator of the appliance.

  1. Read X-Nyx-Timestamp; refuse a delivery whose timestamp is more than five minutes from your own clock, so a captured delivery cannot be replayed later.
  2. Compute HMAC-SHA256(secret, timestamp + "." + body) over the raw bytes you received — not over a re-serialisation — and compare it in constant time with the hex after sha256=.
  3. Deduplicate on id in the body.

In Python:

import hmac, hashlib, time
def verify(secret: bytes, headers, body: bytes, max_skew: int = 300) -> bool:
ts = headers["X-Nyx-Timestamp"]
if abs(time.time() - int(ts)) > max_skew:
return False
expected = "sha256=" + hmac.new(secret, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("X-Nyx-Signature", ""))

The same check from a shell, for a captured request whose body is in body.json:

Terminal window
ts=1789900000
printf '%s.' "$ts" | cat - body.json \
| openssl dgst -sha256 -hmac "$(cat /etc/ndr/secrets/webhook-secret)" -r \
| cut -d' ' -f1
# compare with the hex after "sha256=" in X-Nyx-Signature

The response body is ignored beyond the status. The status decides what happens to the queue:

Status What the appliance does
2xx Acknowledged. The bookmark moves; the event is never sent again.
401, 403 Held. The instance goes to error with code auth/forbidden and retries the same event after its backoff (30 s to 15 min). Fix the secret; nothing is lost meanwhile.
429, 408, 425 Held; Retry-After (seconds) is honoured as the backoff floor.
5xx, timeout, connection failure Retried up to three times within the delivery (retry_delay, then twice that, each plus jitter), then held for the backoff.
413, any other 4xx Rejected for good: counted as rejected, skipped, never sent again. The queue behind it moves on.
3xx Refused. Redirects are never followed; configure the final URL.

At-least-once, in order. Each event is handed to the receiver on its own — one request, one event — and the bookmark moves only after the receiver acknowledged it. A request that timed out on the appliance’s side after you processed it, or a crash between your 200 and the bookmark write, repeats that event with a new X-Nyx-Delivery and the same id. The receiver deduplicates on the id.

When the receiver was down. Undelivered events wait — up to max_pending, then the oldest are dropped and counted — and are delivered in order once the receiver answers again. A receiver that answers 401/403 holds the queue until the secret is fixed. A 400, 413 or another permanent 4xx is counted as rejected and skipped so the queue behind it moves.

The log behind it. Events are written into a shared log on the appliance in the same transaction as the incident they describe, so an incident and its event are durable together or not at all. The log keeps seven days or 10,000 events, whichever is smaller; an instance switched off longer than that resumes at the pruned edge and counts the gap as dropped. An appliance with no sink configured never grows it.

Replaying. A new instance starts at the head of the log — nothing that happened before it was configured is delivered. To send everything still held again, stop the service and reset the bookmark as the service user, exactly as for a source:

Terminal window
systemctl stop ndr-api
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true \
/usr/local/bin/ndr connectors reset-cursor hook
systemctl start ndr-api

The instance appears as connector:hook in GET /api/v1/health and on the system view with five counters, and the same numbers are nyxtrace_connector_count{instance="hook",counter="…"} on the metrics endpoint:

Counter Meaning
delivered Events the receiver acknowledged.
failed Delivery rounds that ended without an acknowledgement (held for the backoff).
rejected Events a permanent 4xx refused; skipped.
dropped Events passed over because the instance fell more than max_pending behind, or behind the pruned edge of the log.
pending Events not yet acknowledged.

The counters count for the life of the process; a restart starts them at zero.

The instance is ok while deliveries are acknowledged or nothing is pending, unavailable with the failure class after a delivery round the receiver did not acknowledge, and it never becomes stale — a quiet network is not a failing sink. ndr connectors list and journalctl -u ndr-api -g 'operation=sink' show the same numbers.

nyxtrace_connector_count{counter="pending"} > 0 for longer than the backoff is the alert worth having: a receiver that stopped answering.

  • One receiver per instance; two receivers are two instances, each with its own bookmark, backoff and counters.
  • A receiver that answers 401 forever holds the queue up to max_pending. That is intended — the event should not be lost because the secret is wrong — but it is visible only in the health line and the metrics.
  • Investigating and reopening are not events; nor is new evidence at an unchanged score.
  • Not yet exercised against a receiver outside the project’s own test harness; the status matrix is proven with recorded receivers.