Console WebSocket Surface
The authenticated multiplexed console WebSocket at /console/websocket, its stream catalog, inbound commands (subscribe, items.create, items.watch), outbound stream events, first-event-immediate burst coalescer, and per-connection rate, stream, and payload limits.
Overview
The console WebSocket is an authenticated, multiplexed real-time surface at
/console/websocket that backs the truestamp console terminal client and any
other client you write by hand. Over a single connection it lets you subscribe to
live streams of platform activity (block lifecycle, entropy observations, public
chain commitments, and your team’s items) and issue a small set of commands,
including submitting a new item for timestamping and watching its lifecycle. The
socket always requires credentials: it accepts either an API key or an OAuth 2.1
bearer access token, and rejects any connection that presents neither. It speaks
the Phoenix Channels V2 wire format (five-element JSON arrays), so it can be driven
from a command-line WebSocket tool as easily as from a purpose-built client.
Connecting and authentication
The surface is a WebSocket upgrade at /console/websocket. You must pin the
Phoenix Channels wire-protocol version in the query string as vsn=2.0.0; this
selects the array-form serializer the surface speaks and is not a Truestamp API
version.
Authentication is mandatory and can be supplied one of two ways:
-
API key as a query parameter on the upgrade URL, the same key format used by the JSON API:
wss://www.truestamp.com/console/websocket?vsn=2.0.0&api_key=truestamp_<token> -
OAuth 2.1 bearer access token. Browsers cannot set request headers on a WebSocket upgrade, so the token is normally supplied as an
access_tokenquery parameter, mirroring theapi_keymechanism. AnAuthorization: Bearer <token>header is also accepted as a fallback for callers that can set headers on the upgrade. Either way, an OAuth connection must carry theconsole:readscope to open the socket at all; theconsole:writescope is checked separately, only when you issue a write command. OAuth is honored only while the server’s OAuth 2.1 authorization server is enabled; when it is off, only API-key connections open.
If neither a valid API key nor a valid token is presented, the upgrade is refused
and the connection closes. On success the server binds your identity and current
team to the connection, and every command and subscription runs authorized against
that context. API-key connections stay open until you close them; OAuth connections
are time-boxed to the access token’s expiry, at which point the server pushes a
token_expired event and closes the channel so the client can reconnect with a
fresh token.
Wire format and the lobby topic
The surface uses Phoenix Channels V2 framing: each message is a five-element JSON
array of the form [join_ref, ref, topic, event, payload]. The ref is a
correlation id you choose; the server echoes it on the matching reply so you can
pair a phx_reply back to the request that produced it. Server-initiated pushes
carry null for both refs.
All commands and stream events flow over one topic, console:lobby, which you join
by sending a phx_join event. The join reply is a welcome envelope containing your
resolved scope (user id, current team id, plan), the stream catalog available to
you, and the server version. A separate console:clock topic on the same socket
emits a one-per-second server time tick and is optional.
Malformed inbound frames (for example multi-line JSON split across several frames, or a stray blank line) are dropped cleanly rather than tearing down the connection, so hand-rolled debugging with a line-oriented tool is forgiving.
Keeping the connection alive
To hold the socket open through network middlebox idle timeouts, clients should
periodically send a no-op heartbeat frame, roughly every 30 seconds, on the reserved
phoenix topic:
[null, "<ref>", "phoenix", "heartbeat", {}]
The server acknowledges each heartbeat with {"status": "ok", "response": {}}.
Purpose-built clients send this frame automatically, but a hand-rolled client must
send it itself or the connection will silently drop after an idle period.
Stream catalog
Clients subscribe by stream id, never by an internal topic. The catalog is the stable contract. The streams available to every authenticated connection are:
| Stream id | Scope | What it carries |
|---|---|---|
blocks |
global | Internal ledger block lifecycle (creation and later transitions). |
blocks.healing |
global | Block healing transitions (forward and reverse). |
beacons |
global | Public beacon projection of newly created blocks (id, hash, previous_hash, timestamp). |
commitments.internal |
global | Item Merkle inclusion commitments (each item bound into a block). |
commitments.external |
global | Block commitments recorded on public chains (Stellar, Bitcoin). |
entropy.nist |
global | NIST beacon entropy observations. |
entropy.stellar |
global | Stellar entropy observations. |
entropy.bitcoin |
global | Bitcoin entropy observations. |
items.team |
team | Item lifecycle events for your current team. |
items.<item_id> |
per-item | Lifecycle events for a single item you explicitly watch. |
The items.team stream is only offered when your connection has a current team.
The per-item items.<item_id> streams are dynamic: they appear when you watch an
item and are removed when you unwatch it. Any stream id not in the catalog is
rejected even for an authenticated caller.
Inbound commands
Every command is an event on console:lobby. The server answers with a phx_reply
carrying {"status": "ok" | "error", "response": {...}}.
| Command | Payload | On success |
|---|---|---|
subscribe |
{"streams": [stream_id, ...]} |
{subscribed, rejected, active} |
unsubscribe |
{"streams": [stream_id, ...]} |
{unsubscribed, active} |
subscriptions |
{} |
{active} (the current active set) |
ping |
{} |
{pong: <unix_ms>} |
items.create |
{"name", "description", "hash"?, "hash_type"?, "watch"?} |
{item: <projection>, watched} |
items.watch |
{"id": "<item_id>"} |
{watching: "<item_id>"} |
items.unwatch |
{"id": "<item_id>"} |
{unwatching: "<item_id>"} |
scope.switch_team |
{"team_id": "<team_id>"} |
{scope, team, role, streams} |
token.refresh |
{"access_token": "<token>"} |
{exp: <unix>} (OAuth connections only) |
A subscribe call reports which requested streams were accepted, which were
rejected (unknown, not permitted, or over the per-connection stream cap), and the
full active set after the call.
scope.switch_team re-binds the connection to another team you belong to without
dropping the socket: catalog subscriptions are re-pointed at the new team while
per-item watches keep their original binding, so you can watch an item that lives in
a different team. token.refresh is an in-band keep-alive for OAuth connections: it
adopts a freshly minted access token and reschedules the expiry so a long-lived
session does not have to reconnect just because a token rolled over. It is a no-op on
API-key connections, which never expire mid-session.
Submitting an item with items.create
items.create submits a new item for timestamping over
the same connection. It has two modes, distinguished by whether you supply a hash:
-
External-hash mode: supply both
hashandhash_type(for example a SHA-256 hex digest of a file you keep yourself). Only the digest and your descriptive claims are submitted; the file itself never leaves your machine.[null, "3", "console:lobby", "items.create", { "name": "Quarterly report", "description": "Q3 financials", "hash": "ab9d...", "hash_type": "sha256", "watch": true }] -
Claims-as-source-of-truth mode: omit both
hashandhash_type. The claims content itself (name, description, and any metadata) becomes the timestamped data. In this mode the description must be at least 32 characters, or the claims must include non-empty metadata, so there is real content to timestamp; the total claims payload is still bounded by the team owner’s plan size budget for claims-only submissions.[null, "3", "console:lobby", "items.create", { "name": "Invention disclosure", "description": "On this day I claim the following invention as my own work.", "watch": true }]
hash and hash_type are co-required: supply both or neither. When watch: true,
the connection also subscribes to the new item’s lifecycle so its state transitions
arrive as stream pushes on items.<item_id>. The reply’s watched field reports
whether that auto-watch actually took effect; the item is created either way. The
resulting item proves the submission window for its data, not a creation time. On a
validation or authorization failure the reply is
{"status": "error", "response": {"code": ..., "message": ...}}.
Because writing is a privileged action, items.create on an OAuth connection
requires the console:write scope; without it the reply is
{"code": "insufficient_scope"}. API-key connections are not scope-limited.
Watching an item with items.watch
items.watch adds a per-item subscription after confirming your credentials can
read the item; if the item does not exist or you cannot read it, the reply is
{"code": "forbidden"} (the two cases are deliberately indistinguishable). Once
watching, the item’s lifecycle arrives as stream pushes on stream id
items.<item_id>. items.unwatch removes it. If the connection is already at its
per-connection stream cap, the watch is refused with {"code": "cap_exceeded"}.
Outbound stream events
Every server-initiated resource event on console:lobby is a stream event with a
uniform envelope, so a client can route on kind without inspecting the shape of
data:
[null, null, "console:lobby", "stream", {
"stream": "blocks",
"kind": "block.created",
"at": "2026-04-27T18:32:11.412Z",
"data": { "id": "019d...", "state": "finalized", "block_hash": "61b8...", "merkle_root": "657b..." }
}]
The kind always follows <resource>.<verb>, for example block.created,
beacon.created, commitment.created, external_commitment.created,
entropy.created, and item.created / item.updated / item.deleted. The data
map is a small, stable projection of the underlying resource rather than the raw
record, so its shape does not shift when internal fields change. Hash and key-id
fields are lowercase hex strings.
Burst coalescing
Every stream passes through one uniform coalescer so a fast fan-out cannot flood a client. The first event in a short window is pushed immediately, so slow streams pay no latency penalty. Further events that arrive during that window accumulate. When the window closes:
- If nothing else accumulated, nothing more is pushed.
- If exactly one further event accumulated, it is promoted to an ordinary individual push (never a burst-of-one).
- If two or more accumulated, a single
<resource>.burstsummary is pushed instead.
A burst summary carries the count, the window length, first and last timestamps, per-kind and per-state histograms, and a projection of the latest event:
[null, null, "console:lobby", "stream", {
"stream": "items.team",
"kind": "item.burst",
"at": "2026-04-27T18:32:11.500Z",
"data": {
"count": 437,
"window_ms": 500,
"first_at": "2026-04-27T18:32:11.001Z",
"last_at": "2026-04-27T18:32:11.498Z",
"by_kind": { "item.created": 250, "item.updated": 150, "item.deleted": 37 },
"by_state": { "created": 200, "processing": 150, "committed": 87 },
"latest": { "id": "01KQ...", "state": "committed" }
}
}]
The effect is adaptive: quiet streams like blocks, entropy.*, and
commitments.external never produce a burst because their events rarely arrive fast
enough to accumulate, while high-volume streams like items.team during a block
close self-throttle to at most a couple of pushes per second regardless of input
rate.
Per-connection limits
The surface applies several defense-in-depth caps that should never fire under normal interactive use:
- Stream cap: a single connection can hold up to 32 active streams (catalog
streams plus per-item watches). Requests past the cap are reported in
rejected(forsubscribe) or ascap_exceeded(for a watch). - Per-call stream cap: a single
subscribeorunsubscribemay name at most 64 stream ids; a larger array is rejected asinvalid. - Command rate limit: inbound commands are rate limited per connection over a
short window. Exceeding it replies
{"code": "rate_limited"}. - Outbound rate limit: outbound stream pushes are capped per second; excess pushes are dropped server-side rather than delivered.
- Payload size guard: an oversized projected
datapayload is replaced with a truncation marker carrying just the id and the original byte size, so a single large record cannot breach the frame budget. - Frame size and idle memory: the socket caps inbound frame size and hibernates idle connections to shrink their memory footprint.
Treat the exact numbers as tuning parameters that can change; the behaviors above are the contract.
Limitations
The console WebSocket does not verify proofs or expose bulk history; it is a real-time command and subscription surface. It carries live events from the moment you subscribe and does not replay past events, so a client that reconnects must re-issue its subscriptions and treat any gap as an outage to backfill through other means. It exposes only the item write actions listed here (create, watch, unwatch, team switch); richer read and management operations belong to the JSON API and GraphQL surfaces.