> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Monitor in-call video quality

> Run the full video quality loop — pre-join preflight checks, live per-participant connection-quality monitoring during the call, and a post-session QoE report with p50/p95/p99 latency, jitter, and packet-loss.

# Monitor in-call video quality

Video quality on Orbit is a loop with three stages, each with its own
endpoint surface:

1. **Before the call** — the preflight check verifies the joiner's devices
   and network path before they ever enter the room.
2. **During the call** — each participant's SDK reports its own connection
   stats, and you read a live per-room snapshot to see who is having a bad
   call right now.
3. **After the call** — the QoE report aggregates the session's samples into
   p50/p95/p99 latency, jitter, and packet-loss, per-participant rollups,
   and drop-offs, retained for 24 hours after the last sample.

Use the preflight as a gate in your join flow, the live snapshot as your
operations monitor, and the report as your after-call troubleshooting and
SLA evidence.

## Where each stage fits

| Stage        | Endpoint                                     | Scope                         | Answers                                     |
| ------------ | -------------------------------------------- | ----------------------------- | ------------------------------------------- |
| Pre-join     | `GET /video/preflight`                       | `video:read` or `video:write` | Can this device and network sustain a call? |
| In-call      | `POST /video/connection-quality/:name`       | `video:write`                 | (Each participant reports its own stats.)   |
| In-call      | `GET /video/connection-quality/:name`        | `video:read` or `video:write` | Who is having a bad call right now?         |
| Post-session | `GET /video/connection-quality/:name/report` | `video:read` or `video:write` | How good was the call, and who suffered?    |

All routes are under `https://api.orbit.devotel.io/api/v1`. Room names are
scoped to your tenant automatically — a caller can never read another
tenant's room snapshot.

## Step 1 — Gate the join with a preflight check

Fetch the preflight configuration and run it on the joiner's device before
you let them into the room:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/video/preflight" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "ice_servers": [
      { "urls": "stun:stun.orbit.devotel.io:3478" },
      { "urls": "turn:turn.orbit.devotel.io:3478?transport=udp", "username": "...", "credential": "..." },
      { "urls": "turns:turn.orbit.devotel.io:443?transport=tcp", "username": "...", "credential": "..." }
    ],
    "signaling_url": "wss://media.orbit.devotel.io",
    "credentials_expires_at": 1783843200000,
    "checks": {
      "webrtc": { "required": true },
      "camera": { "required": false, "kind": "videoinput" },
      "microphone": { "required": true, "kind": "audioinput" },
      "speakers": { "required": false, "kind": "audiooutput" },
      "network": {
        "required": true,
        "min_kbps_send": 150,
        "min_kbps_recv": 150,
        "max_rtt_ms": 400,
        "test_duration_seconds": 10
      }
    }
  }
}
```

The response hands the browser everything it needs to exercise the path:

* **ice\_servers** — STUN entries plus short-lived TURN and TURNS (TLS)
  credentials, so the check validates the exact relay path the real call
  will use, including restrictive-NAT cases where only TURN succeeds.
  Credentials expire after 60 minutes; fetch preflight close to join time
  rather than caching it.
* **signaling\_url** — the media signaling endpoint to probe.
* **checks** — the pass/fail thresholds for each check: a 150 kbps send and
  receive floor and a 400 ms RTT ceiling for a camera-on join, over a
  10-second network test.

Run the checks and present a deterministic "mic OK / camera OK / network OK"
result in your join sheet. A participant who fails the network check should
be offered audio-only join instead of discovering the problem mid-call. If
the endpoint returns 503 `SERVICE_UNAVAILABLE`, the video service is not
configured on the cluster — surface that as a configuration problem, not a
failed joiner.

## Step 2 — Report stats during the call

The media SFU does not expose per-participant transport stats — those live
only in each participant's browser. So the integration model is: **every
participant's SDK rolls up its own `RTCPeerConnection.getStats()` sample and
POSTs it** on a cadence (every 5–10 seconds is the usual interval).

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/video/connection-quality/weekly-sales-sync" \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "participant_identity": "alice@acme.com",
    "rtt_ms": 62,
    "jitter_ms": 8,
    "packet_loss_pct": 0.4,
    "quality_score": 4.3,
    "bitrate_kbps": 1850,
    "codec": "VP8",
    "resolution": "1280x720",
    "content_hint": "camera"
  }'
```

| Field                  | Type   | Notes                                                     |
| ---------------------- | ------ | --------------------------------------------------------- |
| `participant_identity` | string | The participant's identity in the room.                   |
| `rtt_ms`               | number | Round-trip time, clamped to 0–60000.                      |
| `jitter_ms`            | number | Jitter, clamped to 0–60000.                               |
| `packet_loss_pct`      | number | Packet loss percentage, clamped to 0–100.                 |
| `quality_score`        | number | Normalised 0–5 score (5 = excellent).                     |
| `bitrate_kbps`         | number | Optional. Current send bitrate.                           |
| `codec`                | string | Optional. Negotiated codec label, e.g. `VP8`.             |
| `resolution`           | string | Optional. Send resolution, e.g. `1280x720`.               |
| `content_hint`         | string | Optional. `camera` or `screen_share`; tunes the advisory. |

The response carries an `advisory` block: when a sample shows congestion,
the advisory names the concrete encoding the publisher should fall back to —
suggested bitrate, recommended resolution, framerate, and degradation
preference — so a constrained or mobile participant degrades
deterministically instead of freezing. Apply the advisory to the sender's
encoding parameters immediately; pass `content_hint: "screen_share"` when
the stats are for a screen-share track so the screen ladder (sharpness over
framerate) is picked instead of the camera ladder.

<Note>
  The reporting call needs a `video:write`-scoped credential — the same scope
  your SDK already holds to join a room. Reads (the snapshot and report below)
  accept either `video:read` or `video:write`.
</Note>

### Roll up getStats() in the browser

The browser-side shape is a short rollup over one `getStats()` pass. Pick
the outbound-rtp entry for the video sender and the candidate-pair entry for
the transport:

```js theme={null}
const stats = await peerConnection.getStats();
let rttMs = 0;
let jitterMs = 0;
let packetsLost = 0;
let packetsSent = 0;
let bitrateKbps = 0;

for (const report of stats.values()) {
  if (report.type === "candidate-pair" && report.state === "succeeded") {
    rttMs = Math.round(report.currentRoundTripTime * 1000);
  }
  if (report.type === "outbound-rtp" && report.kind === "video") {
    jitterMs = Math.round((report.jitter ?? 0) * 1000);
    bitrateKbps = Math.round((report.bitrateMean ?? 0) / 1000);
  }
  if (report.type === "remote-inbound-rtp" && report.kind === "video") {
    packetsLost = report.packetsLost ?? 0;
    jitterMs = Math.round((report.jitter ?? jitterMs / 1000) * 1000);
  }
  if (report.type === "outbound-rtp" && report.kind === "video") {
    packetsSent = report.packetsSent ?? 0;
  }
}

const packetLossPct =
  packetsSent + packetsLost > 0
    ? (packetsLost / (packetsSent + packetsLost)) * 100
    : 0;
```

Derive `quality_score` from your own thresholds (jitter, loss, and RTT
against the preflight bounds are a workable formula) and clamp it to the
0–5 range. The server clamps every metric to a sane range anyway, so a
buggy SDK build cannot poison the monitor with NaN or runaway values.

## Step 3 — Watch the live snapshot during a call

`GET /video/connection-quality/:name` is the "who is having a bad call
right now" view — one row per still-reporting participant:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/video/connection-quality/weekly-sales-sync" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "room_name": "weekly-sales-sync",
    "participants": [
      {
        "identity": "alice@acme.com",
        "rtt_ms": 62,
        "jitter_ms": 8,
        "packet_loss_pct": 0.4,
        "quality_score": 4.3,
        "quality_label": "excellent",
        "bitrate_kbps": 1850,
        "codec": "VP8",
        "resolution": "1280x720",
        "advisory": { "suggested_bitrate_kbps": 2000, "recommended_resolution": "1280x720" },
        "updated_at": "2026-09-10T09:14:22.000Z"
      },
      {
        "identity": "bob@acme.com",
        "rtt_ms": 412,
        "jitter_ms": 85,
        "packet_loss_pct": 7.9,
        "quality_score": 1.2,
        "quality_label": "poor",
        "bitrate_kbps": 240,
        "codec": "VP8",
        "resolution": "320x180",
        "advisory": { "suggested_bitrate_kbps": 150, "recommended_resolution": "320x180" },
        "updated_at": "2026-09-10T09:14:24.000Z"
      }
    ]
  }
}
```

The snapshot is a **recency signal**: a participant's row lives for 30
seconds after their last sample. A participant who stopped reporting — left,
crashed, or lost connectivity entirely — drops out of the snapshot within
30 seconds, so the view always reflects the live room. An empty
`participants` list means the room is idle or nobody has reported recently,
not that everyone is healthy.

`quality_label` buckets the score into `excellent` (4–5), `good` (2–3.99),
`poor` (above 0, below 2), and `lost` (0). Poll this endpoint from your
operations dashboard every 10–30 seconds while a supervised call is live.

For a single participant, `GET
/video/connection-quality/:name/participants/:identity/stats` returns that
one row — useful for an SLA probe against a specific caller. It returns 404
when the participant is not currently reporting (never joined, left, or
their sample aged out past the live TTL).

## Step 4 — Read the post-session QoE report

After the call, `GET /video/connection-quality/:name/report` aggregates the
session's retained samples into the after-call view:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/video/connection-quality/weekly-sales-sync/report" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "room_name": "weekly-sales-sync",
    "sample_count": 1180,
    "participant_count": 4,
    "first_sample_at": "2026-09-10T09:00:11.000Z",
    "last_sample_at": "2026-09-10T09:47:02.000Z",
    "duration_seconds": 2831,
    "rtt_ms": { "p50": 71, "p95": 210, "p99": 438 },
    "jitter_ms": { "p50": 9, "p95": 41, "p99": 88 },
    "packet_loss_pct": { "p50": 0.3, "p95": 4.8, "p99": 9.1 },
    "quality_score": { "p50": 4.1, "p95": 1.8, "p99": 0.9 },
    "drop_off_count": 1,
    "participants": [
      {
        "identity": "bob@acme.com",
        "sample_count": 210,
        "first_seen_at": "2026-09-10T09:02:41.000Z",
        "last_seen_at": "2026-09-10T09:31:15.000Z",
        "rtt_ms": { "p50": 180, "p95": 401, "p99": 512 },
        "jitter_ms": { "p50": 22, "p95": 80, "p99": 121 },
        "packet_loss_pct": { "p50": 1.1, "p95": 8.2, "p99": 12.4 },
        "quality_score": { "p50": 3.0, "p95": 1.1, "p99": 0.4 },
        "bitrate_kbps": { "p50": 900, "p95": 260, "p99": 120 },
        "codec": "VP8",
        "resolution": "640x360",
        "quality_score_min": 0.4,
        "worst_quality_label": "poor",
        "dropped_off": true
      }
    ]
  }
}
```

Read it this way:

* **Room-level percentiles** — `rtt_ms`, `jitter_ms`, `packet_loss_pct`, and
  `quality_score` each carry `p50` / `p95` / `p99` over every sample in the
  session. The p95 and p99 tails are where "the call felt bad for a few
  minutes" shows up after the p50 looks fine.
* **Per-participant rollups** — each participant gets the same percentile
  block plus a `quality_score_min`, a `worst_quality_label`, and a
  `dropped_off` flag. `dropped_off: true` means the participant stopped
  reporting well before the session's last sample — they left abruptly or
  lost connectivity; `drop_off_count` sums those across the room.
* **Retention** — samples are retained for 24 hours after the last sample
  of the session, then the report expires. Pull and archive any report you
  need as SLA evidence within that window. With no retained samples you get
  an empty-but-valid report (`sample_count: 0`), not an error.

## Step 5 — Wire the operations playbook

Quality telemetry earns its keep when it drives an action. The landed
patterns:

* **Subscribe to the quality-drop webhook.** When a participant's sample
  crosses into `poor` or `lost` from a healthier bucket, Orbit fires a
  `video.participant.qos` event to your tenant's webhook endpoints. It is
  edge-triggered — one event per transition, not one per heartbeat — so it
  is safe to page on. The payload carries the room, identity, both quality
  labels, the raw metrics, and the same encoding advisory the reporting SDK
  received.
* **Alert on the live snapshot.** Poll `GET /video/connection-quality/:name`
  during supervised sessions and raise an alert when any participant sits at
  `poor` or `lost` for two consecutive polls — a single transient dip is
  normal; a sustained one is a supervisor page.
* **Act, don't just watch.** The `advisory` block on both the report
  response and the snapshot row names the fallback encoding. For a managed
  experience (telehealth, support), apply it to the participant's sender
  automatically; for a meeting, surface "Bob's connection is unstable — try
  audio-only" to the host.
* **Correlate against cost and usage.** A chronically degraded room is also
  a billable-usage question — pair the QoE report with the room-usage
  analytics in [Read the cost-intelligence dashboards](/guides/cost-intelligence)
  when a tenant disputes video spend on quality grounds.
* **Never block the call on monitoring.** The whole surface is best-effort:
  when the monitoring store is unavailable, POST and GET return 503
  `SERVICE_UNAVAILABLE` — never 500, and never a reason to fail a join or
  hang up a call. Treat a 503 from these routes as "skip monitoring this
  interval and keep the call," and alert on the 503 itself only if it
  persists.

<Warning>
  Do not gate `POST /video/rooms-scheduled/:id/join` on a connection-quality
  or preflight response. A monitoring outage must never stop people from
  joining; run both surfaces as side inputs to the call, in parallel with it.
</Warning>

## Where to go next

* [Video meetings and conferences](/guides/video-meetings) — create and run
  the rooms these routes monitor.
* [Room access tokens](/guides/video-room-access-tokens) — the credentials
  a participant's SDK holds to join and report.
* [Read the cost-intelligence dashboards](/guides/cost-intelligence) — room
  minutes, usage, and the margin side of video.
* [Video API reference](/api-reference/video) — the full video endpoint
  surface.
