Fetching notifications
Section titled “Fetching notifications”The notification server stores the desktop notifications sent on each display and exposes a read and write API over HTTP and WebSocket. Use these endpoints to read the history of one or more displays, subscribe to new notifications as they arrive, dismiss notifications so they no longer appear in the read API, and clear the dismissed state. All requests run on the notification server’s per-container host and authenticate with a Hoody token.
Get notifications
Section titled “Get notifications”GET /api/v1/notifications/{display}
Section titled “GET /api/v1/notifications/{display}”Retrieves notifications for one display, a comma-separated list of displays, or all.
Without a paging parameter the response holds the newest limit notifications, newest first. With since, after_id, or cursor the response holds the oldest limit matching notifications after that point, oldest first, so a client can catch up page by page: pass data.next_cursor back as cursor while data.has_more is true, and the pages join into one ascending list with every matching notification exactly once. To read the whole history, start with since=0.
Dismissed notifications are left out: a row is hidden when its id was dismissed globally or for its own display. Rows without an id are left out and do not count toward limit.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
display | path | string | Yes | A display ID (1 or :1, up to 5 digits), a comma-separated list (1,:2,3), or all. Any invalid element rejects the whole request with 400. |
limit | query | integer | No | Maximum number of notifications to return. Default: 100. |
since | query | integer | No | Forward start point, Unix milliseconds, inclusive: returns the oldest limit notifications with timestamp >= since, oldest first. Use it to start from a known time (0 for the whole history). Continue with cursor set to data.next_cursor. |
after_id | query | integer | No | Forward cursor on notification id, exclusive: returns the oldest limit notifications with id > after_id, chosen and listed by id. Ids are numbered per display, so the filter is only meaningful for a single display. |
cursor | query | string | No | Keyset cursor, exclusive: pass back data.next_cursor from an earlier response to get the oldest limit notifications after it, oldest first. The cursor keeps the order of the request that produced it. Opaque; cannot be combined with since or after_id. |
username | query | string | No | Read only this user’s history files (letters and digits only). Both username and session narrow the read to that user’s and session’s files. |
session | query | string | No | Read only this session’s history files (letters and digits only). Both username and session narrow the read to that user’s and session’s files. |
Request example
Section titled “Request example”curl -G "https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com/api/v1/notifications/all" \ --data-urlencode "limit=50" \ -H "Authorization: Bearer <token>"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
for await (const notification of client.notifications.listIterator("all", { limit: 50, since: 0 })) { console.log(notification);}Response
Section titled “Response”{ "success": true, "data": { "count": 1, "displays": ["1"], "notifications": [ { "id": 10, "display_id": 1, "appname": "Google Chrome", "summary": "Focus or Open a Window", "body": "Click to focus the window", "message": "Focus or Open a Window: Click to focus the window", "icon_url": "/api/v1/notifications/icons/6_10_1749024932903.png", "has_icon": true, "timestamp": 1749024932903, "timeout": 5000, "progress": 0, "category": "system", "urgency": "NORMAL", "default_action_key": "default", "actions": [ { "key": "default", "name": "default", "text": "Open" } ] } ], "next_cursor": "MTc0OTAyNTAyMjk1NS4xNS4x", "has_more": false }}{ "success": false, "error": "Validation Error", "details": "Invalid display ID: 1a"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | Request parameters failed validation. | Check parameter format and try again. |
INVALID_DISPLAY_ID | Invalid display ID | Display ID format is incorrect. | Use format: "1", ":1", or a comma-separated list like "1,:2,3". |
INVALID_CURSOR | Invalid cursor | The cursor is not a next_cursor value from this endpoint, or it is combined with since or after_id. | Send next_cursor unchanged, without since or after_id. |
{ "success": false, "error": "Rate Limit Exceeded", "message": "You have exceeded the 60 requests in 1 minutes limit on this endpoint."}The response includes a Retry-After header with the whole number of seconds until the current window ends (rounded up, at least 1).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | Too many requests from your IP address. | Wait the number of seconds in the Retry-After header, then retry. Default limits per client IP per minute: 60 for every route, 10 for notify, 100 for icons. |
{ "success": false, "error": "Internal Server Error", "details": "An unexpected error occurred"}Stream notifications in real time
Section titled “Stream notifications in real time”GET /api/v1/notifications/stream
Section titled “GET /api/v1/notifications/stream”Streams new notifications as they are written. The same endpoint serves two transports based on the request:
- WebSocket: send a WebSocket upgrade request. A disallowed
Origin(seeWS_ALLOWED_ORIGINS) is refused with403before the upgrade. Otherwise the server answers101 Switching Protocols; problems found after that arrive as JSONerrorframes on the socket, never as HTTP status codes. The per-IP connection limit (WS_MAX_CONNECTIONS_PER_IP) sends anerrorframe and closes without a status code. Close codes include1000(server shutting down),1001(heartbeat timeout after two missed pings), and1008(message rate limit,WS_MAX_MESSAGES_PER_MINUTE). A send that stalls for more than 10 seconds drops the connection without a close frame. Clients should reconnect with backoff after any close they did not start. - Server-Sent Events: any other GET returns
200 text/event-stream. The first event isconnected, followed by periodicheartbeatmessages. Theretryfield asks EventSource to reconnect after 3 seconds. The stream ends when the server shuts down.
Both transports send a heartbeat message (WebSocket every WS_HEARTBEAT_INTERVAL ms, SSE every 15 seconds) and a resync message when the client fell behind and notifications were skipped. Re-fetch those with GET /{display}?since=<last seen timestamp>.
displays is required for SSE. For WebSocket it is optional: without it the socket receives nothing until the client sends a subscribe message.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
displays | query | string | No | Comma-separated display IDs (1,:2,3), or all / * for every display. Required for SSE (400 without it). Optional for WebSocket: without it the socket receives nothing until the client sends a subscribe message; an invalid ID arrives as an error frame after the upgrade. |
Request example
Section titled “Request example”curl -N "https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com/api/v1/notifications/stream?displays=all" \ -H "Accept: text/event-stream" \ -H "Authorization: Bearer <token>"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
const stream = await client.notifications.connect({ displays: "all" });stream.onNotification((msg) => console.log("new:", msg.data));stream.onHeartbeat(() => {});stream.onDisconnect((code, reason) => {});stream.onError((err) => console.error(err));await stream.connect();// later: stream.close();Response
Section titled “Response”The 101 Switching Protocols response carries no body. The WebSocket frames exchanged on the socket are described in the endpoint description above.
The body is an SSE stream. Each data: line holds one JSON message; the first message is connected, followed by periodic heartbeat messages.
retry: 3000data: {"type":"connected","displays":["1"]}
data: {"type":"heartbeat","timestamp":1749025022950}{ "success": false, "error": "Validation Error", "details": "Invalid display ID format"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | Request parameters failed validation. | Check parameter format and try again. |
INVALID_DISPLAY_ID | Invalid display ID | Display ID format is incorrect. | Use format: "1", ":1", or a comma-separated list like "1,:2,3". |
{ "success": false, "error": "Forbidden", "message": "Origin not allowed"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ORIGIN_NOT_ALLOWED | WebSocket origin not allowed | The upgrade request’s Origin header is not in the server’s WS_ALLOWED_ORIGINS list. | Connect from one of the server’s configured allowed origins (WS_ALLOWED_ORIGINS). |
{ "success": false, "error": "Rate Limit Exceeded", "message": "You have exceeded the 60 requests in 1 minutes limit on this endpoint."}The response includes a Retry-After header with the whole number of seconds until the current window ends (rounded up, at least 1).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | Too many requests from your IP address. | Wait the number of seconds in the Retry-After header, then retry. Default limits per client IP per minute: 60 for every route, 10 for notify, 100 for icons. |
{ "success": false, "error": "Service Unavailable", "message": "Server is shutting down"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
SERVER_SHUTTING_DOWN | Server shutting down | The server is stopping (for example during a deploy) and refuses new stream connections. | Reconnect with backoff; the restarted server accepts the connection. |
Dismiss notifications
Section titled “Dismiss notifications”POST /api/v1/notifications/dismiss
Section titled “POST /api/v1/notifications/dismiss”Marks notification IDs as dismissed. Dismissed notifications are left out of later GET responses (and of every query: /all, lists, and single displays).
Without displayId (or with null) the IDs are dismissed globally, on every display. With displayId they are dismissed only on that display, which matters because notification IDs are numbered per display.
Request body
Section titled “Request body”| Name | Type | Required | Description |
|---|---|---|---|
notificationIds | array of integers | Yes | Notification IDs to dismiss. Must be a non-empty array with at least one integer; non-integer entries are ignored. |
displayId | string or integer | No | Display to scope the dismissal to: "1", ":1", or 1 (0-99999; surrounding whitespace and extra leading colons are ignored). Omit or send null to dismiss globally. Any other value or type is rejected with 400; it never widens the dismissal to every display. |
Request example
Section titled “Request example”curl -X POST "https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com/api/v1/notifications/dismiss" \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"notificationIds":[10,11,12],"displayId":"1"}'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.notifications.dismiss({ notificationIds: [10, 11, 12], displayId: "1" });Response
Section titled “Response”{ "success": true, "message": "3 notification(s) dismissed"}{ "success": false, "error": "Validation Error", "details": "displayId must be a display number (e.g. 1 or \":1\"), 0-99999"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid notification IDs | notificationIds is missing, empty, or holds no integer. | Send the integer ids from GET /{display}. |
INVALID_DISPLAY_ID | Invalid display ID | displayId is present but is not a display number 0-99999 (string or integer). | Send "1", ":1", or 1, or leave displayId out for a global dismissal. |
{ "success": false, "error": "Rate Limit Exceeded", "message": "You have exceeded the 60 requests in 1 minutes limit on this endpoint."}The response includes a Retry-After header with the whole number of seconds until the current window ends (rounded up, at least 1).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | Too many requests from your IP address. | Wait the number of seconds in the Retry-After header, then retry. Default limits per client IP per minute: 60 for every route, 10 for notify, 100 for icons. |
{ "success": false, "error": "Internal Server Error", "details": "An unexpected error occurred"}Clear dismissed state
Section titled “Clear dismissed state”DELETE /api/v1/notifications/dismiss
Section titled “DELETE /api/v1/notifications/dismiss”Clears dismissed state so dismissed notifications show again. Without displayId it clears every scope: the global list and every display’s list. With displayId it clears only that display’s list; global dismissals stay.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
displayId | query | string | No | Clear only this display’s dismissals (1 or :1, 0-99999; surrounding whitespace and extra leading colons are ignored). Omit to clear everything. An invalid value is rejected with 400. |
Request example
Section titled “Request example”curl -X DELETE "https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com/api/v1/notifications/dismiss?displayId=1" \ -H "Authorization: Bearer <token>"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-n-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.notifications.restore({ displayId: "1" });Response
Section titled “Response”{ "success": true, "message": "Dismissed notifications cleared"}{ "success": false, "error": "Validation Error", "details": "displayId must be a display number (e.g. 1 or \":1\"), 0-99999"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_DISPLAY_ID | Invalid display ID | displayId is present but is not a display number 0-99999 (string or integer). | Send "1", ":1", or 1, or leave displayId out for a global dismissal. |
{ "success": false, "error": "Rate Limit Exceeded", "message": "You have exceeded the 60 requests in 1 minutes limit on this endpoint."}The response includes a Retry-After header with the whole number of seconds until the current window ends (rounded up, at least 1).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | Too many requests from your IP address. | Wait the number of seconds in the Retry-After header, then retry. Default limits per client IP per minute: 60 for every route, 10 for notify, 100 for icons. |
{ "success": false, "error": "Internal Server Error", "details": "An unexpected error occurred"}