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.
Set it up
Section titled “Set it up”1. Choose the receiver and its URL
Section titled “1. Choose the receiver and its URL”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.
2. Create the signing secret
Section titled “2. Create the signing secret”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:
umask 077openssl rand -hex 32 > /etc/ndr/secrets/webhook-secretchgrp nyxtrace /etc/ndr/secrets/webhook-secretchmod 0640 /etc/ndr/secrets/webhook-secret3. Write the instance
Section titled “3. Write the instance”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:
NDR_PUBLIC_URL=https://nyxtrace.example.invalid4. Prove it
Section titled “4. Prove it”Validate as the service user, then send a signed test event:
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors validatesudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors test hook --liveThis 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).
5. Restart
Section titled “5. Restart”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.
What arrives
Section titled “What arrives”Every delivery is one request:
POST /nyxtrace HTTP/1.1Host: hooks.example.invalidContent-Type: application/jsonUser-Agent: nyxtrace-webhook/1X-Nyx-Event: incident.createdX-Nyx-Delivery: 3f1c9a2e-7b4d-4c6e-9a0b-2d5e8f7a1c3bX-Nyx-Timestamp: 1789900000X-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 envelope
Section titled “The envelope”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. |
Event types
Section titled “Event types”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 (open → investigating) 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.
Verifying a delivery
Section titled “Verifying a delivery”- 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. - 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 aftersha256=. - Deduplicate on
idin 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:
ts=1789900000printf '%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-SignatureWhat to answer
Section titled “What to answer”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. |
Delivery semantics
Section titled “Delivery semantics”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:
systemctl stop ndr-apisudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true \ /usr/local/bin/ndr connectors reset-cursor hooksystemctl start ndr-apiHealth and counters
Section titled “Health and counters”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.
Known limits
Section titled “Known limits”- One receiver per instance; two receivers are two instances, each with its own bookmark, backoff and counters.
- A receiver that answers
401forever holds the queue up tomax_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.