# Notifications

> For the complete documentation index, see [llms.txt](/llms.txt)

A box tells people how its runs went — in Slack, in Telegram, or as JSON
to a webhook of your own. Every run counts, whoever started it: the
schedule's ticks, a pull request's check, somebody pressing **Run** in the
viewer, a terminal inside the container. The daemon reads each run's own
journal, so the three producers are one source; a scheduled series is
announced on a **change of state**, a run somebody ordered on its own.

Channels and rules live on the box and are managed from the box's
administration page — nothing to edit in a file, nothing to restart.

## Channels

Open `https://<box>/_guard/notifications`, pick the project, and add a
channel:

| Kind | What it needs | Secret |
|---|---|---|
| **Slack** | — | the incoming webhook URL |
| **Telegram** | the chat id; a topic (thread id) of a forum group, optionally | the bot token |
| **Webhook** | the URL; extra headers (`Name: value`, one per line), optionally | a signing secret, optionally |

A secret is typed once into a password field and never shown again — the
page says *set*, and offers to replace it. A webhook's header values are
treated the same way (they usually hold the receiver's `Authorization`):
the edit form lists the names, and typing a value after one replaces the
whole set. The URL itself is shown, so keep credentials out of it and in a
header or the signing secret. A channel can be switched off
without losing it, tested with one button (**Send test** delivers a
recognisably fake `failed` event), and shows when it last delivered and
whether that went through. The first channel of a project also creates a
rule: the schedule's `failed` and `recovered`, every environment.

## Rules

A rule says **which runs** are announced **where**:

| Field | Meaning | Default |
|---|---|---|
| Environments | The project's environments it watches. | all |
| Triggers | Any of `schedule`, `ci`, `manual`, `cli` — what set the run off. | `schedule` |
| Events | Any of `failed`, `recovered`, `not-run`, `passed`. | `failed`, `recovered` |
| Channels | Where the message goes. | — |
| Failures before "failed" | Red ticks in a row before a scheduled series is called `failed` — the debounce for a flaky suite. | `1` |
| Remind every | Minutes between reminders while a scheduled series stays red. | never; **daily** for `not-run` |

The trigger is recorded in each run's manifest: a box marks its scheduled
runs `schedule` and its CI runs `ci` (with the push or pull request that
caused them), the viewer marks a run ordered from its UI `manual` (with the
login of whoever pressed the button), and a terminal's run reads as `cli`.

Next to every rule the page shows a **preview** of the message it would
send for each state.

### When a message is sent

For a **scheduled** run, one *series* is a project, an environment and a
target (`dogfood/prod · smoke`). The box remembers each series' last state
and announces transitions:

- **`failed`** — the series went red: a red tick after a green one, once
  the debounce is reached. A shorter streak is never announced, and never
  "recovers".
- **`recovered`** — green again, with how many ticks and how long it was
  red.
- **`still-failing`** — a reminder while it stays red, at the rule's
  interval.
- **`not-run`** — the suite could not run: no bundle has arrived, or the
  bundle does not install. Repeated daily unless the rule says otherwise,
  because a box with no suite must not be silent for a week.

For a run somebody **ordered** — `ci`, `manual`, `cli` — there is no
series: it is announced on its own, **`failed`** or **`passed`**, every
time, to the rules that subscribe to those events.

A withdrawn or aborted run says nothing. A run whose journal stops and
whose heartbeat goes cold is `failed` with the reason *interrupted*; a run
the box killed on its timeout, or whose `prepare` failed, is announced by
the box itself with that reason.

## Mutes and quiet hours

The **Series** table lists every scheduled series of the project with its
state, its streak and when it was last announced. A series can be
**muted** — until a time, or until unmuted — and **reset**, so its next
tick is its first again. The whole **project** can be muted the same way.
A muted `failed` is remembered but not sent, and the `recovered` that ends
a streak nobody heard about is not sent either.

**Quiet hours** (`22:00`–`07:00` in an IANA zone) hold back what can wait
for the morning: reminders and `passed`. A transition — `failed`,
`recovered` — and `not-run` go through regardless. Held reminders are not
queued: the next one comes at the rule's interval, outside the window.

## What a message says

The text is the same on every channel; Slack and Telegram add their
markup and a link to the run when the box knows its public address. The
last line names who set the run off.

```
❌ dogfood/prod · smoke failed — 3 of 42 scenarios
checkout/guest-order, catalog/search, account/login
2m 14s · web 0.32.0 · schedule
https://box.acme.dev/runs/col-smoke-…
```

```
✅ dogfood/prod · smoke passed — 36 scenarios
5m 12s · web 0.32.0 · manual · ivan
```

```
❌ dogfood/prod · smoke failed — 1 of 42 scenarios
checkout/guest-order
3m 40s · web 0.32.0 · CI · push a1b2c3d (feature/x)
```

```
✅ dogfood/prod · smoke recovered after 3 failures (6h 12m)
```

```
⚠️ dogfood/prod · smoke did not run — bundle cannot be installed
```

At most ten failed scenarios are named; the count says how many more. A
run that left no journal (a timeout, a failed `prepare`) names the reason
instead of counts.

## The webhook

A `webhook` channel receives one `POST` per event, JSON, with these
headers — plus any the channel declares:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `unotest-boxd/<version>` |
| `X-Unotest-Event` | The event's `state`: `failed`, `recovered`, `still-failing`, `not-run` or `passed`. |
| `X-Unotest-Timestamp` | When the delivery was sent, Unix milliseconds. |
| `X-Unotest-Signature` | `sha256=<hex>` — present when the channel has a secret. |

The body is the event, versioned, naming the box:

```json
{
  "version": 1,
  "box": "https://box.acme.dev",
  "project": "dogfood",
  "environment": "prod",
  "label": "dogfood/prod",
  "target": { "kind": "collection", "name": "smoke" },
  "state": "failed",
  "reason": "tests-failed",
  "trigger": { "kind": "manual", "by": "ivan" },
  "scenariosTotal": 42,
  "scenariosFailed": 3,
  "failedScenarios": ["checkout/guest-order", "catalog/search", "account/login"],
  "durationSeconds": 134,
  "consecutiveFailures": 1,
  "failingSince": 1788400800000,
  "runId": "col-smoke-2026-09-03T02-00-00-000Z-0123456789ab",
  "url": "https://box.acme.dev/runs/col-smoke-2026-09-03T02-00-00-000Z-0123456789ab",
  "webVersion": "0.32.0",
  "bundleId": "3f9c…",
  "at": 1788400934000
}
```

| Field | Meaning |
|---|---|
| `version` | Payload version; fields are added, never renamed or removed. |
| `box` | The box's public address, or `null` when it has none. |
| `state` | `failed`, `recovered`, `still-failing`, `not-run`, `passed`. |
| `reason` | `passed`, `tests-failed`, `timed-out`, `prepare-failed`, `interrupted`, `not-arrived`, `install-failed`. |
| `trigger` | `{ "kind": "schedule" \| "ci" \| "manual" \| "cli", "by"?: string }` — what set the run off, and who or which change. |
| `exitCode` | Present only when the box spawned the run itself and it left no verdict in its journal (`90`, `92`, `94`, `124`). |
| `scenariosTotal`, `scenariosFailed` | Counts from the run's journal; `null` when the run left none. |
| `failedScenarios` | Up to ten names, in journal order. |
| `consecutiveFailures`, `failingSince` | The red streak this event is about — the current one, or for `recovered` the one that just ended; `1` and the run's end for a run outside any series, `0` for `passed`. `failingSince` is Unix milliseconds. |
| `runId`, `url`, `webVersion`, `bundleId` | Absent when there was no run (`not-run`) or the box has no public address. |
| `at` | When the run finished, Unix milliseconds. |

### Verifying the signature

The signature is HMAC-SHA256 with the channel's secret over the
`X-Unotest-Timestamp` value, a dot, and the **raw bytes of the body
exactly as received** — no re-serialising, no canonicalisation — spelled
`sha256=<hex>`. It is GitHub's scheme with a timestamp in front, so a
delivery captured on the wire cannot be replayed to you later: refuse a
timestamp more than five minutes from your clock, then compare the
signature in constant time.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_SKEW_MS = 5 * 60_000;

export function verify(rawBody, headers, secret, now = Date.now()) {
  const timestamp = headers["x-unotest-timestamp"];
  if (!/^\d+$/.test(timestamp ?? "") || Math.abs(now - Number(timestamp)) > MAX_SKEW_MS) {
    return false;
  }
  const expected = `sha256=${createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex")}`;
  const a = Buffer.from(headers["x-unotest-signature"] ?? "");
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Read the body as bytes before any JSON middleware parses it: a body
re-serialised from JSON is not the body that was signed. The check on the
timestamp comes first, so a stale delivery is refused without the secret
ever being touched.

## From the command line

Everything the page does, the box's own CLI does too — for scripts, and
for an operator on the machine. Secrets come from stdin, never from the
command line:

```sh
printf '%s' 'https://hooks.slack.com/services/…' |
  docker compose exec -T boxd node /boxd.mjs notify channel add dogfood team-slack \
    --kind slack --secret-from-stdin webhookUrl
docker compose exec boxd node /boxd.mjs notify rule add dogfood \
  --channels team-slack --triggers schedule,manual --events failed,recovered,passed
docker compose exec boxd node /boxd.mjs notify mute dogfood \
  --series dogfood/prod/collection:smoke --until 4h --reason "known flake"
docker compose exec boxd node /boxd.mjs notify quiet-hours dogfood 22:00 07:00 Europe/Berlin
docker compose exec boxd node /boxd.mjs notify list
docker compose exec boxd node /boxd.mjs notify test dogfood team-slack   # exit ≠ 0 when not delivered
```

`boxd notify <command> --help` names the flags of each command. Channels
and rules are addressed by name or by id.

## The audit trail

Every change from the page is recorded — `notify.channel.created` /
`updated` / `removed` / `toggled`, `notify.rule.*` likewise,
`notify.test.sent` with its outcome, `notify.mute.set` / `cleared`,
`notify.series.reset`, `notify.quiet-hours.set` / `cleared` — naming the
channel, the rule or the series, never a secret.
