Client posts its session telemetry (owner or admin).
const url = 'http://localhost:8080/v1/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/stats';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"client":"browser","samples":[{"ts_unix_ms":1,"metrics":{"fps":1,"bitrate_kbps":1,"rtt_ms":1,"jitter_buffer_ms":1,"decode_ms":1,"packets_lost":1,"frames_dropped":1,"present_fps":1,"present_fps_median":1,"present_interval_median_ms":1,"present_interval_sd_ms":1,"present_interval_p95_ms":1,"present_interval_max_ms":1,"present_beat_fraction":1,"present_long_frames":1,"present_n":1,"playout_target_ms":1,"rvfc_capture_time_available":1,"abs_capture_time_negotiated":1,"freeze_count":1,"display_refresh_hz":1,"glass_to_glass_ms":1,"rvfc_capture_to_display_ms":1,"encode_ms":1,"network_pacing_ms":1,"decode_display_ms":1,"is_hidden":1,"input_backpressure":1,"input_channel_buffered_bytes":1,"input_coalesced_per_sec":1,"input_gamepad_count":1,"input_gamepad_send_per_sec":1,"input_mm_per_sec":1,"input_msg_per_sec":1,"input_trace":1},"client_health":"smooth","client_health_reason":"example","device_key":"example","codec_mime_type":"example"}]}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use serde_json::json;use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/stats";
let payload = json!({ "client": "browser", "samples": ( json!({ "ts_unix_ms": 1, "metrics": json!({ "fps": 1, "bitrate_kbps": 1, "rtt_ms": 1, "jitter_buffer_ms": 1, "decode_ms": 1, "packets_lost": 1, "frames_dropped": 1, "present_fps": 1, "present_fps_median": 1, "present_interval_median_ms": 1, "present_interval_sd_ms": 1, "present_interval_p95_ms": 1, "present_interval_max_ms": 1, "present_beat_fraction": 1, "present_long_frames": 1, "present_n": 1, "playout_target_ms": 1, "rvfc_capture_time_available": 1, "abs_capture_time_negotiated": 1, "freeze_count": 1, "display_refresh_hz": 1, "glass_to_glass_ms": 1, "rvfc_capture_to_display_ms": 1, "encode_ms": 1, "network_pacing_ms": 1, "decode_display_ms": 1, "is_hidden": 1, "input_backpressure": 1, "input_channel_buffered_bytes": 1, "input_coalesced_per_sec": 1, "input_gamepad_count": 1, "input_gamepad_send_per_sec": 1, "input_mm_per_sec": 1, "input_msg_per_sec": 1, "input_trace": 1 }), "client_health": "smooth", "client_health_reason": "example", "device_key": "example", "codec_mime_type": "example" }) ) });
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
let client = reqwest::Client::new(); let response = client.post(url) .headers(headers) .json(&payload) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request POST \ --url http://localhost:8080/v1/sessions/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/stats \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "client": "browser", "samples": [ { "ts_unix_ms": 1, "metrics": { "fps": 1, "bitrate_kbps": 1, "rtt_ms": 1, "jitter_buffer_ms": 1, "decode_ms": 1, "packets_lost": 1, "frames_dropped": 1, "present_fps": 1, "present_fps_median": 1, "present_interval_median_ms": 1, "present_interval_sd_ms": 1, "present_interval_p95_ms": 1, "present_interval_max_ms": 1, "present_beat_fraction": 1, "present_long_frames": 1, "present_n": 1, "playout_target_ms": 1, "rvfc_capture_time_available": 1, "abs_capture_time_negotiated": 1, "freeze_count": 1, "display_refresh_hz": 1, "glass_to_glass_ms": 1, "rvfc_capture_to_display_ms": 1, "encode_ms": 1, "network_pacing_ms": 1, "decode_display_ms": 1, "is_hidden": 1, "input_backpressure": 1, "input_channel_buffered_bytes": 1, "input_coalesced_per_sec": 1, "input_gamepad_count": 1, "input_gamepad_send_per_sec": 1, "input_mm_per_sec": 1, "input_msg_per_sec": 1, "input_trace": 1 }, "client_health": "smooth", "client_health_reason": "example", "device_key": "example", "codec_mime_type": "example" } ] }'Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Request Bodyrequired
Section titled “Request Bodyrequired”POST /v1/sessions/{id}/stats body (metrics_handler.go). metrics is filtered to the BrowserMetrics dictionary at ingest.
object
Optional reporter discriminator (P9-07). Absent ⇒ browser. Any other value is rejected, not coerced.
object
Metrics from the browser WebRTC client.
object
Presentation fps derived from the MEAN RVFC interval. DEPRECATED as a headline: at source fps == display Hz a single missed vsync doubles one interval and drags the mean (a healthy 1440p120 session read 88-108 on 2026-08-22). Read present_fps_median. Retained unchanged for series continuity.
Presentation fps from the MEDIAN RVFC interval — the fps a viewer perceives, unmoved by an occasional doubled frame. The number to read.
Median frame-to-frame presentation interval (ms).
Longest presentation interval in the window (ms) — the real stall length, and the source of client.freeze_detected’s gap_ms.
Share (0..1) of the window’s intervals within +/-20% of exactly 2x the median. At source fps == display Hz this is the inherent vsync beat, not stutter; read it beside present_long_frames.
Intervals longer than 2.5x the median — above the doubled band, so genuine stalls rather than the beat. Zero is what makes a beat benign.
Intervals the window actually contained. Below 5 every other present_* key is omitted rather than computed from a fragment.
RVFC captureTime has a current validated sample (0/1); stale/missing/invalid captureTime resets it. Not proof of abs-capture-time RTP negotiation.
Strict abs-capture-time RTP-extension negotiation evidence (0 until SDP/RTP wire proof exists).
CUMULATIVE getStats freezeCount. Its advances drive the client.freeze_detected event; the freeze LENGTH is present_interval_max_ms, never 1000/freezeCount. Stored and taxonomised since P1; declared here 2026-08-23.
The client monitor’s refresh rate, measured from rAF vsync spacing with a MEDIAN (AS10-14). Not RVFC-derived. Stored and taxonomised since AS10-14; declared here 2026-08-23.
DEPRECATED in favour of rvfc_capture_to_display_ms, which names what is actually measured. Unchanged in value and still posted: RTP capture-time to browser present, MEDIAN over a never-drained ring of up to 600 RVFC samples — minutes of history, not this second, and not true glass-to-glass (it excludes app render and client scan-out).
RTP capture-time to browser present (ms), median over a rolling ring of up to 600 RVFC samples. Identical in value to glass_to_glass_ms, which it replaces; both are posted this release so no stored series breaks. Additive, approved by Michael 2026-08-23.
Accepted by the browser ingest allow-list and declared here, but no client has ever posted it — the browser cannot observe host encode time. Kept declared so the ingest contract does not change.
AS10-11: tab visibility at sample time (0/1).
AS10-13 input-pipeline health: the DataChannel hit its backpressure threshold. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: DataChannel bufferedAmount — the backpressure reading. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: pointer samples coalesced away per second (a subset of input_msg_per_sec). POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: connected gamepads. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: gamepad state messages sent per second. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: relative mouse-motion messages sent per second. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: input DataChannel messages sent per second. POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-13 input-pipeline health: the input tracer is on (seq + tc stamped). POSTED EVERY SECOND AND SILENTLY DROPPED — it is not in FilterBrowserMetrics’s allow-list, so it is visible live in the diagnostics panel and exists in no stored sample.
AS10-11: browser-classified client health (sibling of the numeric metrics dict).
UI-P6 (additive): the getStats() codec mimeType the receiver reports it is actually decoding, e.g. “video/H264”. A sibling STRING field, not a metrics dictionary key (the dictionary is numeric). Normalised server-side to the wire codec vocabulary and recorded on the session as negotiated_codec; an unparseable value is dropped and the POST still returns 202.
Responses
Section titled “Responses”Accepted.
Missing/invalid/expired/revoked token.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Authenticated but insufficient role / not the owner (precedes resource lookup).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Too many requests.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}