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 scenarioscheckout/guest-order, catalog/search, account/login2m 14s · web 0.32.0 · schedulehttps://box.acme.dev/runs/col-smoke-…✅ dogfood/prod · smoke passed — 36 scenarios5m 12s · web 0.32.0 · manual · ivan❌ dogfood/prod · smoke failed — 1 of 42 scenarioscheckout/guest-order3m 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 installedAt 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:
{ "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.
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:
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 webhookUrldocker compose exec boxd node /boxd.mjs notify rule add dogfood \ --channels team-slack --triggers schedule,manual --events failed,recovered,passeddocker 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/Berlindocker compose exec boxd node /boxd.mjs notify listdocker compose exec boxd node /boxd.mjs notify test dogfood team-slack # exit ≠ 0 when not deliveredboxd 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.