The notify service is how people hear about what happens on the network when they are not looking. It follows a daemon's activity feed, turns new comments and mentions into notifications, keeps an inbox and read state for each account, and sends email for mentions, replies and discussions. The web app, the desktop app, the vault and the mobile app all read their notification inbox from it. Notify always lives on a site's own origin, at /hm/api/notify: the hosted instance is https://hyper.media/hm/api/notify (development: https://dev.hyper.media/hm/api/notify), and a self-hosted site serves it at https://<site>/hm/api/notify. The reverse proxy in front of the site routes that prefix to the service.
Notifications are centralized on purpose, for now. The desktop app no longer discovers its own notifications over peer-to-peer sync. The notify server is the one source, and read state syncs only when a client is online. The team treats this as temporary. The plan is private peer-to-peer sync of subscriptions and read state.
Where the code is
frontend/apps/notify, package @shm/notify: a headless Bun service with Bun.serve, bun:sqlite, nodemailer and Sentry (@sentry/bun). It serves no HTML; the email settings page is part of the web app. The email templates are a separate package, frontend/apps/emails (@shm/emails, MJML), which also builds every link an email carries (notify-links.ts).
Path | What it holds |
|---|---|
| Boot: opens the database, starts |
| The route table: canonical paths, legacy aliases, CORS preflight. |
| The SQLite schema, migrations and statements. |
| The loops that read the activity feed, classify events, write inbox rows and send email. |
| The unified state API: snapshot and actions. |
| Request signatures and agent-capability checks. |
| The email sender: the hosting email relay when configured, SMTP otherwise. |
| One module per endpoint, exporting |
| The client transport, payload shape, read-state logic and comment classifier shared with every client. |
frontend/apps/notify/README.md is the file-by-file map of the service and its clients. This page is the description of how the pieces work together.
How it talks to the daemon
The service holds one gRPC-web client to DAEMON_HTTP_URL, the daemon's HTTP port. It pages through ActivityFeed.ListEvents for new events, loads comments, documents and accounts through the same typed API handlers the web app uses, and asks AccessControl.ListCapabilitiesForDelegate whether a signing key holds an AGENT capability for the account it claims. The hosted service reads from the hyper.media gateway daemon, so it sees what that node has synced.
The notifier
At startup the service processes once, then runs two loops.
Loop | Every | Produces |
|---|---|---|
Immediate | 15 seconds |
|
Batch | 30 seconds, sending at most every 4 hours (6 minutes in development) |
|
Each loop keeps a cursor in the notifier_status table. Event ids are derived from the blob, blob-<cid> or mention-<cid>-<type>-<target>, and the same id is the inbox row's key and the read-state key. A page loop is capped at 100 pages per pass.
Some pieces exist only in name. site-doc-update exists in the payload schema and the notifyOwnedDocChange flag exists in the settings, but the notifier has a TODO where document-update delivery should be, so nobody gets document-change email today. Batch-only notifications are not clearly written to the inbox.
The immediate loop advances its cursor in a finally block, so a pass that throws part way still marks its events processed. A transient daemon or SMTP failure can therefore drop the notifications in that batch. Inbox rows are written before email is attempted, so an email failure alone loses only the email.
Storage
One SQLite file, web-db.sqlite, in DATA_DIR or the working directory. The tables that matter:
Table | Holds |
|---|---|
| Persisted notification payloads per account. |
| A "read everything before this time" watermark per account, plus explicit reads above it. |
| An account's notification email and its verification. |
| Accounts registered for an inbox, used by the notifier and the vault. |
| Legacy public site subscriptions and one-click unsubscribe tokens. |
| Loop cursors and the last batch send time. |
The watermark design keeps "mark all as read" to one row and makes unread toggling reversible.
Read state rules
Read state is {markAllReadAtMs, readEvents} per account, and the same pure functions in @shm/shared (models/notification-read-logic.ts) decide it on the server, in the desktop app and in the browser. The rules are chosen so that every device converges on the same answer whatever order the actions arrive in.
An event is read when its time is at or below the watermark, or when it is listed in readEvents. An old event that arrives after "mark all as read" is therefore already read.
Merging takes the higher watermark, unions the read events keyed by event id with the later time winning, and then drops every entry at or below the merged watermark. readEvents only grows with items read one by one since the last "mark all as read".
Marking an event unread below the watermark lowers the watermark to just before that event and re-lists the loaded events between as explicit reads, so nothing else on screen flips to unread.
Watermarks are wall-clock times from the device that set them. A device with a clock in the future marks more as read than intended. This is the accepted cost of a timestamp watermark.
Event ids and times are the notifier's own (feedEventId, derived from the blob), so duplicate or reordered feed pages never split one notification into two.
The HTTP API
Every route is canonical under /hm/api/notify/<leaf>. The API leaves are also served at the legacy path /hm/api/<leaf>, because older clients hold an origin-only notify URL (such as https://notify.example.com, or a site's origin advertised as notifyServiceHost) and append /hm/api/<leaf>. Every response carries Access-Control-Allow-Origin: *, and OPTIONS answers the CORS preflight on every path. The signed routes take a POST body of DAG-CBOR: the client builds {action, signer, time, accountUid?}, signs the CBOR encoding, and sends it with sig. The service re-encodes the unsigned payload to verify the signature. If accountUid differs from the signer, the signer must hold an AGENT capability for that account, which is how a browser session key acts for the account held in the vault. The service accepts Ed25519 and compressed P-256 signers.
Leaf (canonical | Legacy alias | Kind and use |
|---|---|---|
|
| signed POST: |
|
| signed POST; the vault: inbox registration, email config, trusted email prevalidation |
|
| signed POST; compatibility only, no first-party client calls it |
|
| unsigned JSON POST; legacy site email subscriptions |
|
| token GET/POST; read and change the settings behind an email's admin token, for the web app's settings page |
|
| token; POST is the RFC 8058 one-click unsubscribe, GET unsubscribes and redirects to the settings page |
| none | email link (GET): verifies the notification email, then redirects to the settings page |
| none | email link (GET): marks the notification read, then redirects to |
| none | GET, |
Links in emails. Email links are built from the origin of NOTIFY_SERVICE_HOST (falling back to SEED_BASE_URL), so they always point at the site that hosts notify:
Link | URL |
|---|---|
Unsubscribe and Manage notifications (footer) |
|
|
|
Open a mention, reply or discussion |
|
Verify the notification email |
|
Welcome email's site link |
|
The redirects use a relative Location, so they resolve on the public origin the email pointed at whatever host the proxy forwards to. Links in emails sent before the move to /hm/api/notify (the old /hm/notification-read-redirect and /hm/notification-email-verify pages) no longer work.
Email prevalidation. A vault that has already verified a person's email signs {email, signer, host} with its daemon's key. The notify service accepts that instead of sending a verification email only when the host is in NOTIFY_TRUSTED_PREVALIDATORS, and only after fetching the host's /hm/api/config and checking that signerAccountUid matches the signer.
Configuration
Variable | Meaning |
|---|---|
| The daemon to read. |
| Listen port; 3000 by default and in the Docker image, 3560 in the hosted deployment, 3060 for |
| Where |
| The gateway site used for document links when a document's own site is unknown, and the email-link origin when |
| This service's own public base URL, canonically |
| Outgoing mail over SMTP, STARTTLS on port 587 by default (465 switches to implicit TLS, which is unreliable under Bun). Without host, user and password (and no relay), emails are logged and dropped. |
| The Seed hosting email relay: each email is one |
| Vault origins whose email prevalidation is trusted. |
| Error reporting. |
Running it
pnpm notify # bun --watch on :3060, against the desktop dev daemon on 58001
pnpm notify:standalone # its own daemon on 54000-54002 and notify on :3061
pnpm notify:build # bun build: one self-contained bundle, build/main.js
pnpm --filter @shm/notify test # vitest for the mocked suites, then bun test for *.bun.test.ts (bun:sqlite)./dev up starts it as the notify pane and points the vault pane at it. Images are seedhypermedia/notify:dev from main and seedhypermedia/notify:latest for releases: oven/bun running the prebuilt bundle as uid 1001, port 3000, data in /data (set DATA_DIR=/data). The hosted services are https://hyper.media/hm/api/notify and https://dev.hyper.media/hm/api/notify.
Working with it
In the Seed app
The desktop app keeps a local optimistic notification store and syncs it with /hm/api/notifications, signing each request through the daemon's SignData RPC. The notify URL comes from VITE_NOTIFY_SERVICE_HOST, https://hyper.media/hm/api/notify in release builds. Account settings can override it, and the override is saved in the synced vault state.
The store lives in the main process, in frontend/apps/desktop/src/app-notifications.ts, persisted in Electron app storage under the key NotificationsState-v001. For every key in the daemon's keystore it holds the last snapshot (inbox, read state, email config), a queue of actions not yet acknowledged, and the time and error of the last sync. A change in the interface is reduced into the local snapshot first, queued, and then synced. A sync fetches the server's snapshot, replays the queue on top, sends apply-notification-actions if the queue is not empty, and drops the actions the server applied. The loop runs every 30 seconds while a window is focused and every 5 minutes otherwise, and on demand. The renderer reads the local store over tRPC and never calls the notify service itself. So the desktop app can show state the server has not confirmed yet, and stale state while the service is unreachable. The files app-notification-config.ts, app-notification-inbox.ts and app-notification-read-state.ts next to it are thin tRPC facades over the one store.
Web API
A site advertises its notify service in /hm/api/config as notifyServiceUrl (the canonical base, such as https://site.com/hm/api/notify) and, for older clients, notifyServiceHost (that base's origin) (see The web API), set from NOTIFY_SERVICE_HOST on the web app. The web app's /hm/notifications page signs requests in the browser with the session key and stores the notify host it learned at sign-in. The mobile app reads the same field and falls back to the hosted service.
In the browser the notify host follows the signed-in session, not the page. The vault callback carries notifyServerUrl, the web app stores it in IndexedDB next to the session key, and frontend/apps/web/app/web-notifications.ts reads it from there. The browser signs the CBOR request with WebCrypto, adds accountUid because the session key is a delegate, fetches the snapshot into React Query, reduces each action into the cached snapshot before sending it, and refetches after the server answers. The browser never classifies feed events. It only renders the NotificationPayload rows the service persisted.
The web app has a second, older way to turn on email: the unsigned subscribe form in email-notifications.tsx, also offered after commenting, which posts JSON to /hm/api/public-subscribe and creates a legacy site subscription for the address. It is a separate product path from the signed inbox, with its own token-based settings page (/hm/email-notifications, served by the web app, which calls email-notif-token).
SDK
The SDK has no notify client. The transport lives in @shm/shared (models/notification-service.ts). A third party can reproduce it with the SDK's CBOR and signing helpers.
Agents
Seed Agents do not use the notify service. They watch the network themselves through the activity feed. See triggers.
Where this is going
As of September 2026, the team plans three changes. None of them is built.
Move subscriptions and read state out of this central server into private peer-to-peer sync once private documents mature.
Restore the link between an email and the account that subscribes. It was dropped in 2025 and later called a mistake.
Replace the legacy site subscriptions with per-site options for document changes, discussions, comments and mentions.
Email the recipient when someone grants them a writer capability, so "Add as writer" works as an invitation. The notifier does not look at Capability blobs today. (The Enterprise Edition covers the invite case differently: the vault emails the invitation and grants the writer capability itself.)