HTTP API
Local dashboard API with explicitly marked hosted-development differences — upload, media, evaluation policy, pricing and workspace-deletion boundaries.
Unless explicitly marked hosted development, this reference describes the
local app.main:app API. Marked sections describe the separate
app.cloud.main:app development deployment, not a generally available hosted-service
contract. New public workspaces remain closed. The SDKs use upload endpoints; the console uses session, audio and
review endpoints.
The hosted working tree includes separate WAV media-link/playback and asynchronous workspace-data deletion paths; these are not publicly validated service guarantees, and workspace deletion does not delete the Clerk identity. Derived turns, analytics and evaluations remain unavailable, with no evaluation provider calls. Do not infer hosted behavior from the local routes documented here.
For the phase-two hosted checkout, schema migration
002_media_and_workspace_deletion.sql is required after 001_initial.sql.
playback and account_deletion capability flags indicate implemented surfaces,
not successful live deployment. Per-recording preview.available controls
actual playback readiness. Workspace deletion requires recent factor verification
from signed Clerk fva claims and confirmation of the authenticated workspace;
it retains the disclosed minimal ledger and does not globally delete the Clerk
identity. These hosted requirements do not apply to the local routes below.
Authentication is off by default. With VAANI_REQUIRE_API_KEY=1 the two
ingest endpoints (POST /v1/sessions and POST /v1/sessions/{id}/complete)
require a bearer token minted through POST /v1/api-keys; without it, the
authorization header is recorded but not checked and any non-empty key
works. Read endpoints, including audio download, are open either way. Bind it
to localhost. Neither an ingest key nor a proxy makes this API tenant-safe.
The local entrypoint refuses hosted-environment startup except for explicitly
configured curated demo operation; that exception is not a SaaS deployment
mode.
Upload protocol
Three steps, in order.
Use the pinned source previews. Cloud-compatible uploaders are published
in Python revision 0797c0fcdac6039f8830e6c726e0e593d36cf646
(0.5.7b1) and Node revision d2bb0f3629d724ea6bfc505e1b975d595dddddeb
(0.1.1-beta.1). Follow the exact Git install commands in
Python quickstart or
Node.js quickstart. Registry packages have not been
published; Git/build tooling and explicit revision upgrades are required.
Source availability does not open workspace admissions or certify production readiness.
POST /v1/sessions
Creates a session from a manifest.
Headers
| Header | Required | Notes |
|---|---|---|
content-type: application/json | yes | |
idempotency-key | no | When present, must equal session_id or the request is a 400 |
authorization: Bearer <key> | no | Accepted, not validated |
Response 201
{
"session_id": "9f2c…",
"upload_urls": { "events.jsonl": "/v1/uploads/9f2c…/events.jsonl" },
"accepted_encodings": ["gzip"]
}Re-posting the same manifest is idempotent.
Additive upload inventory
The development-checkout uploaders include an upload_objects field alongside
the manifest fields on create. It uses the same inventory shape as completion:
{
"upload_objects": {
"events.jsonl": { "byte_size": 0, "sha256": "<SHA-256 of the file>" },
"call.audio": { "byte_size": 5898240, "sha256": "<SHA-256 of the file>" }
}
}events.jsonl is included even when empty. Sizes and hashes describe the
uncompressed objects. This is additive: the local API remains compatible with
the older manifest-only create request. An inventory is transfer metadata, not
proof that capture was complete or that a tenant has been authenticated.
Replay compatibility in the development checkout
The current hosted development backend uses the accepted-replay shortcut.
An identical create for an already-accepted attempt returns the same session_id
and upload_id, an ISO expires_at, and these replay fields:
{ "status": "accepted", "upload_urls": {}, "upload_headers": {} }Manifest and inventory identity are checked first: a different manifest or
inventory returns 409, not this shortcut. In the explicit accepted/empty-map
case, the checkout SDKs skip all object PUTs and repeat completion with the
same exact uncompressed inventory:
{ "objects": inventory }. This also applies if the single permitted
expired-capability refresh returns the already-accepted response.
Completion remains idempotent with a stable 202 body containing session_id,
upload_id and status: "queued"; that acknowledges queued work, not finished
import. Each completion checks that the session is still eligible and the
inventory matches exactly. First completion checks each stored object's digest
and size and pins its current ETag. An already-accepted completion retry returns
the stable receipt without re-statting Blob storage; it is not a fresh
integrity check. Accepted objects are never republished or repaired by replay.
A previously issued, still-valid upload capability may verify identical streamed
bytes and return 204 without changing the accepted object or ETag. For this
old-capability PUT against an accepted object, upload finalization rechecks the
stored object's pinned ETag, digest and size. Changed bytes are rejected with
409 or 422; the old URL is not a replacement path.
For create responses that do contain upload URLs, the SDKs continue to use the
normal PUT-then-complete flow.
An empty URL map alone is not this acknowledgement; clients must not infer acceptance from missing upload URLs. This is an unreleased hosted development contract, not a change to the legacy local API's storage semantics, a durability guarantee or evidence of live cloud acceptance. It requires the compatible checkout SDK behavior; current public Git pins do not include that behavior.
Per-object upload headers
An upload handshake can return upload_headers keyed by object name alongside
upload_urls. The development-checkout SDKs apply only allowed per-object
headers and do not forward the ingest bearer token to object uploads. They
accept HTTPS destinations or loopback HTTP, reject redirects, and forbid an
HTTPS-ingest-to-HTTP-object downgrade. The local API does not require this
extension and still has unauthenticated object PUTs.
These restrictions reduce unintended credential or recording disclosure but can reject older proxy setups or non-loopback HTTP collectors. They do not certify a hosted backend or make the local API safe to expose.
In the hosted development contract, 401 with
detail.code: "upload_expired" explicitly permits one capability refresh.
An elapsed expires_at alone does not make an ordinary 401/403 refreshable.
The 429 codes monthly_quota_exhausted and storage_quota_exhausted are
permanent failures, not requests to retry after a delay.
accepted_encodings
This field is the compression contract, and a client must treat it as mandatory input rather than decoration.
| Value | Client must |
|---|---|
["gzip"] | Send Content-Encoding: gzip for large objects, or not — both work |
[] or field absent | Send objects identity-encoded only |
An absent field means identity. This matters in both directions:
- A client that compresses unconditionally against an ingest that does not
decompress will get a
204on thePUT— the bytes are stored verbatim — and then fail digest verification at/v1/sessions/{id}/complete. The upload appears to succeed and the recording is lost. This is not hypothetical: it was observed against an older build of this API and is why the field exists. - A server that omits the field will never receive a compressed upload from a conforming client, so every recording transfers at full size. If you are implementing this API and you decompress, you must advertise it.
PUT /v1/uploads/{session_id}/{object_name}
Uploads one object. Local object PUTs are unauthenticated; the returned local
URL is not a signed credential. Keep this API on localhost. Do not forward
the dashboard bearer token to a different upload origin. Hosted-beta object
authorization is a separate contract, not a property of this local route.
| Constraint | Value |
|---|---|
Allowed object_name | events.jsonl, call.audio, caller.audio, agent.audio |
| Max body | 128 MiB (decompressed) |
| Success | 204 No Content |
| Unknown object name | 404 |
| Over the cap | 413 |
Unsupported Content-Encoding | 415 |
| Malformed gzip body | 400 |
The body is streamed to a .part file and renamed on success, so a failed upload
never leaves a partially written object in place.
Content-Encoding: gzip
The body may be gzip-compressed only when the 201 advertised gzip in
accepted_encodings. The server decompresses as it streams and counts
decompressed bytes against the cap, so compression buys transfer time, not
headroom. Only gzip, identity and an absent or empty header are accepted;
anything else is a 415.
The digest and byte_size you declare in
POST /v1/sessions/{id}/complete describe the
uncompressed object — they are a property of the object, never of the
transfer. The Python SDK compresses objects of 64 KiB or more by default
(upload={"compress": False} to turn it off).
The 128 MiB cap is a hard limit, not a soft one. Raw stereo PCM at 16 kHz is roughly 3.8 MB per minute, so a call longer than about 35 minutes cannot be uploaded. The binding constraint arrives sooner at higher sample rates: at 48 kHz the limit is around 12 minutes.
POST /v1/sessions/{session_id}/complete
Marks the session complete and verifies every object.
{
"objects": {
"events.jsonl": { "byte_size": 48213, "sha256": "3f9a…" },
"call.audio": { "byte_size": 5898240, "sha256": "c14b…" }
}
}| Outcome | Status |
|---|---|
| Accepted | 202 |
| Byte size or digest mismatch | 400 |
| A declared object was never uploaded | 400 |
The session's status becomes:
| Status | Meaning |
|---|---|
ready | Objects verified and operations imported |
partial | Objects verified but no operations could be read |
Verification is why a truncated upload is a 400 rather than a call with
quietly missing turns. partial is what surfaces as unverifiable in the
dashboard.
Reading sessions
GET /v1/sessions
Lists sessions with status and manifest summary.
GET /v1/sessions/{session_id}
Full session detail: manifest, status, timestamps and imported operations.
Audio
GET /v1/sessions/{session_id}/audio/{track}
| Parameter | Values |
|---|---|
track | call | caller | agent | mixed |
preview | wav — wrap raw PCM in a WAV header for browser playback |
from_ms, to_ms | Cut a clip |
Honours HTTP Range. This is not optional politeness — Safari refuses to play
any media response that does not support ranges.
mixed is WAV-preview only. Audio is stored once as raw PCM; the WAV
wrapper is generated per request rather than stored as a second copy.
GET /v1/sessions/{session_id}/audio/{track}/peaks
Server-rendered waveform envelope, one peak per pixel column, so a long call does not ship megabytes of samples to the browser.
Hosted playback and workspace deletion (development checkout)
These routes belong to the hosted working tree, not the unauthenticated local audio API above. They do not establish live Azure/Clerk acceptance or public availability.
POST /v1/sessions/{session_id}/media-link
Uses the external SDK session ID and requires owner authentication. The
request selects the finalized call recording with { "track": "call" }.
Check the recording's preview.available before requesting playback.
| Response field | Meaning |
|---|---|
url | Signed media URL; fetch it without forwarding the API bearer token |
session_id | External SDK session ID used to request the link |
media_id | Internal UUID used by the signed media route; distinct from the SDK session ID |
expires_at | ISO expiration timestamp |
content_type | audio/wav |
byte_size | Prepared WAV size |
track | call |
range_bytes | Maximum byte range per media request |
session_id and media_id are additive correlation fields. The current UI
accepts earlier responses without them and validates them when present. They
are not authorization credentials; access still depends on the signed media
capability. Treat url as sensitive and do not put it in logs or share it as a
permanent recording link.
DELETE /v1/account
This deletes the authenticated Vaanieval workspace and its data, not the
global Clerk identity. It requires recent factor verification from signed
Clerk fva claims and this API confirmation body:
{
"confirmation": "DELETE",
"workspace_id": "<authenticated workspace UUID>"
}The UI asks a person to type DELETE WORKSPACE and submits the API value
DELETE; the human confirmation text is not the API token.
Deletion is asynchronous: a 202 response is not completed deletion. Read
GET /v1/account/deletion for status. The disclosed minimal ledger retains the
workspace identifier, hashed owner subject, deletion timestamps and monthly
usage totals—not call content or API keys. This scope and status do not
constitute a legal erasure or recovery guarantee.
STT evaluation
This section describes local API behavior. Hosted evaluation remains unavailable and makes no evaluation-provider calls.
GET /v1/evaluation-policy
Read this before requesting external evaluation. The local response contains:
| Field | Meaning |
|---|---|
enabled | true only when VAANI_EVALUATIONS_ENABLED=1; off by default |
reason | Explanation of the current policy state |
challenger | { "provider": "ElevenLabs", "model": "scribe_v2" } |
semantic_risk | enabled: true, provider: "OpenAI", configured judge model, compatible_model_fallback: true |
disclosure | Current external-processing disclosure |
consent_version | Hash of the current policy disclosure; send this unchanged on confirmation |
cost_estimate | null — no run-cost estimate is supplied |
allowance | null — no local allowance is supplied or reserved |
The run sends the full recorded caller audio to ElevenLabs and transcript
comparison content to OpenAI for semantic-risk judging when configured. The
OpenAI judge may use a compatible-model fallback. semantic_risk.enabled is a
policy declaration, not proof that a provider key exists or a judge run succeeded.
See Capture and privacy.
GET /v1/sessions/{session_id}/stt-evaluation
Every measured production and challenger value for the call: streaming timing, per-turn transcripts, alignment, estimated WER, risk judgements and the cohort comparison.
POST /v1/sessions/{session_id}/challenger-evaluation
Queues a manual challenger run after the caller confirms the current disclosure.
Use the value returned by GET /v1/evaluation-policy, not the placeholder:
{ "model": "elevenlabs_scribe_v2", "consent_version": "<current policy value>" }| Outcome | Status |
|---|---|
| Queued | 202 |
| Evaluations disabled | 503 |
Missing or stale consent_version | 409 |
Unsupported model | 422 |
Supported models come from CHALLENGER_MODELS, currently one entry: ElevenLabs
Scribe v2. Requires ELEVENLABS_API_KEY; OpenAI judging requires
OPENAI_API_KEY. Provider keys alone do not opt in. In the UI, selecting a model
does not run a job; Run comparison requires confirmation.
GET /v1/sessions/{session_id}/challenger-evaluation
Polls job status. Jobs execute off the request thread on a two-worker in-process pool, with state in SQLite.
Jobs that were queued or in_progress when the process stopped are marked
failed on startup and are not resumed. A half-finished run is never
presented as complete. This is not a durable cloud queue. Re-queue only after
reviewing the disclosure; interrupted work may already have incurred charges.
Pricing
GET /v1/pricing · PUT /v1/pricing
Read or override the cost model used for switch decisions.
Published costs are list prices with provenance (estimated_list_price vs
configured), not invoices. They exist to compare options, not to reconcile a
bill. The local evaluation policy provides neither a quoted run cost nor an
allowance, and this path has no global spend-cap enforcement.
Pages and health
| Method | Path | Purpose |
|---|---|---|
GET | / | Call console |
GET | /stt-evaluation?session=<id> | STT evaluation workspace |
GET | /health | Health check |
Uploading manually
Useful for replaying an archived package:
SESSION=9f2c1e4a-3b7d-4c88-9a21-0e5f7b2d1c34
BASE=http://localhost:8000
curl -sS -X POST "$BASE/v1/sessions" \
-H 'content-type: application/json' \
-H "idempotency-key: $SESSION" \
--data-binary @manifest.json
for object in events.jsonl call.audio; do
curl -sS -X PUT "$BASE/v1/uploads/$SESSION/$object" --data-binary "@$object"
done
curl -sS -X POST "$BASE/v1/sessions/$SESSION/complete" \
-H 'content-type: application/json' \
-d "{\"objects\":{
\"events.jsonl\":{\"byte_size\":$(wc -c < events.jsonl),\"sha256\":\"$(shasum -a 256 events.jsonl | cut -d' ' -f1)\"},
\"call.audio\":{\"byte_size\":$(wc -c < call.audio),\"sha256\":\"$(shasum -a 256 call.audio | cut -d' ' -f1)\"}
}}"