Alert rules and webhook

An alert is a message Nexus Telemetry Fleet sends by email or to a webhook when a terminal changes state. This page is the reference behind the dashboard’s Alerts page and the alert settings in Settings: every kind of alert, the rules that hold one back, and the exact shape of a webhook post.

Kinds

An alert is one of 8 kinds. The first 4 are the tick boxes under Alerts in Settings. The fleet server raises the other 4 on its own.

Kind Tick box Raised when Cleared by
offline is offline The collector disconnects, or stays connected and sends nothing for 120 seconds or more recovered
restricted is service restricted The terminal reports a Starlink restriction recovered
on_backup has failed over to its backup connection Traffic moves to the backup connection recovered
recovered has recovered The terminal’s state returns to healthy, the collector reconnects, or a stale collector reports again n/a
flapping none 4 or more state changes in 30 minutes. No further alert is sent for the terminal until stable stable
stable none A flapping terminal has had no state change for 30 minutes n/a
reminder none 4 hours have passed since the last message about an alert that is still open. Sent again every 4 hours n/a
test none A user presses Send test alert on one channel. The test goes to that channel only n/a

An unticked kind is sent to neither channel.

An offline alert waits 2 minutes before it is sent. A recovered inside those 2 minutes cancels it, and that recovered is not sent either.

Suppression and reminders

Rule Value Scope
Confirmation hold 2 minutes offline only
Repeat suppression 15 minutes The same kind for the same terminal
Reminder every 4 hours While an alert that was sent stays open
Sends per organisation 20 an hour. Alerts above that wait for the next hour The whole organisation
Delivery pass every 30 seconds, 20 alerts at a time The whole organisation

None of them can be changed.

The 15 minutes count from the last alert queued for delivery, not from one held by the cooldown, so a terminal in constant trouble raises one alert every 15 minutes. A different kind, or a different terminal, is never held. A held alert is recorded in the delivery log with the reason. The cooldown never holds a test alert. The hourly cap can.

When several alerts are due on one pass, they are sent as one email. Webhook posts are never combined: there is one post per alert.

Email

Email is sent through the SendGrid API, one request per delivery, to up to 10 recipients. SMTP is not supported. NF_SENDGRID_API_KEY, or NF_SENDGRID_API_KEY_FILE, holds the key, and NF_ALERT_FROM holds the sender address. Both are read at every send. A running process cannot see a change to its own environment, so only the file named by NF_SENDGRID_API_KEY_FILE can be changed without a restart.

Part Shape
Subject, one alert [Nexus Telemetry Fleet] <terminal name>: <kind>
Subject, several alerts [Nexus Telemetry Fleet] <n> alerts: <n> <kind>, <n> <kind>
Body One sentence naming the terminal and what happened, then Detail: when there is one, then At: with the time in UTC, then a link to the terminal’s page

The dashboard link is left out of a test alert, and out of every message when the fleet server has no NF_SERVER_URL. A send that fails is recorded in the delivery log with SendGrid’s reason.

Webhook

The fleet server sends one JSON post per alert, to one address per organisation.

Address

Rule What happens
https:// Accepted
http:// Accepted only for localhost, 127.0.0.1 or ::1, and only on a fleet server with NF_ALLOW_PRIVATE_WEBHOOKS set
https:// to a private network address Accepted only on a fleet server with NF_ALLOW_PRIVATE_WEBHOOKS set. Setting it to anything, an empty value included, allows them
A link-local, metadata, unspecified or broadcast address Refused always, whatever else is set
Whitespace or a control character Refused
Over 2048 characters Refused

The check applies to an IP address in the URL. A host name that resolves to a private address is accepted.

Signing

A signing secret is optional, up to 128 characters. With one saved, every post has a signature header. With none, the post is sent without one.

Part Value
Header X-Nexus-Telemetry-Signature
Algorithm HMAC-SHA256
What is signed The request body bytes, and nothing else. No timestamp, no method, no path
Encoding Lower case hex, prefixed sha256=, so 71 characters in all

A receiver verifies a post by computing the HMAC over the raw body, before parsing it, and comparing the result with the header in constant time. Example post under Alerts in Settings shows a sample post and its signature to test against.

bash
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET"

The result must equal the 64 hex characters that follow sha256= in the header.

Delivery

Rule Value
Method POST
Content type application/json
Other headers None. No authentication header, no delivery id, no timestamp
Timeout 10 seconds
Success Any 2xx
Retries 24 attempts, backing off 1 minute, 5 minutes, 30 minutes, then hourly
After that The alert is marked failed and never retried

A 4xx response is retried the same as a 5xx. An alert counts as delivered only when every channel accepted it. A retry sends the alert to every channel again, so a webhook that keeps failing means repeated emails.

The delivery log records when the alert was raised, its kind, the terminal, the number of attempts, whether it was held and why, and the error from the last attempt. The error records the response status, and the first 240 characters of a SendGrid error body. It does not record a webhook’s response body, or the signature.

Payload

The body of a post is one JSON object with 7 fields. Every field is always present and never null. The JSON has no whitespace, and those exact bytes are what the signature covers.

json
{"at_ms":1756800000000,"collector_id":"c-example","collector_name":"Head office","detail":"","event":"offline","org_id":"org-example","suppression":"none"}
Field Type What it is
event string The kind: offline, restricted, on_backup, recovered, flapping, stable, reminder or test
collector_id string The terminal’s stable id, or the literal test
collector_name string Its display name when the alert was raised, so a later rename is not reflected. test for a test
detail string A short line describing the event, or ""
at_ms integer When the alert was raised, in Unix milliseconds. A retry keeps the original value
org_id string The organisation the alert belongs to
suppression string Whether the email for this alert was held, and why: none, maintenance, silenced or acked. The webhook post is sent either way

For recovered, suppression is none or maintenance. A silence or an acknowledgement never holds back a recovery.

One example per kind

json
{"at_ms":1756800420000,"collector_id":"c-4f21","collector_name":"Depot","detail":"IN_OCEAN","event":"restricted","org_id":"org-acme","suppression":"none"}

detail holds the restriction code the terminal reports.

json
{"at_ms":1756800900000,"collector_id":"c-4f21","collector_name":"Depot","detail":"active carrier Vodafone NL","event":"on_backup","org_id":"org-acme","suppression":"silenced"}

detail names the network traffic is now on.

json
{"at_ms":1756803600000,"collector_id":"c-4f21","collector_name":"Depot","detail":"returned to service","event":"recovered","org_id":"org-acme","suppression":"none"}

detail reads returned to service when the collector reconnected, back online when a stale collector reported again, and "" when the terminal’s state returned to healthy.

json
{"at_ms":1756804200000,"collector_id":"c-9a02","collector_name":"Site 3","detail":"5 state changes in the last 30 minutes; further changes are held until stable","event":"flapping","org_id":"org-acme","suppression":"none"}
json
{"at_ms":1756806000000,"collector_id":"c-9a02","collector_name":"Site 3","detail":"no state changes for 30 minutes","event":"stable","org_id":"org-acme","suppression":"none"}
json
{"at_ms":1756818000000,"collector_id":"c-4f21","collector_name":"Depot","detail":"still offline after 4h 0m","event":"reminder","org_id":"org-acme","suppression":"none"}

detail names the state and how long the terminal has been in it.

json
{"at_ms":1756800000000,"collector_id":"test","collector_name":"test","detail":"Test alert to webhook requested by admin from the dashboard","event":"test","org_id":"org-acme","suppression":"none"}

detail names the user who pressed the button.

Receiver

A receiver answers 2xx first, then processes the alert. It verifies the signature over the raw body, before parsing the JSON. The example below does both.

bash
node receiver.js
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.WEBHOOK_SECRET;

createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    if (SECRET) {
      const want = 'sha256=' + createHmac('sha256', SECRET).update(body).digest('hex');
      const got = req.headers['x-nexus-telemetry-signature'] || '';
      const a = Buffer.from(want);
      const b = Buffer.from(got);
      if (a.length !== b.length || !timingSafeEqual(a, b)) {
        res.writeHead(401).end();
        return;
      }
    }
    res.writeHead(200).end();
    const alert = JSON.parse(body.toString('utf8'));
    console.log(alert.event, alert.collector_name, alert.detail);
  });
}).listen(8787, () => console.log('listening on 8787'));

WEBHOOK_SECRET is the receiver’s own variable, not a fleet server setting. The fleet server stores the secret with the organisation.

To test a receiver, save its address under Alerts in Settings and press Send test alert on the Webhook row. The test is sent on the same 30 second pass as a real alert, and the Alerts page records whether it was delivered.

The address http://127.0.0.1:8787 can be saved only on a fleet server with NF_ALLOW_PRIVATE_WEBHOOKS set. See Server configuration.