This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Notifications

For the complete documentation index, see 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:

KindWhat it needsSecret
Slackthe incoming webhook URL
Telegramthe chat id; a topic (thread id) of a forum group, optionallythe bot token
Webhookthe URL; extra headers (Name: value, one per line), optionallya 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:

FieldMeaningDefault
EnvironmentsThe project’s environments it watches.all
TriggersAny of schedule, ci, manual, cli — what set the run off.schedule
EventsAny of failed, recovered, not-run, passed.failed, recovered
ChannelsWhere 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 everyMinutes 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 orderedci, 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:0007: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:

HeaderValue
Content-Typeapplication/json
User-Agentunotest-boxd/<version>
X-Unotest-EventThe event’s state: failed, recovered, still-failing, not-run or passed.
X-Unotest-TimestampWhen the delivery was sent, Unix milliseconds.
X-Unotest-Signaturesha256=<hex> — present when the channel has a secret.

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

{
"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
}
FieldMeaning
versionPayload version; fields are added, never renamed or removed.
boxThe box’s public address, or null when it has none.
statefailed, recovered, still-failing, not-run, passed.
reasonpassed, 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.
exitCodePresent only when the box spawned the run itself and it left no verdict in its journal (90, 92, 94, 124).
scenariosTotal, scenariosFailedCounts from the run’s journal; null when the run left none.
failedScenariosUp to ten names, in journal order.
consecutiveFailures, failingSinceThe 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, bundleIdAbsent when there was no run (not-run) or the box has no public address.
atWhen 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.

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:

Terminal window
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.