Notifications
You want to hear about a failed deploy or a service that lost every healthy
replica before your users do. isb serve tells an org's people about what
happens to its apps and stacks: deployments that succeed or fail, services
that lose every healthy replica and recover, certificates issued or failing,
and backups and scheduled jobs. Each org has its own channels; a channel
is a destination plus rules choosing which events it hears about. Only
the org's own events ever reach its channels.
isb secret create OPS_HOOK <<<'https://hooks.example.com/isb' # the URL is a secret
isb secret create OPS_HOOK_KEY <<<"$(openssl rand -hex 32)"
isb notify create ops --webhook OPS_HOOK --signing-secret OPS_HOOK_KEY --events 'deploy.*,health.*'
isb notify test ops # sends a test message now, prints the outcome
isb notify deliveries ops # what was sent, retried, failed
isb notify create NAME (--webhook URL_SECRET [--signing-secret S] | --slack URL_SECRET | --discord URL_SECRET
| --telegram TOKEN_SECRET --chat-id ID | --smtp-host H [--smtp-port P] [--smtp-tls starttls|tls|none]
[--smtp-user U --smtp-password-secret S] --from ADDR --to ADDR...)
[--events deploy.*,health.*] [--app-project P]... [--app A]... [--stack S]... [--disabled]
isb notify ls [--json] | show NAME | rm NAME
isb notify update NAME [--events ...] [--app-project P]... [--app A]... [--stack S]... [--enable|--disable]
isb notify test NAME sends a test message now; exit 1 if it failed
isb notify deliveries NAME [-n 20] [--json]
isb notify settings [--allow-private-targets true|false] platform admins
All commands take --org ORG. The same operations are tools on MCP and REST
(below), so the web UI's Notifications section and agents use
them too.
Destinations#
Every URL, token and password lives in the org's secret store
(Secrets) and a channel names the secret, never the value. A
secret's value is read at send time, so isb secret set rotates it without
touching the channel.
| Type | Settings | Sends |
|---|---|---|
webhook |
url_secret, optional signing_secret |
A JSON POST (below) to any http(s) URL. |
slack |
url_secret: an incoming-webhook URL, https://hooks.slack.com/services/... |
{"text": ...}, the title in bold. |
discord |
url_secret: https://discord.com/api/webhooks/... |
{"content": ..., "allowed_mentions": {"parse": []}} (never pings). |
telegram |
token_secret (a bot token), chat_id (-100... or @channel) |
sendMessage to the Bot API, plain text. |
email |
host, port (default by tls), tls: starttls (587, default), tls (465) or none (25); username + password_secret; from; to (1 to 20) |
A plain-text mail through that SMTP server. A password is never sent without TLS. |
Slack and Discord URLs are checked against their hosts when the channel is
saved and again at every send; a Telegram token must look like one
(123456:ABC...), so it cannot change the request path.
The webhook body#
{
"id": "1791014653123-7",
"org": "acme",
"kind": "deploy.failed",
"level": "error",
"stack": "shop-production",
"service": "web",
"project": "shop",
"message": "app web: deployment 3 failed: build: ...",
"at": 1791014653123,
"seq": 412,
"test": false
}
Headers: Content-Type: application/json, X-Isb-Event: <kind>,
X-Isb-Delivery: <id> (the same for every retry of one delivery: receivers
deduplicate on it), and with a signing secret
X-Isb-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed by the secret>. To verify, compute the HMAC over the bytes received and compare in
constant time:
import hmac, hashlib
ok = hmac.compare_digest(sig, "sha256=" + hmac.new(key, body, hashlib.sha256).hexdigest())
at (unix milliseconds) is inside the signed body, so a receiver can refuse
old replays. Events whose producer adds structure carry it in details:
monitor.* events have the URL, HTTP status, latency, error, downtime and a
link to the monitor (Uptime monitoring); Slack,
Discord, Telegram and email show the latency and the link too.
Rules#
A channel's rules is a list; an event is sent when any rule matches. A
rule matches when the event's kind matches one of its events globs (*
matches any run of characters) and it passes every filter given:
| Field | Matches |
|---|---|
events |
deploy.*, health.*, monitor.*, backup.*, job.*, cert.*, *.failed, * (the default) |
projects |
the app's project (only app events have one) |
apps |
the app (a service of that name) |
stacks |
the stack's name in the org (<project>-<env> for apps) |
With no rules, a channel hears every event that has a kind. isb notify create builds one rule from --events, --app-project, --app and
--stack; the notification_channel_* tools take the full list. The web
UI's channel dialog ticks event kinds by subject and writes them back as the
shortest globs (*, deploy.*, *.failed).
Event kinds#
| Kind | When |
|---|---|
deploy.succeeded, deploy.failed |
An app deployment finished (build, push and rollout), or failed at any step. Preview deployments too. |
health.unhealthy |
A service that was healthy has had no healthy replica (none in rotation) for 20 s. Not raised during a rollout (a failed rollout is deploy.failed), nor before the service was first healthy in this daemon run. |
health.recovered |
A replica of that service is healthy again. |
cert.issued, cert.failed |
The ingress got a certificate for a domain, or failed to (Domains and ingress). |
backup.succeeded, backup.failed, job.succeeded, job.failed |
Database and volume backups and scheduled jobs. |
monitor.down, monitor.up |
An uptime monitor went down (its failure threshold reached), or came back (with the downtime). Once per incident; a flapping monitor is held until it settles. The body's details carry the URL, status, latency, error and a link. |
monitor.cert_expiring |
An HTTPS certificate a monitor sees expires within its cert_expiry_days; once per certificate. |
server.unreachable, server.recovered |
On a control plane: a server stopped answering its heartbeat, or answers again (stack <org>/@servers for each org on it, and system/@servers). |
The list is the kind field on the event feed (the events tool, the SSE
stream); a producer adding a kind adds it there, and a * rule hears it.
Delivery#
- A dispatcher follows the daemon's event feed. The feed lives in memory and starts over with each daemon run, so the dispatcher starts from the beginning of the run: an event is never sent twice across a restart. Deliveries still queued when the daemon stops are lost.
- Each channel has its own queue (100 deliveries; the oldest is dropped, and
logged as
dropped, past that) and its own sender, so a slow or broken destination never delays another channel. - A failed attempt is retried when it may succeed later (connection errors,
timeouts, HTTP 408, 429 and 5xx, SMTP 4xx), up to 6 attempts, waiting 5 s,
10 s, 20 s, 40 s and 80 s, or longer when the server sends
Retry-After(at most 5 min). Other answers (4xx, a refused address, a missing secret) fail at once. - A channel sends at most 20 messages a minute; more wait their turn.
notification_deliverieskeeps each channel's last 50 deliveries (in<state>/orgs/<org>/notify/deliveries/), with statusqueued,retrying,sent,failed,droppedorskipped(the channel was disabled or removed meanwhile), attempts, the HTTP status and the error. Errors name the host, never the URL or a token.notification_testsends one test message (kind: "test") at once, without retries, and returns its outcome; it is logged too.
Private destinations (SSRF)#
A channel's destination is chosen by an org member, so it must not become a
way into the host's network. Every destination (webhook URL, SMTP host, and
the fixed Slack, Discord and Telegram hosts too) is resolved by the daemon,
every address it resolves to is checked, and the connection goes to one
of those checked addresses, never re-resolved, so DNS rebinding cannot swap
one in; the connected peer is checked again. Redirects are never followed (a
3xx is a failure). URLs must be http:// or https://, without credentials.
Refused by default: loopback (127.0.0.0/8, ::1), private ranges
(10/8, 172.16/12, 192.168/16, fc00::/7), link-local (169.254/16,
fe80::/10, which holds cloud metadata services), shared address space
(100.64/10, which holds tailnets), benchmarking, documentation and reserved
ranges, and IPv6 forms that carry such an IPv4 address (::ffff:a.b.c.d,
::a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16; Teredo too). Numeric
host spellings (2130706433, 0x7f000001, 0177.0.0.1, 127.1) are read
as the address they name and refused the same way. Unspecified, multicast and
broadcast addresses are never reachable.
A platform admin can allow private targets for the whole server, for a receiver on the host or the LAN:
isb notify settings --allow-private-targets true # or the notification_settings tool
It is stored in <state>/notify.json and applies at once. The same policy,
never relaxed, guards template logo fetches
(Templates).
Tools#
| Tool | Does |
|---|---|
notification_channel_create |
Add a channel (name, provider, rules, enabled); its secrets must exist. |
notification_channel_list |
The org's channels, with each one's last delivery. |
notification_channel_get |
One channel. |
notification_channel_update |
Replace a channel's provider, rules or enabled; fields left out are kept. |
notification_channel_delete |
Remove a channel and its delivery log (its secrets stay). |
notification_test |
Send a test message now; returns the delivery. |
notification_deliveries |
A channel's recent deliveries, newest first. |
notification_settings |
allow_private_targets, server-wide. Platform admins. |
The channel tools act in one org: its members may use them (viewers only the
read-only ones, as everywhere), nobody else (Users, roles and superadmins). A provider is
written as an object with a type:
{"name": "oncall", "provider": {"type": "telegram", "token_secret": "TG_BOT", "chat_id": "-1001234567890"},
"rules": [{"events": ["deploy.failed", "health.*"], "projects": ["shop"]}]}
An org placed on a server keeps its channels on that server (Servers), so they hear about the server being unreachable only once it is back.