Skip to content
Hoody.com

The Notification Server runs inside your container and sends Linux desktop notifications over HTTP. Each request dispatches through notify-send on the container’s X11 display, the same mechanism Linux desktop notification daemons use.

  • Display notifications: notify-send delivers a Linux desktop notification to a container’s X11 display.
  • WebSocket streaming: subscribe to a live feed of notifications, filtered server-side per display.
  • History: query past notifications for one or more displays, with limit and time-range filters.
  • Custom icons: attach an image. When the recorded icon is stored in the kit’s icons directory, each history entry returns an icon_url on the icons endpoint.
  • Urgency levels: low, normal, and critical.
  • Auto-expiration: expire_time controls how long a notification stays on screen.
  • Categories: tag a notification with a category for client-side organization and filtering (history queries filter by limit, since, username, and session; category is returned on each notification, not a server-side filter).

The Notification Server receives HTTP POST requests and calls notify-send on the specified container display. The display parameter maps to an X11 display identifier: "1" (equivalent to :1), or ":2".

Notifications appear wherever that display is rendered: in a Hoody display viewer session, a VNC session, or any remote desktop client connected to the container.

Every notification you send is routed to a specific X11 display inside the container. The flow is:

  1. The kit’s HTTP handler calls notify-send, which speaks D-Bus to the notification daemon running on the target display.
  2. The notification daemon runs a logging hook that records each delivered notification to a per-display history JSON file in the kit’s notification-history directory.
  3. The kit’s file watcher (inotify on that directory) picks up the new entry and fans it out to any WebSocket subscribers.

You do not need to set up an X server, D-Bus, or a notification daemon yourself. A display’s services come up in two ways:

  • When you create a terminal or desktop session with a display argument: Hoody Terminal boots the X server and the display’s notification session the first time a session for that display is created.
  • On demand, when a notification is triggered: before dispatching, the notifications kit calls Hoody Terminal to ensure the target display exists (display-ensure, enabled by default). A first notification to a not-yet-running display normally succeeds once the display boots. If ensure cannot reach the display’s notification session within the display-ensure timeout (30s by default), and no inherited D-Bus session is available, the trigger returns HTTP 500 instead.
// Creating a terminal session with `display: '1'` is enough. When this
// returns, the display and its notification session should be ready on :1, so notifications to
// display "1" do not need a separate manual display start.
await box.terminal.sessions.create({
terminal_id: '1',
display: '1',
wait_until_display: true,
});
await box.notifications.send({
display: '1',
summary: 'Build complete',
body: 'Your deployment finished successfully',
});

The same is true when you open a display via the URL path (terminal-N?display=N&redirect=display, display-N, etc.): every entry point that brings a display up also brings up its notification daemon.

If box.notifications.send returns:

{
"success": false,
"error": "Notification dispatch failed",
"details": "The display's notification session is not available yet. Start the display and retry."
}

…then the kit’s automatic display-ensure could not obtain a working D-Bus session for :N. The display, or the display’s notification session, did not come up in time. Every dispatch failure uses the same error string, so read details to tell them apart. details is one of three fixed sentences: the one above, Sending the notification timed out. Retry later., or The notification service failed to send the notification. The steps below apply to the first. Spawn the display explicitly via the SDK and retry:

// Recovery: bring up display :N and its notification session explicitly, then retry.
await box.terminal.sessions.create({
terminal_id: 'N',
display: 'N',
wait_until_display: true,
});
await box.notifications.send({ display: 'N', summary: '…' });

Keeping the session alive is also a latency optimization: after a successful ensure, repeated triggers reuse the display-ensure cache for 60 seconds. Once the cache expires, ensure runs again and is usually quick for an already-running display. If you tear the session down with terminal.sessions.delete(terminal_id), the next trigger may first try the cached notification session, then invalidate it after a notify-send failure and re-run ensure. It can still fail if ensure cannot produce a D-Bus session, or if notify-send keeps failing before the retry deadline.

All endpoints are relative to your Notification Server URL:

https://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.com

Triggering:

Fetching:

  • GET /api/v1/notifications/{display} - Get notification history for a display
    • Query params: limit (default 100, range 1-1000), since (Unix milliseconds), after_id (return only IDs above this one), cursor (a next_cursor value from an earlier response), username, session. Responses carry next_cursor and has_more

Dismissals:

  • POST /api/v1/notifications/dismiss - Mark notification IDs as dismissed, optionally scoped to one displayId
  • DELETE /api/v1/notifications/dismiss - Clear dismissals for one ?displayId=, or every scope when it is omitted

Metrics:

  • GET /api/v1/notifications/metrics - Prometheus metrics (uptime, active WebSocket connections, notification counts)

Streaming:

  • WebSocket: wss://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/stream?displays=1,2 (or displays=all)

Icons:

Health:

Terminal window
# Send a notification to display 1
# All kit commands require container targeting: -c <container-id> (or HOODY_CONTAINER)
hoody notifications send -c CONTAINER_ID \
--display "1" \
--summary "Build Complete" \
--body "Your deployment finished successfully"
# Get notification history for display 1
hoody notifications list "1" -c CONTAINER_ID

The notification appears on the container’s X11 display 1, visible in any connected display session.

The display parameter specifies which X11 display to target inside the container:

ValueMeaning
"1"Display :1 (the primary desktop)
":1"Same as above, explicit X11 format
"2"Display :2
":2"Same as above, explicit X11 format

The display field is always a JSON string, even when the value is a number ("1", not 1). A JSON number is rejected with a "display" is required validation error. The field is also required, with no default. It must be a syntactically valid X11 display ID: 1 to 5 digits, optionally colon-prefixed ("1", ":2", "10"), naming a display from 1 to 40000. Leading zeros are allowed, so "0001" is display 1. The display does not have to be running before the request, because the kit ensures it on demand.

See Display routing for how that happens automatically, and Manual display startup for recovery if ensure cannot obtain a D-Bus session.

low - Subtle, dismisses quickly:

{
"display": "1",
"summary": "Background task finished",
"urgency": "low",
"expire_time": 3000
}

normal - Standard notification:

{
"display": "1",
"summary": "Build Complete",
"urgency": "normal"
}

critical - Highest urgency; often remains visible until dismissed, depending on daemon policy:

{
"display": "1",
"summary": "System Alert",
"body": "Immediate action required",
"urgency": "critical",
"expire_time": 0
}

POST /api/v1/notifications/notify validates every field before it reaches the display:

FieldLimit
summaryRequired, 1 to 200 characters (whitespace-only is rejected)
body1000 characters
category1 to 50 characters when present (empty or whitespace-only is rejected)
urgencylow, normal or critical
expire_timeMilliseconds, 0 to 300000 (five minutes)

expire_time is passed through to notify-send -t unchanged, so 0 means the notification does not expire on its own. Anything outside the range comes back as a validation error rather than being clamped.

Two counters run in front of the service, both over a sixty-second window and both keyed on the caller’s IP address as the service sees it.

CounterDefaultCovers
General60 requests / 60 sEvery route, including history, dismissals, metrics and health
notify10 requests / 60 sPOST /api/v1/notifications/notify, on top of the general counter
icons100 requests / 60 sGET /api/v1/notifications/icons/{iconId}, on top of the general counter

Exceeding one returns 429 with {"success": false, "error": "Rate Limit Exceeded", "message": "..."} and a Retry-After header giving the seconds until the window resets, rounded up to a whole second and never less than 1. Each counter is kept per client IP. The window is fixed rather than sliding: the counter resets sixty seconds after its first request, so a burst of ten notifications in one second spends the whole notify allowance for that window.

Ten per minute is the number that catches people, because the use cases on this page are exactly the bursty ones. A CI job that notifies once per failing test, a monitor that fans out per host, or a cron script that loops over a list will hit it. Batch the results into one notification, or space the calls.

The operator sets all three with --rateLimitMaxRequests, --rateLimitNotifyMax and --rateLimitIconsMax, and the window with --rateLimitWindowMs. A Hoody container passes none of them, so the defaults above are what you get.

Monitor notifications in real time. The stream endpoint upgrades to a WebSocket (HTTP 101 Switching Protocols).

const ws = new WebSocket(
'wss://PROJECT_ID-CONTAINER_ID-n-1.SERVER.containers.hoody.com/api/v1/notifications/stream?displays=1'
);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'notification') {
// msg = { type: 'notification', display: '1',
// data: { id, display_id, appname, summary, body, urgency, … } }
console.log('New notification on display', msg.display, msg.data.summary);
}
};

The stream sends one notification message per emitted notification. The endpoint upgrades the connection to a WebSocket when the client requests an upgrade, and falls back to Server-Sent Events when it does not; the SSE path requires displays and opens with a connected event. On WebSocket the upgrade itself signals connection. Both transports push a JSON {"type": "heartbeat", "timestamp": ...} message: SSE every 15 seconds, WebSocket every WS_HEARTBEAT_INTERVAL (30 seconds by default) alongside a protocol Ping. The SDK surfaces these via onHeartbeat. If a client falls behind and the server drops notifications it could not deliver in time, the stream sends {"type": "resync", "skipped": N, "timestamp": ...}; re-fetch history with GET /api/v1/notifications/{display} to fill the gap. A history file that appears while the server is running is broadcast in full, once; for files that already existed when the server started, only rows added later are broadcast. A WebSocket request from an origin outside WS_ALLOWED_ORIGINS is refused with HTTP 403 before the upgrade. The server can still push a JSON error message for an invalid initial display id, or a connection or rate limit. See the streaming reference for the full message shapes.

The displays query parameter chooses which displays the subscription receives:

displays= valueBehavior
1Only notifications routed to display 1
1,2,4Notifications routed to any of displays 1, 2, or 4
allAll displays, useful for debug subscribers and dashboards

Display numbers may carry a colon or leading zeros (:1 and 00001 both mean display 1), on the URL and in WebSocket subscribe and unsubscribe messages.

Per-display subscriptions are filtered server-side: a subscriber to display 1 never receives a notification triggered on display 2, even if both are active in the same container. That lets you route each user’s tab to that user’s display:

// User A's tab: subscribed to display 10
new WebSocket('wss://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/stream?displays=10');
// User B's tab: subscribed to display 20
new WebSocket('wss://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/stream?displays=20');

The filter is routing, not access control. Any client that can reach the stream URL chooses its own displays= value, including all, so user A can read user B’s notifications by changing the query. If users must not see each other’s notifications, give them separate containers or restrict the Notification Server URL with proxy permissions.

Subscribing to a display does not start that display; only trigger requests ensure displays on demand. Pre-creating sessions with parallel terminal.sessions.create calls is optional: do it when you want lower first-notification latency, or visible desktops ready before the first trigger.

Query past notifications:

Terminal window
# Last 50 notifications on display 1
curl "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/1?limit=50"
# Time range
curl "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/1?since=1749025000000"
# Multiple displays
curl "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/api/v1/notifications/1,2,3?limit=100"

Display numbers in the path may be zero-padded (/00001 is display 1); data.displays lists each requested display once, without leading zeros. limit defaults to 100 and must be between 1 and 1000. Without since, after_id, or cursor the response holds the newest limit notifications. With one of them it holds the oldest limit notifications after that point. Either way the list is sorted newest first; notifications with the same timestamp are ordered by display, then by ID, both descending.

To read forward without gaps, use cursor. Every response includes data.next_cursor, the position of the last row the page selected (the highest ID after an after_id page, otherwise the newest row), and data.has_more, which is true when more rows already exist after that position. Pass next_cursor back unchanged as ?cursor=; the response holds the next rows that the original request matched, with nothing skipped or repeated. A cursor from an after_id page continues in ID order; any other cursor continues in time order, and rows that share a timestamp are never skipped. Request again straight away while has_more is true, then poll at your own interval.

  • To follow new notifications, make one request without a cursor (limit=1 is enough) and continue from its next_cursor.
  • To read the whole history from the start, request since=0 and continue from that page’s next_cursor.

An empty page returns the cursor you sent, so polling never loses its place. next_cursor is null only when the page is empty and you sent no cursor. has_more is always false on a newest-rows page, one sent without since, after_id, or cursor. Treat the cursor value as opaque. Combining cursor with since or after_id, or sending a malformed cursor, returns HTTP 400.

since (Unix milliseconds, inclusive) and after_id (exclusive) set the starting point; follow next_cursor from there rather than moving since or after_id yourself. since and after_id can be combined, and every page reached by following next_cursor keeps both filters (timestamp at or after since, ID above after_id). after_id compares notification IDs, and each display numbers its notifications separately, so it suits only single-display queries.

Without username or session, every history file is read, including the shared notifications.json and each user’s session files. A request for specific displays (for example /3 or /3,4) then returns only rows whose display matches. These are the same files the stream watches, so any notification the stream delivered can be fetched again, unless it was dismissed or has expired.

username and session (letters and digits, up to 100 characters) select notifications by the history file they were recorded in. username matches files recorded for that user, including names such as user or display; session matches files for that session; both together match that user’s session. With either filter, the shared per-display history files are left out.

POST /api/v1/notifications/dismiss takes {"notificationIds": [12, 13]} and, optionally, "displayId" to scope the dismissal to one display. DELETE /api/v1/notifications/dismiss clears them again: with ?displayId= it clears that display’s scope, and without it every scope, global and per display. displayId is a display number from 0 to 99999, as an integer or a string with or without the colon; leading zeros are ignored, so ":00001", "001" and 1 all name display 1.

Dismissal is not cosmetic. Dismissed IDs are filtered out of GET /api/v1/notifications/{display} from then on. A notification is hidden when its ID is dismissed globally or in its own display’s scope, whether you query one display, a list such as 1,2, or all: the rows are skipped in the response rather than flagged, so a history query that returns fewer rows than you expect may be showing you the effect of an earlier dismissal. Clear the dismissals if you need the full record back.

Long-running build completes → HTTP POST → notification appears on the container display in a running display session.

Server alerts → HTTP → desktop notification on the container’s X11 display, visible in any active display session.

Data exports, video rendering, ML model training, and backup completion can announce themselves when they finish, without polling.

Cron jobs, scheduled tasks, and automation scripts can send notifications to the container display upon completion or failure.

Use consistent display IDs (per-user, per-tenant, per-purpose) and document your mapping. For latency-sensitive paths, keep a terminal or desktop session alive for displays you send to often; otherwise the notifications kit ensures the display on demand when a trigger arrives.

Write clear, actionable summaries. Put the relevant context in the body, set an urgency level that matches the event, and avoid sending notifications a reader will not act on.

Subscribe only to the displays a dashboard needs, implement reconnection logic, filter client-side if you need to, and show notification history in the UI.

Notifications appear on the container’s X11 display. Access the display via a Hoody display viewer session, VNC, or any remote desktop client connected to the container. Without a display viewer, a browser page that loads the kit’s client subscribes to the stream and shows each notification as a browser notification after the user grants permission. Browser support for notifications varies, and the page has to stay open.

<script src="https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com/notification-client.v2.js?displays=1,2"></script>

Use the versioned /notification-client.v2.js. The older /notification-client.js still serves the same client, but earlier releases let browsers cache it for a year, so embeds that use it can keep running an old copy; switch them to v2. displays is required. The client uses a WebSocket by default (mode=polling polls over HTTP instead); it never switches between the two on its own. Notifications stored before the page opened are not shown, except that polling mode shows the 10 newest. A notification stored while the client is still starting up, with a timestamp no newer than the newest one already stored, can be counted as history and not shown. The client reconnects on its own, and after a reconnect, or when the server reports that it fell behind, it fetches the notifications it missed, so each notification is shown once. A failed catch-up or poll is retried with backoff and never sooner than the server’s Retry-After. In a browser without notification support the client stays inactive and opens no connection, unless it runs in an iframe whose parent page shows notifications for it. autoPermission=false stops the client from asking for permission by itself, and showNotifications=false keeps the connection without showing anything. username limits the client to one user’s history files; leave it unset unless notifications are stored per user. The live stream cannot filter by user, so with username set the client polls over HTTP even when mode is websocket. To embed the client in an iframe instead of a script tag, point the iframe at /notification-client-frame.html?displays=1.

Not as push notifications. The kit runs notify-send on a container display and has no mobile push transport. A phone browser with the kit’s client open, and notification permission granted, can show relayed notifications while that page stays open and connected; once it closes, nothing is delivered to the phone.

It’s an X11 display identifier (e.g., "1" for display :1). It must be syntactically valid (1 to 5 digits, optionally colon-prefixed) and name a display from 1 to 40000; the kit ensures that display before sending. If ensure cannot obtain a D-Bus session, or notify-send still fails before the retry deadline, the trigger returns HTTP 500.

What if I send to a display that doesn’t exist?

Section titled “What if I send to a display that doesn’t exist?”

The kit first tries to bring the display up automatically (display-ensure). A non-existent display only becomes an error if ensure cannot provide a usable D-Bus session, or if notify-send still fails before the retry deadline. That failure is typically HTTP 500 with {"success": false, "error": "Notification dispatch failed", "details": "The display's notification session is not available yet. Start the display and retry."}. The recovery path is to spawn the display via terminal.sessions.create({ terminal_id, display, wait_until_display: true }), then retry. See Manual display startup. Stream subscribers to a non-running display receive nothing rather than an error.

No. The kit calls notify-send, which speaks the freedesktop D-Bus notification protocol, so both an X11 display and the display’s notification daemon must be running. Both start automatically: either when you create a terminal or desktop session with a display argument, or on demand when the kit ensures the display before a trigger. You do not install or run them yourself.

Usually. The notification server runs inside your container, so delivery does not require internet access once the container, display services, D-Bus, and the notify-send path are all working.

Notification session not available (HTTP 500)

Section titled “Notification session not available (HTTP 500)”

The response details reads The display's notification session is not available yet. Start the display and retry.

Cause: The kit’s automatic display-ensure could not obtain a working D-Bus session for the target display. The display, or the display’s notification session, did not come up in time, so notify-send had no session to dispatch through. Solution: Spawn the display explicitly via the SDK and retry, as in Manual display startup. Once the terminal session is created with wait_until_display: true, subsequent triggers should not need another manual display start. Failures can still occur if D-Bus or notify-send fails before the retry deadline. Those return the same 500 with details set to Sending the notification timed out. Retry later. or The notification service failed to send the notification.; retry after a short wait.

Missing notifications on the WebSocket stream

Section titled “Missing notifications on the WebSocket stream”

Cause: The subscriber is filtering for a different display (per-display routing), or the trigger went to a display that is down. Solution: Confirm the displays= query parameter on the WebSocket matches the display field in the trigger. To receive everything for debugging, subscribe with displays=all.

Cause: The notify counter allows 10 requests per sixty-second window, and the general counter 60 across all routes. A loop that sends one notification per item spends the allowance quickly, and several callers inside the private network share one bucket between them. See Rate limits. Solution: Collapse the batch into one notification with the detail in body, or space the calls so no window carries more than ten. The window is fixed, so waiting out the remainder of the current one clears it; the Retry-After header on the 429 says how many seconds remain.

Cause: Network instability or idle timeout. Solution: Implement reconnection with exponential backoff and handle connection errors gracefully. On WebSocket the browser handles protocol-level Ping/Pong automatically, and the server also sends a JSON heartbeat message every WS_HEARTBEAT_INTERVAL (surfaced by the SDK via onHeartbeat) that you can monitor for liveness. Close code 1001 means the server saw no reply to two heartbeats in a row; 1008 means the client exceeded the message rate limit. After any reconnect, or a resync message, re-fetch history to catch up.