Skip to main content
The VoiceGateway HTTP API runs via 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:
Example:

Status and Models

GET /v1/status

Returns the configuration status of all providers and high-level counts. Response:
Example:

GET /v1/models

List all registered models across all modalities. Query parameters: Response:
Example:

Costs and Latency

GET /v1/costs

Return cost summary for a period, optionally filtered by project. Query parameters: Response:
Example:

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 to voicegw 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 of components, and every member of e2e_ms.
Example:
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:
When tenant is passed, the response also carries that tenant’s per-(modality, model) line items for invoice detail:
Example:

GET /v1/billing/rate-card

Return the rate card in effect: the global default markup plus every rule, merging the rate_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:
Example:

GET /v1/billing/rate-card/models

Distinct models seen in telemetry, each with its voice-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 its rule_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 by tenant|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.
A rule that sets both 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 its rule_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:
Example:

GET /v1/projects/

Return full details for a single project including today’s spend and budget status. Response:
Example:

POST /v1/projects

Create a new project (stored in SQLite). Request body:
Response:
Example:

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:
Example:

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):
Response (confirmed):
Example:
YAML-defined projects cannot be deleted via the API. A 403 is returned.

Providers

GET /v1/providers

List all providers (YAML-defined and managed). Response:
Example:

POST /v1/providers

Add a new provider (stored in SQLite). The provider type must be one of the supported types. Request body:
Response:
Example:

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:
Moving 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:
Sending the key with the change is always allowed, because the caller already holds a key:
Requests that keep the same host (port or path edits), clear 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:
Example:

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 no provider_id: nothing has to exist yet. Request body:
Response: same shape as 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:
Response:
Example:

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):
This endpoint exposes VoiceGateway’s own numbers so your Prometheus can scrape it. It is the opposite direction from the node scrape, which pulls exposition text from livekit-server and node_exporter into this database; nothing scraped from another process is served back out here. Turning the node scrape on. That inbound scrape is off unless you name targets. Set 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:
The credential is split off the URL when the variable is parsed and sent as an 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 with revoked_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: a calls 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 no track_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:
Example:
Fields: 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 by register_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 via register_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:
Authentication: read scope. Tenant-scoped like the other reads: an admin/operator sees every worker, a tenant-scoped key sees only its own, and a non-admin key with no tenant maps to the unattributed bucket. There is no 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 what register_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’s RemoteCollectorSink (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:
A duplicate is a record 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.
A separate route for the same reason as /v1/ingest/turns.