Vaanieval
Reference

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

HeaderRequiredNotes
content-type: application/jsonyes
idempotency-keynoWhen present, must equal session_id or the request is a 400
authorization: Bearer <key>noAccepted, 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.

ValueClient must
["gzip"]Send Content-Encoding: gzip for large objects, or not — both work
[] or field absentSend 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 204 on the PUT — 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.

ConstraintValue
Allowed object_nameevents.jsonl, call.audio, caller.audio, agent.audio
Max body128 MiB (decompressed)
Success204 No Content
Unknown object name404
Over the cap413
Unsupported Content-Encoding415
Malformed gzip body400

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…" }
  }
}
OutcomeStatus
Accepted202
Byte size or digest mismatch400
A declared object was never uploaded400

The session's status becomes:

StatusMeaning
readyObjects verified and operations imported
partialObjects 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}

ParameterValues
trackcall | caller | agent | mixed
previewwav — wrap raw PCM in a WAV header for browser playback
from_ms, to_msCut 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 fieldMeaning
urlSigned media URL; fetch it without forwarding the API bearer token
session_idExternal SDK session ID used to request the link
media_idInternal UUID used by the signed media route; distinct from the SDK session ID
expires_atISO expiration timestamp
content_typeaudio/wav
byte_sizePrepared WAV size
trackcall
range_bytesMaximum 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:

FieldMeaning
enabledtrue only when VAANI_EVALUATIONS_ENABLED=1; off by default
reasonExplanation of the current policy state
challenger{ "provider": "ElevenLabs", "model": "scribe_v2" }
semantic_riskenabled: true, provider: "OpenAI", configured judge model, compatible_model_fallback: true
disclosureCurrent external-processing disclosure
consent_versionHash of the current policy disclosure; send this unchanged on confirmation
cost_estimatenull — no run-cost estimate is supplied
allowancenull — 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>" }
OutcomeStatus
Queued202
Evaluations disabled503
Missing or stale consent_version409
Unsupported model422

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

MethodPathPurpose
GET/Call console
GET/stt-evaluation?session=<id>STT evaluation workspace
GET/healthHealth 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)\"}
      }}"

Next

On this page