voicegw serve (default port 8080). It provides read-only observability endpoints and full CRUD for managing providers, models, and projects.
Start the server:
Health
GET /health
Returns the health status and uptime of the gateway. Response:Status and Models
GET /v1/status
Returns the configuration status of all providers and high-level counts. Response:GET /v1/models
List all registered models across all modalities. Query parameters:
Response:
Costs and Latency
GET /v1/costs
Return cost summary for a period, optionally filtered by project. Query parameters:
Response:
GET /v1/latency
Return latency statistics for the given period. Query parameters:
Response: Per-model latency statistics including average TTFB and total latency.
Example:
Sessions
One row per voice call: when it started and ended, which modalities and providers it used, and what it cost. These are the public twin of the dashboard’s/api/sessions reads and return the same rows; the Dashboard API additionally serves the per-session drill-downs (turns, transcript, dead air, replay).
Authentication: both routes require the same read authentication as the dashboard reads, declared on the router so no route can miss it. The gate is a no-op while no API keys are configured (the self-hosted default: no auth.api_keys block and no VOICEGW_API_KEY), and it enforces as soon as auth is enabled, when an unauthenticated request gets 401. A read-scoped key is enough; no admin scope is needed to read rows already on disk.
Tenant scoping: the tenant comes from the authenticated key, never from a parameter (these routes publish none). A tenant-scoped key lists only its own sessions and reads only its own session by id. The list filters as well as the detail: a list that returned every tenant’s sessions would hand over exactly the ids the detail route refuses. An operator with no credential, a static config key, or an admin key sees every tenant, unchanged.
GET /v1/sessions
Return recent sessions. Query parameters:
Response: An array of session rows, each containing
id, project, started_at, ended_at, modalities, request_count, total_cost_usd, and tenant_id. Empty array when cost tracking is disabled.
GET /v1/sessions/
Return one session with the per-modality cost breakdown (by_modality) and the deduplicated providers list computed by joining the requests table.
Example:
Rooms
The per-stage latency split for one LiveKit room, over HTTP. The same split is available in-process tovoicegw livekit latency, but only when the prober and the agent share one local store. A deployment that runs the agent in one container and the collector in another holds every row needed and cannot reach the computation. This endpoint is that computation, reachable. No new measurement happens here.
Keyed on the room name, deliberately, not on session_id. A caller mints the room name when it signs the LiveKit token, so the room is the only identifier both sides hold at call time; session_id is minted inside VoiceGateway and a caller never observes it.
Authentication: read authentication, the same as Sessions. Tenant comes from the key and never from the request.
GET /v1/rooms//latency
Query parameters:since_turn is a cursor over turns and nothing else. It cuts on seq, the room-wide position, not on turn_index: turn_index is session-local, so a room carrying more than one session has two turns numbered 0, two numbered 1, and so on. Pass back the seq of the last turn you saw.
components and e2e_ms are whole-call aggregates and are never narrowed by since_turn. They are recomputed from the rows present at request time, so they do move as a call progresses and successive polls will differ; what since_turn guarantees is only that it is not the thing moving them.
Four answers that mean different things:
Every value is in milliseconds, but of two different kinds, and the field names do not distinguish them:
- Epoch timestamps (a point in time):
caller_speak_end_ms,agent_speak_start_ms. Unix epoch, milliseconds. - Elapsed durations (a length of time):
response_speed_ms, every member ofcomponents, and every member ofe2e_ms.
e2e_ms is computed by the same summarize() the CLI reports through, so the two surfaces cannot drift on what p95 means. It is null, rather than a row of zeros, when no turn carries a measured response_speed_ms.
That is not the same as “no turns”: turn_count can be non-zero while e2e_ms is null. A turn the agent never answered records a null response_speed_ms, and such turns are listed in turns but contribute no sample. Averaging them in as 0 would drag every percentile down and report a call as faster than it was.
Not provided, on purpose: no CORS and no browser-facing key (a read-scoped key in a browser can read every session and cost row for the tenant, so proxy it server-side); no WebSocket or SSE (ingest is batched behind a bounded queue, so freshness is inherently seconds, and a socket would deliver seconds-stale data faster while adding a stateful fan-out surface); no per-turn component split.
Billing
The rating layer’s read surface. VoiceGateway rates each recorded request at write time (rated_price_usd + rate_rule); these endpoints roll that up per tenant and expose the rate card in effect. See Rating for the model.
GET /v1/billing/usage
Return rated revenue, recorded cost, and margin per tenant for a window. Query parameters:
Response:
tenant is passed, the response also carries that tenant’s per-(modality, model) line items for invoice detail:
GET /v1/billing/rate-card
Return the rate card in effect: the global default markup plus every rule, merging therate_card: YAML seed with the DB overrides. The rule field on each rule is the audit token stamped onto matching requests (for example cost_plus:1.3 or fixed:0.006/minute).
Response:
GET /v1/billing/rate-card/models
Distinct models seen in telemetry, each with itsvoice-prices unit cost and the rate rule that currently applies to it. Lets an operator spot stale prices and see which rule matches a model without cross-referencing /rate-card/rules by hand.
Response:
unit is the canonical unit voice_price_usd is priced per: minute for STT, 1k_char for TTS, 1m_token for LLM. voice_price_usd is null when the model is not in the voice-prices catalog. effective is the matching rate-card rule (seed or DB override), or null when no explicit rule matches and the default markup applies.
Example:
GET /v1/billing/rate-card/rules
Return the editable DB override rules, each with itsrule_id. Use this to drive an editor (the seed rules in GET /rate-card have no rule_id; only DB overrides are mutable).
POST /v1/billing/rate-card/rules
Upsert a DB rate-card override for a scope (one rule per scope, keyed bytenant|plan|modality|provider|model). Requires the write scope. Takes effect on the next config refresh, which the call triggers.
Body: a scope (modality?, provider?, model?, tenant?, plan?) plus either markup (cost-plus) or fixed + unit.
markup and fixed, or a fixed rule with a missing/invalid unit, returns 400.
DELETE /v1/billing/rate-card/rules/
Delete a DB override by itsrule_id (from GET /rate-card/rules). Requires the write scope. Returns 404 when no rule has that id.
The rate card is one store, edited three ways: the
rate_card: seed in voicegw.yaml, the CLI (voicegw prices set / rm), and these HTTP endpoints (which the dashboard Rate card page under Configure also uses). See Rating.Projects
GET /v1/projects
List all configured projects with today’s stats. Response:GET /v1/projects/
Return full details for a single project including today’s spend and budget status. Response:POST /v1/projects
Create a new project (stored in SQLite). Request body:PATCH /v1/projects/
Update a managed project. Only projects created via the API (source"db") can be updated.
Request body: Any subset of fields from the POST body.
Response:
DELETE /v1/projects/
Delete a managed project. Requires?confirm=true to actually delete. Without the parameter, returns a preview of what would be deleted.
Query parameters:
Response (preview):
Providers
GET /v1/providers
List all providers (YAML-defined and managed). Response:POST /v1/providers
Add a new provider (stored in SQLite). The provider type must be one of the supported types. Request body:PATCH /v1/providers/
Update a managed provider’s API key, base URL, or type. Omitted fields keep their stored value, so a body of{"api_key":"sk-new-key"} rotates the key and leaves base_url alone.
Example:
base_url to a new host
A request that changes base_url without supplying an api_key keeps the stored key, and POST /v1/providers/{provider_id}/test then sends that key to the new host. So that one combination requires the new host to be permitted. Permitted are the provider’s current host, the vendor’s own default host (api.openai.com for openai, localhost for ollama, and so on), and any host listed in serve.provider_base_url_hosts in voicegw.yaml. An unpermitted host returns 400 naming the config key:
base_url, or leave it untouched are unaffected, as are providers stored with an empty key such as a local ollama.
DELETE /v1/providers/
Delete a managed provider. Requires?confirm=true.
Query parameters:
Example:
POST /v1/providers//test
Test connectivity to a provider by running its health check. Response:POST /v1/providers/test
Test connectivity for a provider type + credential before saving it, for an “add provider” form that wants to validate a key first. Unlike the route above, this takes noprovider_id: nothing has to exist yet.
Request body:
POST /v1/providers/{provider_id}/test: {"status": "ok" | "failed", "latency_ms": <int>, "message"?: <str>}.
Example:
Models
POST /v1/models
Register a new model (stored in SQLite). Request body:DELETE /v1/models/
Delete a managed model. Requires?confirm=true. The model_id is a path parameter (for example, deepgram/nova-3).
Example:
Logs and Metrics
GET /v1/logs
Return recent request logs. Query parameters:
Response: An array of log records, each containing
timestamp, project, modality, model_id, cost_usd, total_latency_ms, status.
Example:
GET /v1/metrics
Return Prometheus-format metrics (plain text). Response (text/plain):VOICEGW_NODE_SCRAPE_TARGETS to a comma-separated list of source:name=url entries before starting the collector:
source is one of livekit-server, livekit-sip or node-exporter. name is the node the samples are filed under, and using the same name for two sources is the point: it puts an SFU’s own counters and its host’s file descriptors on one time axis. A malformed entry is skipped with a warning instead of failing startup, and the collector logs how many targets it read.
An endpoint behind basic auth takes the credential in the URL, the usual way:
Authorization header instead. That matters because the HTTP client logs its request line at INFO: left in the URL, the password would be written to the log on every tick, four times a minute, for as long as the collector runs. Nothing logs it now, including the warning about a malformed entry, which prints the host with the credential replaced by ***. Percent-encode a password containing @, : or /.
With the variable unset or empty no scrape worker is started and the collector makes no outbound requests, which is the default. Cadence comes from workers.node_scrape_interval_seconds in voicegw.yaml (default 15 seconds, matching Prometheus’ own default); workers.enabled: false disables this worker along with the rollups.
voicegw_cost_usd_total and voicegw_requests_total are gauges over a rolling 24-hour window, not counters. Both are computed from the "today" window, which is now - 86400 seconds: a rolling trailing 24 hours. It is not midnight-to-now and not a since-process-start total. The value therefore goes down as well as up, every time a request falls off the trailing edge. The period="today" label and the _total suffix are both misnomers kept for backward compatibility, because renaming a scraped series would break every dashboard already built on it. The # TYPE metadata is now gauge, which is what your tooling actually reads.
Concretely, this means:
sum(voicegw_requests_total) sums the per-provider series; there is no separate unlabelled total. Do not write bare sum(voicegw_cost_usd_total): that series is emitted three times over, once with period="today", once per provider and once per project, so an unfiltered sum counts the same spend about three times. Always select a label set.
For an actual monotonic spend counter (one that supports rate() and increase()), VoiceGateway does not publish one yet. Use GET /v1/costs?period=all, which is a true since-start total, or period=week / period=month for wider rolling windows.
The latency series (voicegw_request_ttfb_seconds, voicegw_request_total_latency_seconds) are summary quantiles over the same rolling trailing 24 hours. Summary quantiles were never counters, so their type is unchanged; no _sum or _count children are published, so there is nothing to rate() there either.
Diagnostics gate series. voicegw_diag_gate_status reports the health gates of the newest stored diagnostics run that gated anything, aggregated per gate id and status: the probed agent is not a label, so cardinality does not grow with your fleet. Statuses are the one ladder voicegw livekit check uses, PASS < WARN < UNKNOWN < FAIL, where UNKNOWN means the gate could not be evaluated and is not a pass (only PASS exits 0). voicegw_diag_run_verdict is that run’s stored verdict, and voicegw_diag_run_timestamp_seconds is when it finished, so a clean verdict from three weeks ago is distinguishable from one from a minute ago.
Unknown values are omitted, never zero. No diagnostics run, no readable diagnostics table, or a status this build does not recognise means the series is absent. A 0 would be a real observation and would be alerted on; absence is the honest reading. Alert on what is there (for example voicegw_diag_gate_status{status!="PASS"} > 0) and on absent(...) if you require a run to have happened.
Example:
Audit Log
GET /v1/audit-log
Return audit log entries for CRUD operations performed via the API. Query parameters:
Response: An array of audit log entries.
Example:
API Keys
Mint, list, and revoke the virtual keys (vk_...) that authenticate callers of this API.
Authentication: every route under /v1/api-keys requires the admin scope, declared on the router so no route can miss it. Like Diagnostics, the gate is a no-op while no API keys are configured (the self-hosted default: no auth.api_keys block and no VOICEGW_API_KEY), and it enforces the admin scope as soon as auth is enabled. Pass a static key carrying admin or the * wildcard scope, or an admin-role vk_ key: Authorization: Bearer ....
This gate matters because a minted key is issued with the wildcard scope. An ungated mint is a write escalation onto every /v1 endpoint, so an unauthenticated caller must not reach it. A key minted here defaults to role: tenant, which means a minted key cannot mint another key (403): a leaked key is not self-replicating.
POST /v1/api-keys
Mint a virtual key. Body:name (required), tenant_id (optional, binds the key to one tenant), issued_by (optional audit string). Returns 201 with plaintext (the only time the full key is ever returned) and the stored key row. The bcrypt hash is never exposed.
GET /v1/api-keys
List every key.include_revoked (default true) keeps soft-revoked rows in the response. Rows carry key_prefix, never the hash or the plaintext.
GET /v1/api-keys/
Fetch one key by id.404 when missing.
DELETE /v1/api-keys/
Soft-revoke a key. The row stays for audit withrevoked_at set. Returns 204.
LiveKit Webhooks
POST /v1/livekit/webhook
Receive LiveKit room and participant lifecycle webhooks and record them as calls. This is what makes a call that runs no inference visible: acalls row and its call_legs are written from webhook events alone, so a call that never reached an LLM still exists in the schema.
Point your LiveKit project’s webhook URL at this endpoint. It must be publicly reachable, since LiveKit posts to it.
Authentication: the LiveKit webhook signature, not a bearer token. LiveKit cannot send a VoiceGateway API key, so the signature is the auth boundary. The request is verified against your LiveKit API key and secret before the body is parsed, and the endpoint fails closed: with no LiveKit credentials configured it returns 503 and writes nothing, rather than accepting unsigned writes.
Events recorded:
room_started, room_finished, participant_joined, participant_left, participant_connection_aborted, track_published, track_unpublished. Egress and ingress events are accepted and ignored.
Delivery is neither ordered nor exactly-once, so every write is an idempotent upsert keyed on room_sid (and participant_sid for legs). Any event can create the call row, including a participant_left that arrives first.
What this gives you: disconnect_reason from LiveKit includes real layer-1 failure causes, so a SIP_TRUNK_FAILURE, USER_UNAVAILABLE, or USER_REJECTED becomes readable per leg.
Configuration: set LIVEKIT_API_KEY and LIVEKIT_API_SECRET (or the livekit: block in voicegw.yaml). Set VOICEGW_LOADTEST_TRUNK_IDS to a comma-separated list of SIP trunk ids to mark calls arriving on those trunks as probe traffic, so load tests never pollute production percentiles.
Call Observations
POST /v1/calls/observations
Record what only a participating process can see. LiveKit sends notrack_subscribed webhook, and webhook timestamps are whole seconds, so the agent’s own clock is the only source of a millisecond-precision “I published audio at T” for the call: the timestamp that gates the caller’s ring time, because livekit-sip withholds 200 OK until it subscribes to an audio track. An agent (or a load worker) posts its own view here; it merges into the same calls and call_legs rows the webhook receiver writes.
Authentication: a VoiceGateway API key with the write scope (Authorization: Bearer vk_..., or a static key from auth.api_keys). Unlike the LiveKit webhook, the caller here is your own agent, which already carries VOICEGW_API_KEY. tenant_id is taken from the key, never from the body.
This endpoint does not wait for the database. The report is validated, queued, and answered; a single background flusher writes it. That is deliberate: the hook runs in the agent’s job-start path, so a synchronous write would add latency to the exact number being reported. The queue is bounded at 1000 reports and drops the newest report when it is full rather than blocking the agent or growing without limit.
Both the
202 and the 429 body carry the counters, so a reporter can see the loss:
origin (agent or loadgen, required), one of room_sid or attempt_id (required: a report with neither cannot be correlated), plus room_name, run_id, project, agent_id, started_at_ms, ended_at_ms, and up to 16 legs. A leg carries participant_sid (required), identity, kind (SIP/AGENT/STANDARD/INGRESS/EGRESS), joined_at_ms, left_at_ms, disconnect_reason, first_audio_track_at_ms, audio_track_sid, audio_codec. All timestamps are epoch milliseconds; a seconds- or microseconds-scale value is rejected rather than merged as a nonsense call duration. calls.channel and calls.end_reason are derived from the reported legs by the same rule the webhook uses (the call’s end reason comes from the SIP leg, because that leg is the caller).
Unknown fields are rejected, not ignored (422). Silently accepting a field nothing stores would let you believe a number was recorded when it was not. So there is no per-call loss, jitter, MOS, or DTMF field (not observable, no column), no SIP response code (livekit-api exposes no ListSIPCallInfo), no is_probe (probe traffic is discriminated by a dedicated load-test trunk, never by anything on the wire), and no tenant_id.
Writes are idempotent. Reports are merged with the same upserts the webhook uses, keyed on room_sid/attempt_id (and participant_sid for legs), so a re-sent report is a no-op and a report that arrives before any webhook creates the row.
Configuration: set VG_DISABLE_CALL_OBSERVATIONS to any value (other than 0/false/no/off) to turn the path off: the endpoint then answers 503, queues nothing, and starts no flusher. It is read per request, so it takes effect without a restart.
Fleet
Two endpoints an agent process (not an operator) calls directly: presence for the dashboard’s Fleet/Agents view, and telemetry ingest for a self-hosted collector. Both are written byregister_worker and attach(collector_url=...) / attach(heartbeat=True); you would only call them by hand when scripting a non-Python worker.
GET /v1/agents
Return the live worker roster: processes that registered viaregister_worker or attach(heartbeat=True) and heartbeated recently. A worker ages out of the roster 45 seconds after its last heartbeat.
This is presence only (idle/busy, version, host, memory), not cost or latency telemetry. For the fleet index with cost, p95 latency, and the probe button, see GET /api/agents on the Dashboard API; the two are deliberately separate surfaces (roster vs. telemetry).
Response:
tenant query parameter; scoping comes entirely from the caller’s credential.
Example:
POST /v1/agents/heartbeat
Upsert the caller’s own worker presence row. This is whatregister_worker’s collector-mode pusher and attach(heartbeat=True) POST every 15 seconds by default when a collector_url is configured; a vk_ key can only write its own tenant (any tenant_id in the body is overridden by the authenticated key’s tenant).
Request body: the worker’s presence snapshot: agent_id, agent_name, dispatch_name, status, active_sessions, version, project, tenant_id, region, host, started_at, memory_rss_bytes, memory_total_bytes, cpu_pct, ts.
Response: 202 with {"status": "accepted"}.
Authentication: write scope.
Example:
POST /v1/ingest
Persist a batch of request records pushed by a fleet agent’sRemoteCollectorSink (used when attach(collector_url=...) / VOICEGW_COLLECTOR_URL is set). This is the self-hosted collector’s write path; a single-node deployment with no collector_url never calls it.
Request body: a JSON array of request-record dicts (not wrapped in an object). Unknown keys are ignored; a record missing required fields is skipped and counted, not rejected as a batch. Each record is re-rated against the collector’s own rate card before it is stored, so an agent-supplied rated_price_usd / rate_rule is overwritten: agents rate at cost pass-through, the collector is the source of truth for margins. See Rating.
Response:
id (UUID) already stored, from a sink retry; it is counted, not double-written.
Authentication: write scope.
Example:
POST /v1/ingest/turns
Push a batch of conversation turns. Same bearer auth and batch cap as/v1/ingest, returns {"accepted": n}.
Each object needs session_id, turn_index, caller_speak_start_ms, and caller_speak_end_ms. agent_speak_start_ms, agent_speak_end_ms, and response_speed_ms are optional. Unknown keys are ignored, and one malformed row does not reject the batch.
A separate route rather than a record type on
/v1/ingest. That handler builds a RequestRecord from every object it receives and counts anything that fails to build as malformed, so a turn posted there is answered 200 and silently dropped.Turns are always written to SQL, even where ClickHouse is configured for requests: ClickHouse has no turns table, and every reader of turns (/v1/rooms/{room}/latency, /api/sessions/{id}/turns, the session aggregates) reads from SQL.POST /v1/ingest/dead-air
Push a batch of dead-air events. Same bearer auth and batch cap, returns{"accepted": n}.
Each object needs session_id, started_at_ms, duration_ms, and threshold_used_ms. Unknown keys are ignored, and one malformed row does not reject the batch.
/v1/ingest/turns.