Channels
Three endpoints. One is the read-only view behind the App Admin panel's Channels tab: an App declares channels and broadcasts to them from its own handlers (see the App Channels guide), and this endpoint reports what it declared, what the server allows, and who is connected right now. The other two are not called over HTTP at all: they are the requests an App's page makes over its channel socket when it calls channel.send() or catches up after a reconnect, listed here so the codes they answer with can be looked up.
GET /api/apps/{id}/channels
The App's live-channel surface: the server's mode and limits, the manifest relay block, the deployed channels/ handlers, and the counters behind them.
Authentication: Required
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Key Fields:
| Field | Description |
|---|---|
enabled | false when an administrator switched the feature off (app.channels.enabled). Nothing can subscribe, and the counters below are then unreadable (countersAvailable: false), not idle |
originMode | false when the server serves Apps from the Informer origin, where nothing can subscribe |
limits | The server's app.channels caps: maxSubscribersPerApp, maxChannelsPerSocket, maxFrameBytes, broadcastRate (perSecond, burst), inboundRate (perSecond, burst; the per-user cap on send() and on the replay reads a reconnect makes), replay (frames, ttlMs; how many frames each channel keeps for reconnecting pages, and for how long), joinTimeoutMs |
relay | The channels: block of informer.yaml, sorted by name: name, description, on (the events an emit() also broadcasts; always present, since an entry without it fails the deploy) |
handlers | The deployed channels/ files: channel (a hapi-style pattern such as /orders/:region), handlerPath, exports (the lifecycle exports plus every event name the file accepts from send()), roles, and the timeout the handler actually gets |
countersAvailable | false when the counters could not be read. The three blocks below are then zeros rather than measurements, and must not be presented as one |
live | subscribers cluster-wide, and byChannel per exact channel name |
last60m | broadcasts, deliveries, and rateLimited over the last 60 one-minute buckets |
last30d | joins, refusals, leaves, and sends (inbound messages handled) from the channel invocation rows |
A channel an App broadcasts to without declaring it is real and counted: it appears in live.byChannel with no matching relay or handlers entry.
Permissions Required: app:write (Designer for Magic Reports, Admin for
full Apps); 404 if the user cannot read the app at all
HAL Relation: inf:app-channels. Its absence means the server predates
live channels, and clients should hide the surface rather than call the path.
A response without limits.inboundRate, limits.replay, or last30d.sends
comes from a server that predates inbound messages and replay; clients leave
those rows and tiles out rather than showing zeros.
Socket-only routes
The two routes below exist for the page-side channel client, which invokes them as requests over the App's channel WebSocket (the same socket that carries its subscriptions). Both are refused over plain HTTP with 403 app_channel_socket_required: there is no socket, so there is nothing to send on and no subscription to read back. There is no API key, bearer, or session that makes them work over HTTP, and an App's server-side code has no reason to call them; the page's channel.send() and its reconnect logic are the only callers.
Both are authorized by the socket's subscription, not by a permission. A request names a channel, and the socket must currently hold that channel (it was admitted by the channel's join handler and config.roles, or the channel is open). A channel the socket does not hold answers 403 app_channel_not_subscribed, whatever else is true of the user. The socket credential also pins the App: a path naming another App than the one the socket was minted for answers 403 app_channel_app_mismatch.
POST /api/apps/{id}/channels/_send
What channel.send(event, payload) performs. Runs the event-named export of the channels/ handler file covering the channel, as the socket's user, with the message as payload, and answers with the export's return value.
Request body:
| Field | Type | Description |
|---|---|---|
channel | string | A channel the socket holds. A wildcard (rooms/*) is never held as a concrete channel and is refused by the page client before the request is made |
event | string | The export to run: a valid event name (letters, digits, _, ., -, at most 64 characters). error and connected are reserved |
payload | any JSON | The message. Omitted becomes null. The body is capped at 128 KiB |
Response: { "result": <return value> }, with null when the export returned nothing.
Errors:
| Status | message | Meaning |
|---|---|---|
| 403 | app_channel_socket_required | Not invoked over an App channel socket |
| 403 | app_channel_app_mismatch | The socket was minted for a different App |
| 403 | app_channel_not_subscribed | The socket does not hold channel |
| 404 | app_channel_no_handler | The handler file has no export named event, event names an export with a fixed meaning (config, join, joined, leave), or the channel is open (no channels/ file covers it) |
| 400 | app_channel_reserved_event | event is error or connected |
| 400 | app_channel_invalid_event | event is not a valid event name |
| 429 | app_channel_rate_limited | The user is over limits.inboundRate for this App (a token bucket per user per App, shared by all of that user's tabs). Taken before anything else the route does, so a refused message spends a token like any other |
| 402 | (budget reason) | The App's compute budget for the period is spent |
| 504 | app_channel_send_timeout | The export ran past its cap (config.timeout, never above joinTimeoutMs) |
| 500 | app_channel_send_failed | The export threw |
Every run, successful or not, is recorded as a SEND invocation row under channel:<name>#<event> and counted in last30d.sends. A thrown export is also counted for the operator as send_failed, a rate-limited one as send_rate_limited, in the App's log stream under the channel source.
GET /api/apps/{id}/channels/_replay
What the page client reads after every re-subscribe (and on a first subscribe made with channel(name, { since })): the frames a channel still buffers past a sequence number, so a page that dropped and came back can deliver what it missed before any live frame.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
channel | string | A concrete channel the socket holds, or one beneath a wildcard it holds (a socket on rooms/* may read back rooms/east) |
since | integer, default 0 | The last seq the page delivered on that channel. 0 asks for everything still buffered |
Response:
| Field | Description |
|---|---|
frames | The buffered frames with seq greater than since, oldest first, each the full envelope a subscriber would have received (tenant, appId, channel, event, payload, seq, at). When the channel's counter has restarted below since, every buffered frame is returned instead |
oldest | The smallest seq still buffered, or null when nothing is |
current | The channel's current seq. 0 means the counter is not there: before the channel's first frame, or after it restarted (idle long enough to expire, Redis flushed, or a failover to a replica that never had it). Either way current below since is a restart, and the caller must not keep measuring against the old numbering |
The page reads the three together: current === since means nothing was missed; oldest <= since + 1 means every missed frame is in frames; otherwise (including oldest: null with current > since, an emptied buffer) frames were sent that the buffer no longer reaches back to, and the page reports replay_gap so the App can refetch that channel's state. The check errs toward reporting: a frame broadcast with replay: false takes a seq without being buffered, so a page away longer than the window while only such frames flowed is told replay_gap too. The buffer holds at most limits.replay.frames frames, for limits.replay.ttlMs milliseconds after its last write, per channel. Asking for a wildcard name itself answers { "frames": [], "oldest": null, "current": 0 }: a wildcard has no buffer of its own.
Errors:
| Status | message | Meaning |
|---|---|---|
| 403 | app_channel_socket_required | Not invoked over an App channel socket |
| 403 | app_channel_app_mismatch | The socket was minted for a different App |
| 403 | app_channel_not_subscribed | The socket holds neither channel nor a wildcard above it |
| 429 | app_channel_rate_limited | The user is over limits.inboundRate for this App: replay reads spend from the same per-user bucket as send(), taken before the App lookup. A reconnecting page reads once per concrete channel it holds, so a wildcard that heard from more channels than burst sees some reads refused; the page client reports each as rate_limited with err.channel and still replays the others. Counted for the operator as replay_rate_limited |
| 400 | (validation message) | channel is not a valid channel name, or since is not a non-negative integer |
Errors:
| Status | message | Meaning |
|---|---|---|
| 403 | app_channel_socket_required | Not invoked over an App channel socket |
| 403 | app_channel_app_mismatch | The socket was minted for a different App |
| 403 | app_channel_not_subscribed | The socket holds neither channel nor a wildcard above it |
| 400 | (validation) | channel is not a valid channel name, or since is not a non-negative integer |