Notification channels

A channel is an HTTP endpoint the worker POSTs to when a message fires. It is the outbound half of the notification model: the in-app store holds every message, and a channel carries the message, never the estate. This guide covers the default posture, declaring and editing channels under Settings → Channels, what fires and what a body contains, and why a Delivery is an operational record of an attempt rather than a message.

The ruling is ADR-0039; the full enumeration is docs/spec/notification-channels.md. The outbound code lives in internal/delivery/ and the Settings → Channels handlers in cmd/web/settings.go.


Default posture: nothing is routed anywhere

No channel ships configured. A default install writes and renders every message in the store and carries it nowhere else — the delivery loop enqueues nothing until an admin declares a channel. Configuring one is purely additive: it never changes what a message says or when it fires, only whether a copy is POSTed out.

The store is complete by construction. Every message is written and rendered whatever happens to any channel, so a misconfigured, disabled, or dead channel loses no fact — which is why the one surface that reports a delivery failure (the store) is the one surface that cannot have one.


The in-app store — the complement to channels

The store is the always-present surface a channel copies from. A global bell carrying an unread count sits on every screen; in the V3 shell it opens the Inbox at /inbox, and the older messages fold at /messages stays as the viewer-readable mirror. Both read the same message store, and read/unread state routes through the shared POST /messages/read and /messages/read-all acts. Any logged-in account may read them; declaring a channel is admin-only.

The store is never a channel: it is not configured, cannot fail, and no message may skip it. A channel is the opposite on every count — configured, able to fail, and skippable by class routing or by there being no channel at all.


Declaring a channel — Settings → Channels

Creating, editing, or deleting a channel is an admin act; the routes (POST /settings/channels, /settings/channels/update, /settings/channels/delete) sit behind an admin check. A viewer sees the channel table read-only, with no edit controls.

A channel holds four things:

FieldValue
URLAn absolute https:// URL. http:// is refused except to a loopback address literal (§ below).
SecretOptional. Signs the body with HMAC-SHA256; write-only — set, replaced, or cleared, never rendered back.
ClassesA subset of drift · coverage · clock. At least one must be chosen — this is the only routing axis.
EnabledBoolean. A disabled channel is skipped at routing time; disabling is not a delete.

Declaring one. Enter the URL, tick at least one class, optionally set a secret, and declare it — it lands enabled. There is no cap on the number of channels: each is one POST per message it subscribes to.

Editing one. The edit form updates the URL, classes, and enabled state together. The secret has its own write path so an edit that leaves the secret field blank keeps the stored one untouched; a typed value replaces it, and the clear the secret box removes it (and wins over any typed value).

Deleting one. Delete removes the channel row and is idempotent — deleting a row that is already gone satisfies the intent either way.

The URL rules

The URL is validated at configuration time, not at delivery time:

  • An absolute https:// URL is accepted, unless its host is an IP literal in a non-globally-reachable range (loopback, link-local including the 169.254.169.254 cloud-metadata address, private ranges) — that is refused as an internal address.
  • http:// is accepted only where the host is a loopback address literal. A loopback hostname is not accepted here: resolving a name to confirm it is loopback is exactly the rebinding surface the literal check avoids.

At delivery time the runner adds a second guard: it resolves the target host and refuses to POST if any resolved address is non-globally-reachable, and the HTTP dialer refuses the socket if the kernel is about to connect to such an address — closing the DNS-rebinding gap a literal-only config check leaves open. A body that is the operator's attack surface never leaves for an internal host.


What fires, and what a body contains

This guide does not decide what fires. The message set is closed elsewhere — ADR-0026, ADR-0029, ADR-0031, and ADR-0033 — and a channel carries whatever that set produces. Every message names one of four causes, which merge into the three routing classes:

CauseMeaningRouting class
driftThe estate's own object moved.drift
apertureUs — our own aperture widened.coverage
declared-inputThe operator's own declared input moved.coverage
thresholdOnly a clock or threshold was crossed; no measurement moved.clock

A channel subscribes on a subset of the three classes and nothing finer. The cause travels in the body as a field the operator reads, but the router never keys on it — per-cause, per-rule, and per-subject routing are all refused (ADR-0091).

When a message is sent: when the fold that caused it completes, with the census that fold could see — not held for a set of tiers and not emitted incrementally per batch. A message is computed once at the cause and never recomputed; the store rendering and the channel body are two renderings of that one computation.

The body

One JSON document per message, identical across every channel and every retry, carrying exactly what the in-app message carries and no rows:

FieldCarries
messageStable, unique identifier — unchanged across retries, the receiver's de-duplication key.
classdrift · coverage · clock.
causeWhich of the four causes fired (read, never routed on).
subjectThe bare (kind, key) of the thing the message fired at — the key, never a rendered label.
instantThe instant of the cause, not of the delivery attempt.
headlineThe rendered sentence, byte-identical to the in-app message's.
censusA count where the firing has one, omitted otherwise — never the rows behind it.
linkAn absolute URL into this instance at the fired-at object or scope (see below).

No field enumerates the services behind a census count, the addresses behind a resolution move, or the evidence behind a Signal. The receiver's disk and log pipeline accumulate what happened; an operator who wants what they have follows the link and authenticates.

Authentication, and that it is one-way

When a secret is set, the POST carries an HMAC-SHA256 signature over <unix-seconds>.<body> in the X-Verge-Signature header; the X-Verge-Timestamp header is always present because it is part of the signed input. With no secret, the URL is the only credential, as it is for any incoming-webhook receiver. There is no bearer header, ever — a bearer sits in the receiver's access log and a signature does not, and the signature authenticates us to them, not the reverse. The channel is strictly one-way: no callback, no ack, no fetch, no inbound surface.


Each body's link is an absolute URL into this instance at the object or scope the message fired at. It is built from VERGE_PUBLIC_URL, set on the worker service:

sh
# in .env / the worker service env
VERGE_PUBLIC_URL=https://verge.example.com

Left empty (the default), the link is simply omitted rather than fabricated. Add it to the worker env whenever you configure channels; see running.md → Environment variables. The target follows the mover: drift and threshold link to the fired-at subject's own page, declared-input to its Source, and an aperture widening to the Seed whose scope moved — never to Coverage's standing aperture statement.


A Delivery is a record of an attempt, never a message

A Delivery is the operational record of one POST attempt to one channel. It is not a message: it is not about a subject, carries no evidence, has no cause, and never touches Coverage. A delivery failure is not the world moving, our looking changing, or a clock crossing — so it earns no place in the message set.

What counts as a failure

OutcomeVerdict
2xxDelivered.
Any 3xxFailed — the redirect is not followed (it would move delivery to a host the operator never declared).
4xx, 5xxFailed.
Timeout, connection refused, DNS failure, TLS failureFailed.

Retry budget: five attempts over roughly one hour, exponential, then dead-lettered. Deliveries ride the measurement queue's own retry / backoff / dead-letter curve rather than a second mechanism — the same one running.md governs for scans. The budget is fixed and project-authored, not an operator dial: it governs request rate against someone else's server. A dead-lettered delivery marks the delivery undelivered and leaves the message untoucheda dead-lettered Delivery licenses no silence: it means we could not reach you, never nothing fired.

Semantics are at-least-once at the receiver (the message identifier de-duplicates), no ordering (retries and concurrent batches reorder), and no back-pressure — a dead channel never blocks measurement or the writing of a Span.

Where a delivery is seen

WhereWhat it shows
On the message, in the storeWhether it was delivered, to which channels, and whether any is dead-lettered.
On the channel, on its own surfaceCurrent state, consecutive failures, and the last error string as drill-down.
Nowhere elseIt is never a message and never a log line to go read.

The raw error appears only as drill-down on the channel surface — a configuration statement next to the thing the operator would change — never as a top-level log.


  • running.md — deploying the stack and setting VERGE_PUBLIC_URL on the worker service.
  • using.md — the first-run checklist; its Notification channels note points here.
  • reports.md — the other store-side read surface.
  • troubleshooting.md — diagnosing a channel that fails to deliver.