Skip to main content
The dashboard API is mounted by the daemon (voicegw serve) under the /api/ prefix on the same port as the public HTTP API (/v1/*) and the React SPA (/). The default port is 8080; the serve.port key in voicegw.yaml overrides it. Start the daemon (the dashboard API ships with it):
These endpoints are optimized for the dashboard UI and aggregate data slightly differently from the HTTP API (/v1/*). For example, /api/overview combines multiple queries into one response. If you are building external tooling, prefer the HTTP API.

GET /api/status

Returns the configuration status of all providers, registered models, and fallback chains. Response:
Example:

GET /api/auth-status

Report whether the deployment requires a token for mutating endpoints. The frontend calls this on load to decide whether to show the login gate; it never exposes key material. Response:
auth_required is true when auth.api_keys is set in voicegw.yaml or VOICEGW_API_KEY is set in the environment; false on the self-hosted default of neither. It does not look at vk_ keys minted via the API: a deployment with only minted vk_ keys and no static key reports auth_required: false, since the no-op auth gate throughout this API stays open until a static key exists. Authentication: none. This endpoint has to answer before the caller can present a token. Example:

GET /api/overview

Return aggregated dashboard overview statistics. This endpoint combines multiple queries into a single response for the dashboard’s summary cards. Query parameters: Response:
Example:

GET /api/costs

Return cost summary for a period, optionally filtered by project. Includes per-project breakdown when no project filter is applied. Query parameters: Response:
Example:

GET /api/costs/by-day

Day-bucketed cost and request counts for the Overview trend chart. Query parameters: Response: an array, ascending by day, each entry {"day": <epoch seconds, UTC start-of-day>, "cost": <float>, "requests": <int>}.
Example:

GET /api/latency

Return latency statistics, optionally filtered by project. Query parameters: Response: Per-model latency statistics including average TTFB and total latency. Example:

GET /api/metrics

Aggregated voice-conversation quality metrics (talk time, per-minute cost, response speed, dead air) over a trailing window, for the dashboard’s Metrics page. Query parameters: Response:
Averages are computed only over sessions rows that carry the measured columns (measured_session_count); older sessions recorded before these columns existed still count toward session_count but are excluded from the averages, so the two counts can differ. Authentication: read scope, same as /api/costs. 503 when storage is disabled. Example:

GET /api/calls

Return recent recorded calls, newest first, each with its participant legs. A call row is written by the LiveKit webhook receiver and by agent/load-worker self-reports (POST /v1/calls/observations), so a call that ran no inference at all still appears here. This is what the dashboard’s per-call layer waterfall reads. Query parameters: Response:
answer_latency_ms is the caller-visible ring time. It is derived once, at write time, and served here untouched. answer_latency_source names the clock behind it, strongest first: Four things this endpoint deliberately does not do:
  • It never computes. No value is re-derived, repaired or aggregated on the way out, and no percentile is served: one page of rows is not the population.
  • It never fills a NULL. answer_latency_ms: null means the recorded timestamps do not support a ring time (for example a call that never answered), which is a different fact from a fast answer. Nothing is coerced to 0 or "".
  • It excludes load-test traffic. Rows flagged is_probe are synthetic and are not served here, so they cannot leak into numbers read as production.
  • It is polled, not pushed. There is no WebSocket or SSE variant.
Only the sip.* participant attributes are persisted and served in attributes_json; other participant attributes are application state and are dropped at write time. Authentication follows the other dashboard reads: with no API keys configured (the self-hosted default) the endpoint is open, and once keys are configured a tenant-scoped key sees only its own calls. Tenant scoping happens in the query, not on the page after it is read, so limit means the same thing for a scoped key as for the operator: a key bound to one tenant asking for 50 gets its own newest 50, not whatever fraction of the newest 50 calls overall happened to belong to it. A key whose tenant_id is null reads the unattributed calls (tenant_id IS NULL), never every tenant’s. Example:

GET /api/correlation

How many sessions that had a room actually reached a call row. This is a data-quality reading about VoiceGateway’s own recording, not about your deployment’s health: a low rate means calls are being missed or arriving unmatched, so any per-call number computed downstream is drawn from an incomplete set. Response:
status is one of ok, warn, unknown. rate is null and status is unknown when eligible is 0: no session that could have joined a call has been recorded, so there is no rate to publish. That is not the same as a rate of 0, and the dashboard renders it as “not measured” rather than “0%”. ambiguous, dangling and no_room account for the uncorrelated remainder: a room name that matched more than one call, a call row that retention has since pruned, and a session that never had a room at all. Authentication is require_principal. A tenant-scoped key gets 403 rather than the number, because the underlying query has no tenant dimension and the counts are deployment-wide: serving them to one tenant would publish every other tenant’s session volume. The self-hosted operator default (no keys configured, or a static config key) is unaffected.

GET /api/nodes

Per recent call, which infrastructure nodes were sampled during that call’s time window. Correlation is by time window, not by attribution: VoiceGateway does not claim a given node served a given call, only that these samples fall inside the call’s span padded on each side. window.pad_ms is that padding and is reported alongside every window so the claim is auditable. Requires the node scrape to be running (VOICEGW_NODE_SCRAPE_TARGETS); with it unset the endpoint returns 200 with samples_stored: 0 and every window no_samples. Response (abridged):
correlation.status distinguishes three states that must never be read as each other: correlation is null when the call has no closed span to search, which is distinct again from no_samples. A series the scrape did not return is named and counted rather than dropped or zeroed, and a counter with no sourceable rate reports as not measured rather than 0/s. Peak statistics carry their own label (p95, max_of_n, not_measured), so a peak over fewer than ten samples is never presented as a p95. Authentication matches /api/correlation, including the 403 for a tenant-scoped key: a node is infrastructure serving every tenant at once, so there is no tenant-scoped answer to give.

GET /api/nodes/live

The newest sample per (node, source), for watching a fleet while something is happening to it. /api/nodes above answers “what did the boxes look like during this call” and is keyed by recent calls; this answers “what is the fleet doing now” and is keyed by the targets themselves. It adds no collection. The collector already scrapes every target on its interval and writes node_samples; this is a read of rows that are already on disk. There is no WebSocket: a caller that wants a live view polls this. Query parameters Response:
Every stored column keeps its own name and value. A column the scrape did not return arrives as null, never 0: an idle node and an unwatched one must stay distinguishable, and the unwatched one is the emergency. One entry per (node, source) pair, not per node. The same box is usually scraped twice per tick (once as livekit-server, once as node-exporter), and each exporter sees a different set of series. stale says only that nothing has been written for that target for longer than stale_after_seconds. It is not a health verdict: it cannot distinguish a dead node from a stopped exporter from a collector that is not running. outcome on the row is what says why a scrape failed when one did happen. The reading itself is still served when stale rather than withheld, because the last thing a node reported is often what you want to see; it is labelled so it cannot be mistaken for current. An empty nodes with a non-zero samples_stored means every target has aged out of the table. With samples_stored at 0, nothing has ever been scraped here. 503 when storage is disabled, rather than an empty fleet: “nothing is scraped” and “this deployment records nothing” are different facts. Authentication matches /api/nodes, including the 403 for a tenant-scoped key, and for the same reason.

GET /api/server/overview

A read-only, non-billing snapshot of the LiveKit deployment the metered agents run on, annotated with VoiceGateway’s own cost and latency: rooms, egress, ingress, SIP trunks/dispatch rules (from the LiveKit control plane), plus the local fleet roster. This is a control-plane list and a local DB read on every field: no synthetic probe and no billed call, unlike Diagnostics. Each section (rooms, egress, ingress, sip, fleet) is shaped {"ok": <bool>, "error": <str|null>, ...} independently, so one failing LiveKit read never blanks the whole page. Response (abridged):
connection.reachable is true/false only when a control-plane read was actually attempted, and stays null when nothing probed the deployment (no LiveKit credentials configured, or the [livekit] extra is not installed), so an unmeasured deployment is never reported as “unreachable”. Room cost_usd/request_count/p95_latency_ms are VG’s own metered totals for that room over the trailing 24 hours, joined in because LiveKit’s own console has no cost concept. Authentication: admin scope (a no-op until API keys are configured): the response includes the LiveKit URL and full control-plane topology. Example:
See Server page for how the dashboard renders this.

Session reads

The list endpoint plus five reads that hang off a session id back the dashboard’s call drill-down: Authentication: all six require the same read authentication as the other dashboard reads (/api/costs, /api/calls). As there, the gate is a no-op while no API keys are configured (the self-hosted default) and enforces as soon as auth is enabled, when an unauthenticated request gets 401. Only the list endpoint was gated before, so a session id alone was enough to read the detail, the turns, the transcript, the dead air and the replay of any call on the deployment. Tenant scoping: a tenant-scoped key reads only its own sessions. The per-session routes take no tenant parameter (the id is the whole request), so the check runs on the fetched session row. A session belonging to another tenant returns 404 with the same body as a session id that does not exist: a 403 would confirm the id is real. The self-hosted operator (no credential, or a static config key) is an admin principal and keeps reading every session, unchanged. Example:
The same rows are served by GET /v1/sessions and GET /v1/sessions/{id} on the HTTP API, under the same authentication and the same tenant scoping.

Replay writes and storage

Three endpoints beside the GET /api/sessions/{id}/replay read above: Authentication: the two writes require the admin scope, the scope every write on a dashboard router takes (API keys, diagnostics runs, the agent probe, the branding logo upload). One destroys captured payloads outright and the other sets how long any of them survive. GET /api/replay/storage is a read and takes the same read authentication as the reads above: a read-scoped key is enough. All three gates are no-ops while no API keys are configured (the self-hosted default). With auth enabled, an unauthenticated caller gets 401 and a read-scoped key attempting either write gets 403. Tenant scoping: GET /api/replay/storage aggregates every session row, so the tenant resolved from the key becomes a predicate on the query. A tenant-scoped key is told its own footprint only, and the total matches the breakdown it can see. There is no id to 404 on here: what the scoping protects is the size of another tenant’s captured traffic and the names of the projects producing it.

GET /api/agents

Return the fleet index over the last 24 hours: the telemetry rollup merged with the live worker roster. Agents that have metered traffic come from the rollup; registered-but-idle workers (0 requests) are merged in so a booted agent appears before it has handled its first call. Query parameters: Response:
fleet_status is idle, busy, or offline from the heartbeat roster, and null when the agent is telemetry-only (not a currently registered worker). latency_ms holds the average first-byte latency per modality over the same 24-hour window; only STT, LLM, and TTS are metered, so the waterfall shows three segments and no more. probe reports whether this agent’s card can place a probe: The dispatch name is LiveKit’s agent_name (the value on @server.rtc_session(agent_name=...), or the legacy WorkerOptions.agent_name). VoiceGateway resolves it from two sources, most-trusted first: the name observed on a call the agent already ran, and failing that the dispatch name a live worker reports in the fleet roster (register_worker’s name, or the value attach resolves from the job). The roster fallback lets a booted-but-idle agent be probed before its first call. A roster name is what the worker claimed, not what a finished job proved, so reason flags it as unverified; if it is wrong, the probe reaches no worker and returns that as an error rather than a fabricated number. A worker with no LiveKit dispatch (a Pipecat agent) reports no dispatch name and is not probeable, so it never gets a play button it could not answer. Only an agent absent from both sources comes back ineligible. Example:

GET /api/agents/

Aggregates for a single agent: the same rollup row GET /api/agents returns for it, plus p95_latency_ms. 404 when the agent has no telemetry (unseen or storage disabled). Response:
Unlike the index endpoint, this does not merge in the live fleet roster (no fleet_status, models, latency_ms, probe, or resources): it is a plain read of the agent_observations rollup for one id. Use GET /api/agents for the fuller per-card shape. Example:

POST /api/agents//probe

Place one real call to this agent and report its latency split and cost. Every press is billed traffic against the agent’s real providers, so this endpoint is admin-scoped (admin; the gate is a no-op until API keys are configured) and rate limited per agent: one probe in flight at a time, and no more often than every 30 seconds. Limits are per agent, so probing one agent never throttles another. One press places exactly one call. There is no warmup turn: a discarded warm-up would double what the press charges while cost_usd reported only half of it. The sample therefore includes whatever cold start that one call hit, which is why the dashboard renders it beside the 24-hour average rather than merging the two. Probe rows are tagged by a vg-probe- room name, which the 24-hour rollups exclude. Pressing play cannot move the agent’s own cost, p95, or error rate. Response:
All probe times are seconds, not milliseconds. The rest of this API reports milliseconds.
Every number returned was measured: e2e comes from a synthetic client that speaks a fixed utterance and waits for audio back, while components and cost_usd are read from the rows the agent itself wrote for the probe’s room. Anything that could not be measured is null, never zero. A null cost_usd means this host cannot know (the agent ships its telemetry to a remote collector), which is a different claim from “the call was free”. components is null for the same reason. e2e is null when no turn completed, in which case error says why. Two failure shapes are named rather than left blank: if the dispatch reached no worker (a wrong or unverified name, or an offline automatic worker), the probe detects that no one joined the room and error says so; and if the agent joined but its own pipeline errored (an STT/LLM/TTS that failed, for example a 401 to a model gateway), error carries the agent’s own message (e.g. STT: Invalid response status (401 Unauthorized)), read back from the error rows it wrote, so a probe that measured nothing still says why. components carries whichever of these legs the call produced: eou (turn detection), stt, stt_ttfp (onset to first partial, the head), stt_transcription_delay (end of speech to final, the tail), llm_ttft, and tts. Keys for legs the call did not produce are absent rather than zeroed. Errors: Example:

GET /api/projects

List all configured projects with today’s stats. Response:
Example:

API keys

GET /api/api_keys, POST /api/api_keys, and POST /api/api_keys/{key_id}/revoke back the dashboard’s API keys screen: list, mint, and soft-revoke the virtual keys (vk_...) that authenticate callers. Authentication: every route under /api/api_keys requires the admin scope, declared on the router so no route can miss it. As with Diagnostics and the Server overview, the gate is a no-op while no API keys are configured (the self-hosted default), and it enforces the admin scope as soon as auth is enabled. An unauthenticated request then gets 401, and a valid token without the admin scope gets 403. The dashboard already sends its bearer token on these calls. This gate matters because a minted key is issued with the wildcard scope. An ungated mint is a write escalation onto every /v1 endpoint. A key minted here defaults to role: tenant, so a minted key cannot mint another key (403). POST /api/api_keys takes name (required), tenant_id (optional), and issued_by (optional), and returns plaintext exactly once at creation; the dashboard shows the “save this key” modal and discards it. Subsequent list responses expose only key_prefix, never the plaintext or the bcrypt hash. Revoke is soft: the row stays for audit with revoked_at set, and revoking an already-revoked key returns 404. For the equivalent endpoints on the public API, see the HTTP API.

Branding

GET /api/projects/{id}/branding returns the project’s white-label payload (logo_url, accent_color, product_name), POST /api/projects/{id}/branding upserts it, and POST /api/projects/{id}/branding/logo uploads a PNG or SVG logo. Authentication: both POSTs require the admin scope; the GET does not. White-label branding is an operator/agency setting: the write stamps the logo_url every dashboard page renders and the product_name it renders as its own name. The GET stays open to any authenticated reader on purpose: every dashboard layout mount calls it to apply the brand, and the frontend treats a 403 exactly like a 401 (it clears the stored token and shows the login gate), so gating it behind the admin scope would log out a read-scoped operator on page load. That is why the gate sits on the two write routes and not on the router. Both gates are no-ops while no API keys are configured. The logo upload accepts PNG or SVG, capped at 256 KB on the wire; a PNG is also capped at 512x512 pixels (SVG is vector, so no dimension check applies). An uploaded SVG is rejected if it contains script, an event-handler attribute, or an executable URL scheme.

GET /api/loadtest/runs

Imported load-test runs, newest first, each with its per-test rows embedded (no second call per run: a load run is a handful of rows, and the dashboard’s demo mode answers by pathname only, which a parameterised /runs/{id}/tests path cannot fixture). Query parameters: Response (abridged):
data_provenance is derived, not stored: "measured" when the run carries a real artifact_sha256, "synthetic" otherwise. It is computed by the same helper the load-test report uses, so a run cannot read as measured on one surface and not the other. Authentication: read scope (require_principal), not the write scope /v1/calls/observations carries: this is a read and does not inherit that router’s scope. 503 (not an empty array) when storage is disabled, since “nothing imported” and “this deployment records nothing” are different facts. Example:
See voicegw loadtest import for how a run gets here.

Diagnostics

Single-vantage LiveKit deployment checks, run from this host and persisted to storage so history survives a restart. Every route below requires the admin scope (a no-op until API keys are configured): a run places real, billed calls against your own LiveKit project and providers.

GET /api/diagnostics/creds

Whether LiveKit credentials are configured on this host, and the server URL if so. The dashboard checks this before offering the “run a diagnostic” UI. Response:
Example:

POST /api/diagnostics/runs

Start a new diagnostics run: a background task executes the requested checks (up to 360 seconds) and the run is persisted at every state transition (queued -> running -> done/failed), so its last observed state survives a restart or a killed process. Request body:
checks must be a non-empty subset of agents, sfu, sfu_load, latency (see voicegw livekit check for what each verifies). config is check-specific and optional. Response: {"run_id": "<hex>", "status": "queued"}. Example:

GET /api/diagnostics/runs

The run history, newest first: stored rows merged with any run still live in this process (the in-process copy wins on overlap, since a status flip is written to storage right after it happens, not atomically with it). Each entry has the same shape as GET /api/diagnostics/runs/{run_id} below. Example:

GET /api/diagnostics/runs/

Return one recorded diagnostics run: its status, verdict, gates, and one entry per check that ran. Only the latency check’s per-agent entry is specified here; every check is exported in full by the report endpoints below. latency check result:
All probe times are seconds. trials counts the trials that answered, so 0 means nothing was measured for that agent and every statistic beside it is summarize’s fabricated zero: render it as not measured, never as an instant reply. error is the reason the probe recorded for an agent that answered nothing, verbatim: a dispatch that reached no worker, a connection that failed, a client that raised. null means no reason was recorded, which is a different fact from an empty reason (the worker joined and simply never replied). Both surfaces name the failure the same way, because both read this one field: voicegw livekit latency and voicegw livekit check print no successful probe (<reason>) and fall back to no successful probe (no reply), and the dashboard’s Latency and Errors tabs say the same. A probe that measured nothing therefore still says why, whenever why is knowable. The string is written by the LiveKit server or a provider, not by VoiceGateway. Treat it as untrusted remote text: render it as text, never as markup.

GET /api/diagnostics/runs//report

Export one stored diagnostics run as a versioned JSON payload. The payload carries schema_version (currently 1) and kind: "voicegateway.diagnostics.run_report". Within a major version the payload is additive only: no key changes meaning, type or nesting, none disappears, and parsers must ignore keys they do not recognise. Anything that would break a v1 parser ships as schema_version: 2. Unmeasured is null, never 0. A check that was not part of the run, one that recorded no result, and one that errored are three distinct states, so an absent measurement can never be read as a clean one. Packet loss is reported as the literal "not_measured" because it is not observable server-side. The run’s verdict is read from what the run stored, not recomputed: livekit_diag/gates.py is the only place in the product that decides a verdict. A run recorded before gates existed reports gates_recorded: false rather than being re-judged after the fact.

GET /api/diagnostics/runs//report.html

The same report as a single self-contained HTML file, served with Content-Disposition: attachment. Self-contained means exactly that: no script, no external stylesheet, no remote font, no image, no network request of any kind. It renders correctly from file:// on a machine with no internet, months later, which is the point of handing it to a client or attaching it to a ticket. A verdict of UNKNOWN renders as the word UNKNOWN on neutral grey with the line “This is NOT a pass”, never a green tick. Each gate prints its status as text rather than colour alone, so a printed or monochrome copy still carries it.

Static File Serving

The dashboard also serves the React frontend’s built assets on the same port as /v1/* and /api/*. If the frontend has been built (src/dashboard/frontend/dist/ exists), the daemon serves:
  • GET / — the React app’s index.html
  • GET /assets/* — bundled JavaScript, CSS, and other static files
  • All other paths fall through to index.html for client-side routing (SPA fallback)
If the frontend has not been built, GET / returns an error message with build instructions.