# VoiceLab API reference API base URL: https://api.voicelab.uz This file contains all 66 documented endpoint contracts and examples. For a smaller context, use the per-page Markdown links in [llms.txt](https://docs.voicelab.uz/llms.txt). Read [AGENTS.md](https://docs.voicelab.uz/AGENTS.md) for credential handling and retries. ## Authentication Product REST routes under /v1 use Authorization: Bearer . Account key management and analytics require a user access JWT. Browser WebSockets use service-bound tickets from POST /v1/ticket; server clients can use xvl-api-key instead. Never send both ticket and key. Keep API credentials out of signed storage downloads, URLs, prompts and logs. ## SDKs and tools - [Python and TypeScript SDKs](https://docs.voicelab.uz/libraries/sdk) - [MCP tools](https://docs.voicelab.uz/mcp/tools) SDK and MCP helpers cover selected endpoints. Use HTTP for other operations. ## Documentation index - [VoiceLab API](https://docs.voicelab.uz/markdown/api/index.md) - [API usage analytics and request logs](https://docs.voicelab.uz/markdown/api/analytics.md) - [Authentication and API keys](https://docs.voicelab.uz/markdown/api/authentication.md) - [Errors, limits, and security](https://docs.voicelab.uz/markdown/api/errors.md) - [LLM files](https://docs.voicelab.uz/markdown/api/files.md) - [LLM API](https://docs.voicelab.uz/markdown/api/llm.md) - [Meeting AI](https://docs.voicelab.uz/markdown/api/meetings.md) - [Realtime STT over WebSocket](https://docs.voicelab.uz/markdown/api/realtime-stt.md) - [Speech-to-Text (STT)](https://docs.voicelab.uz/markdown/api/stt.md) - [Text to Speech](https://docs.voicelab.uz/markdown/api/tts.md) - [Voice Cloning](https://docs.voicelab.uz/markdown/api/voice-cloning.md) - [Voice Isolator](https://docs.voicelab.uz/markdown/api/voice-isolator.md) - [Voices and TTS capabilities](https://docs.voicelab.uz/markdown/api/voices.md) - [Webhooks](https://docs.voicelab.uz/markdown/api/webhooks.md) ## Endpoint inventory Replace path placeholders with returned IDs. Read the linked contract for required fields and response handling. | Method | URL | Credential | Reference | | --- | --- | --- | --- | | GET | `https://api.voicelab.uz/api/v1/account/analytics` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/analytics) | | GET | `https://api.voicelab.uz/api/v1/account/request-logs` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/analytics) | | GET | `https://api.voicelab.uz/api/v1/account/api-key-permissions` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | GET | `https://api.voicelab.uz/api/v1/account/api-key-scopes` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | POST | `https://api.voicelab.uz/api/v1/account/api-keys` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | GET | `https://api.voicelab.uz/api/v1/account/api-keys?limit=50&cursor=` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | PATCH | `https://api.voicelab.uz/api/v1/account/api-keys/{key_id}` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | POST | `https://api.voicelab.uz/api/v1/account/api-keys/{key_id}/revoke` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | DELETE | `https://api.voicelab.uz/api/v1/account/api-keys/{key_id}` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/authentication) | | POST | `https://api.voicelab.uz/v1/files` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/files) | | GET | `https://api.voicelab.uz/v1/files` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/files) | | GET | `https://api.voicelab.uz/v1/files/{file_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/files) | | DELETE | `https://api.voicelab.uz/v1/files/{file_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/files) | | GET | `https://api.voicelab.uz/v1/models` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/llm) | | POST | `https://api.voicelab.uz/v1/chat/completions` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/llm) | | GET | `https://api.voicelab.uz/v1/llm/requests/{id}` | `Bearer ` | [Contract](https://docs.voicelab.uz/api/llm) | | GET | `https://api.voicelab.uz/v1/meetings/capabilities` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | POST | `https://api.voicelab.uz/v1/meetings` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | GET | `https://api.voicelab.uz/v1/meetings` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | GET | `https://api.voicelab.uz/v1/meetings/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | PATCH | `https://api.voicelab.uz/v1/meetings/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | DELETE | `https://api.voicelab.uz/v1/meetings/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | POST | `https://api.voicelab.uz/v1/meetings/{id}/notes/generations` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/meetings) | | POST | `https://api.voicelab.uz/v1/ticket` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/realtime-stt) | | WSS | `wss://api.voicelab.uz/v1/stt/stream?ticket=` | `See reference` | [Contract](https://docs.voicelab.uz/api/realtime-stt) | | GET | `https://api.voicelab.uz/v1/stt/preflight` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | POST | `https://api.voicelab.uz/v1/stt` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | GET | `https://api.voicelab.uz/v1/stt/transcriptions?limit=10&cursor=` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | GET | `https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | PATCH | `https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | PUT | `https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/editor` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | DELETE | `https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | GET | `https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/export?format=json` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/stt) | | POST | `https://api.voicelab.uz/v1/tts` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | POST | `https://api.voicelab.uz/v1/tts/async` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | GET | `https://api.voicelab.uz/v1/tts/languages` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | GET | `https://api.voicelab.uz/v1/tts/generations?limit=30&cursor=` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | GET | `https://api.voicelab.uz/v1/tts/generations/{generation_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | DELETE | `https://api.voicelab.uz/v1/tts/generations/{generation_id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | WSS | `wss://api.voicelab.uz/v1/tts/stream` | `xvl-api-key: vlk_...` | [Contract](https://docs.voicelab.uz/api/tts) | | GET | `https://api.voicelab.uz/v1/voice-enrollments/capabilities` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | POST | `https://api.voicelab.uz/v1/voice-enrollments` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | POST | `https://api.voicelab.uz/v1/voice-enrollments/{id}/transcript` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | POST | `https://api.voicelab.uz/v1/voice-enrollments/{id}/challenge` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | POST | `https://api.voicelab.uz/v1/voice-enrollments/{id}/verify` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | GET | `https://api.voicelab.uz/v1/voice-enrollments/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | GET | `https://api.voicelab.uz/v1/voices/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | PATCH | `https://api.voicelab.uz/v1/voices/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | DELETE | `https://api.voicelab.uz/v1/voices/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-cloning) | | POST | `https://api.voicelab.uz/v1/voice-isolations` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | POST | `https://api.voicelab.uz/v1/voice-isolations/batch` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | GET | `https://api.voicelab.uz/v1/voice-isolations` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | GET | `https://api.voicelab.uz/v1/voice-isolations/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | DELETE | `https://api.voicelab.uz/v1/voice-isolations/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | POST | `https://api.voicelab.uz/v1/voice-isolations/{id}/exports` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | GET | `https://api.voicelab.uz/v1/voice-isolations/{id}/exports/{format}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voice-isolator) | | GET | `https://api.voicelab.uz/v1/voices?language=en` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/voices) | | POST | `https://api.voicelab.uz/v1/webhooks` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | GET | `https://api.voicelab.uz/v1/webhooks` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | GET | `https://api.voicelab.uz/v1/webhooks/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | PATCH | `https://api.voicelab.uz/v1/webhooks/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | DELETE | `https://api.voicelab.uz/v1/webhooks/{id}` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | POST | `https://api.voicelab.uz/v1/webhooks/{id}/secret/rotate` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | POST | `https://api.voicelab.uz/v1/webhooks/{id}/test` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | GET | `https://api.voicelab.uz/v1/webhooks/{id}/deliveries` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | | POST | `https://api.voicelab.uz/v1/webhooks/{id}/deliveries/{delivery_id}/retry` | `Bearer vlk_...` | [Contract](https://docs.voicelab.uz/api/webhooks) | --- Source: https://docs.voicelab.uz/api/ # VoiceLab API Generate text and speech, transcribe or clean up audio, clone your voice, and stream realtime audio. Keep your API key on your server. Product endpoints use API keys; account management uses a signed-in user's JWT. ## Latest STT update **October 9, 2026:** STT accepts 33 languages plus `language=auto` detection. Existing routes and request formats are unchanged. See [language codes and rules](https://docs.voicelab.uz/api/stt#languages). ## Base URL ```text https://api.voicelab.uz ``` The public developer API uses the `/v1` prefix. For example: ```text POST https://api.voicelab.uz/v1/tts ``` ## Choose the right credential | Credential | Used for | Example | | --- | --- | --- | | User access JWT | VoiceLab website and API-key management | `Authorization: Bearer eyJ...` | | Developer API key | Your application calling LLM, TTS, STT, Voice Isolator, Meeting AI, voices, and realtime ticket endpoints | `Authorization: Bearer vlk_...` | | Realtime ticket | One short-lived WebSocket connection | `?ticket=eyJ...` | Do not send a user JWT to `/v1/tts`, `/v1/stt`, or `/v1/voices`. Do not send a long-lived API key in a WebSocket URL. The only value allowed in that URL is a short-lived ticket returned by `POST /v1/ticket`. Voice Isolator developer requests use an API key at `/v1/voice-isolations`. The dashboard routes at `/api/v1/voice-isolations` require a user JWT. Meeting AI developer requests use a personal workspace API key at `/v1/meetings`. The dashboard routes at `/api/v1/meetings` require a user JWT. ## Endpoints Create and manage keys with the signed-in dashboard: | Method | Endpoint | Credential | | --- | --- | --- | | `GET` | `/api/v1/account/api-key-permissions` | User JWT | | `GET` | `/api/v1/account/api-key-scopes` | User JWT | | `POST` | `/api/v1/account/api-keys` | User JWT | | `GET` | `/api/v1/account/api-keys` | User JWT | | `PATCH` | `/api/v1/account/api-keys/{key_id}` | User JWT | | `POST` | `/api/v1/account/api-keys/{key_id}/revoke` | User JWT | | `DELETE` | `/api/v1/account/api-keys/{key_id}` | User JWT | Use the generated key for application services: | Feature | Endpoints | Permission | | --- | --- | --- | | LLM models | `GET /v1/models` | `llm:read` | | Chat completion | `POST /v1/chat/completions` | `llm:write` | | LLM request status | `GET /v1/llm/requests/{id}` | `llm:read` | | LLM files | `POST`, `GET /v1/files`, `GET`, `DELETE /v1/files/{file_id}` | `llm:read` for reads, `llm:write` for upload/delete | | TTS capabilities | `GET /v1/tts/languages` | TTS read | | Voices | `GET /v1/voices?language={code}` | Voices read | | Clone discovery/state | `GET /v1/voice-enrollments/capabilities`, `GET /v1/voice-enrollments/{id}` | `voices:read` | | Verified voice enrollment | `POST /v1/voice-enrollments`, then `/{id}/transcript`, `/{id}/challenge`, `/{id}/verify` | `voices:write` | | Clone metadata | `GET /v1/voices/{id}` | `voices:read` | | Edit/delete own clone | `PATCH /v1/voices/{id}`, `DELETE /v1/voices/{id}` | `voices:write` | | TTS generation | `POST /v1/tts` | TTS write | | Background TTS | `POST /v1/tts/async` | TTS write; TTS read for polling | | TTS history | `GET /v1/tts/generations`, `GET /v1/tts/generations/{id}` | TTS read | | Delete TTS audio | `DELETE /v1/tts/generations/{id}` | TTS write | | STT transcription | `POST /v1/stt` | STT write | | STT credit preflight | `GET /v1/stt/preflight` | STT write | | STT history and files | `GET /v1/stt/transcriptions...` | STT read | | Edit or delete STT | `PATCH`, `PUT`, `DELETE /v1/stt/transcriptions/{id}` | STT write | | Voice isolation | `POST /v1/voice-isolations`, `POST /v1/voice-isolations/batch` | `audio_isolation:write` | | Isolation history and results | `GET /v1/voice-isolations`, `GET /v1/voice-isolations/{id}` | `audio_isolation:read` | | Hide isolation | `DELETE /v1/voice-isolations/{id}` | `audio_isolation:write` | | Create isolation export | `POST /v1/voice-isolations/{id}/exports` | `audio_isolation:write` | | Get isolation export | `GET /v1/voice-isolations/{id}/exports/{format}` | `audio_isolation:read` | | Meeting AI capabilities | `GET /v1/meetings/capabilities` | `meetings:read` | | Create meeting | `POST /v1/meetings` | `meetings:write` | | Meeting history and results | `GET /v1/meetings`, `GET /v1/meetings/{id}` | `meetings:read` | | Rename or delete meeting | `PATCH`, `DELETE /v1/meetings/{id}` | `meetings:write` | | Regenerate meeting notes | `POST /v1/meetings/{id}/notes/generations` | `meetings:write` | | Realtime TTS | `POST /v1/ticket`, then `GET /v1/tts/stream` | TTS realtime | | Realtime STT | `POST /v1/ticket`, then `GET /v1/stt/stream` | STT realtime | | Webhooks and deliveries | `/v1/webhooks...`; see [complete reference](https://docs.voicelab.uz/api/webhooks) | `webhooks:write`, including reads | Grant **Voice Isolator → Access** for the complete upload, polling, and export workflow. This enables both isolation scopes. Existing restricted keys need this permission added explicitly. See [Voice Isolator](https://docs.voicelab.uz/api/voice-isolator). Grant **Meeting AI → Access** to upload recordings, poll results, and manage meetings. This enables both meeting scopes. Only personal workspace keys can use Meeting AI. See [Meeting AI](https://docs.voicelab.uz/api/meetings). Grant `llm: access` for model discovery, completions, and request status. Developer LLM requests use API keys and token-based credits. Ask's `/api/v1/llm/*` and `/api/v1/chats/*` routes remain JWT-only and uncharged. See [LLM API](https://docs.voicelab.uz/api/llm). Grant **Voices → Access** for the verified [Voice Cloning](https://docs.voicelab.uz/api/voice-cloning) workflow and owner-only editing/deletion. All active paid plans qualify; consent, a fresh live recording and both verification checks are required. Analytics and request logs are dashboard views. They use a user JWT and show only traffic authenticated with developer API keys; website JWT traffic is not included. See [Analytics and logs](https://docs.voicelab.uz/api/analytics). ## Quick start ### 1. Create an API key Use a user access JWT from the VoiceLab dashboard to create a key. The secret is returned only in this response. ```bash curl -X POST 'https://api.voicelab.uz/api/v1/account/api-keys' \ -H 'Authorization: Bearer USER_ACCESS_JWT' \ -H 'Content-Type: application/json' \ -d '{ "name": "My production integration", "permissions": { "text_to_speech": "access", "speech_to_text": "access", "voices": "read" }, "expires_at": null, "restrict_key": true, "auto_disable_if_leaked": true }' ``` Save `data.secret` immediately. It cannot be recovered later. ### 2. Store the key on your server ```bash export VOICELAB_API_KEY='vlk_replace_with_the_secret_from_creation' ``` For production, use your deployment secret manager instead of a shell file. ### 3. Generate speech ```bash tts_http_status=$(curl -fS -w '%{http_code}' 'https://api.voicelab.uz/v1/tts' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: hello-world-001' \ -d '{ "text": "Hello from VoiceLab.", "language": "en", "voice_id": "voice_01J9NEUTRAL0000000000000001", "speed": 1 }' \ -o tts-response) case "$tts_http_status" in 200) mv tts-response hello.wav ;; 202) cat tts-response ;; # Poll the returned status_url before downloading audio *) cat tts-response >&2; exit 1 ;; esac ``` Output is mono 24 kHz WAV. Fetch available models and languages from `/v1/tts/languages`. If generation is pending, the response is `202` JSON instead of audio. See [TTS polling](https://docs.voicelab.uz/api/tts#generate-speech-in-the-background). ### 4. Transcribe audio ```bash curl -fS 'https://api.voicelab.uz/v1/stt' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ -F 'audio=@meeting.wav;type=audio/wav' \ -F 'language=en' \ -F 'include_speakers=false' ``` See [Speech to Text](https://docs.voicelab.uz/api/stt) for upload limits and polling. ## Common response rules - JSON responses use `Content-Type: application/json`. - Resource timestamps are RFC 3339 UTC strings, for example `2026-08-16T12:30:00Z`. LLM completion `created` and file `created_at`/`expires_at` use Unix seconds; model `created` is `0`. - IDs, cursors, tickets, and URLs are opaque. Do not parse or construct them. - Standard resource responses include `request_id`. LLM success bodies use their own shapes; completion headers include `X-LLM-Request-ID`. LLM streaming uses SSE. See [LLM response formats](https://docs.voicelab.uz/api/llm#json-response). - TTS returns WAV audio or pending-job JSON. STT exports return files. Voice Isolator returns JSON with signed download URLs. - Private audio URLs are short-lived signed URLs. Do not store them as permanent links. ## Error response Standard JSON errors use this shape: ```json { "message": "This API key is not allowed to perform this operation.", "error": { "code": "insufficient_scope" }, "request_id": "req_01J..." } ``` [LLM handler errors](https://docs.voicelab.uz/api/llm#errors) put the message in `error.message` and include `error.type`. Shared authentication errors can still use the standard shape shown above. Handle both envelopes when integrating LLM endpoints. Some validation errors also contain `error.fields`: ```json { "message": "Check the highlighted fields.", "error": { "code": "validation_error", "fields": { "language": "Choose a supported language." } }, "request_id": "req_01J..." } ``` Always log `request_id` in your application. Never log an API key, realtime ticket, audio bytes, or signed audio URL. ## Documents - [Authentication and API keys](https://docs.voicelab.uz/api/authentication) - [LLM](https://docs.voicelab.uz/api/llm) - [LLM files](https://docs.voicelab.uz/api/files) - [Voices and capabilities](https://docs.voicelab.uz/api/voices) - [Voice Cloning](https://docs.voicelab.uz/api/voice-cloning) - [Webhooks](https://docs.voicelab.uz/api/webhooks) - [Text to Speech](https://docs.voicelab.uz/api/tts) - [Speech to Text](https://docs.voicelab.uz/api/stt) - [Voice Isolator](https://docs.voicelab.uz/api/voice-isolator) - [Meeting AI](https://docs.voicelab.uz/api/meetings) - [Realtime TTS](https://docs.voicelab.uz/api/tts#realtime-speech) - [Realtime STT](https://docs.voicelab.uz/api/realtime-stt) - [Analytics and logs](https://docs.voicelab.uz/api/analytics) - [Errors and limits](https://docs.voicelab.uz/api/errors) --- Source: https://docs.voicelab.uz/api/analytics # API usage analytics and request logs Read API usage and request logs with a signed-in user's access JWT. ```http Authorization: Bearer ``` These endpoints show developer API-key traffic only. Website activity using a user JWT is excluded. Developer API keys cannot authenticate these requests. ## Analytics **GET https://api.voicelab.uz/api/v1/account/analytics** Return aggregate VoiceLab API usage for the signed-in account. Authentication: `Bearer `. Query parameters: | Parameter | Required | Rules | | --- | --- | --- | | `from` | no | RFC3339 timestamp or `YYYY-MM-DD`; defaults to seven days ago | | `to` | no | RFC3339 timestamp or `YYYY-MM-DD`; defaults to now; end date is exclusive | | `granularity` | no | `day` (default) or `hour`; hourly windows are at most 31 days | The total window cannot be longer than 90 days. Times are interpreted and returned in UTC. Example: ```bash curl -fS 'https://api.voicelab.uz/api/v1/account/analytics?from=2026-08-01&to=2026-08-16&granularity=day' \ -H "Authorization: Bearer $USER_ACCESS_JWT" ``` Response shape: ```json { "period": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-16T00:00:00Z", "timezone": "UTC", "granularity": "day" }, "summary": { "total_requests": 113, "successful_requests": 110, "failed_requests": 3, "success_rate": 0.973, "average_latency_ms": 289, "total_characters": 517, "total_duration_ms": 60000, "total_credits": 517, "billable_requests": 110, "total_cost_micros": 85000, "currency": "USD" }, "series": [], "status_codes": [ {"key":"200","value":110}, {"key":"400","value":3} ], "top_routes": [ {"key":"/v1/tts","value":80} ], "product_usage": [], "request_id": "req_01J..." } ``` Allow unknown fields and new product entries in the analytics arrays. ## Voice Isolator usage [Voice Isolator](https://docs.voicelab.uz/api/voice-isolator) reports usage as `voice_enhancer`. Successful processing records usage against the submitting key and settles credits in one transaction. Failed jobs, polling and exports add no charges. A `202` upload log means the job was accepted. Billing appears when processing completes. ## Meeting AI usage [Meeting AI](https://docs.voicelab.uz/api/meetings) reports usage as `meeting_ai`. Successful processing records usage against the submitting key; a notes regeneration records a second usage event. Failed or deleted meetings and polling add no charges. A `202` upload log means the meeting was accepted. ## LLM usage [LLM completions](https://docs.voicelab.uz/api/llm) record settled usage with product `llm` and operation `chat.completions`. Token details and exact fractional credit units are available through the [LLM request-status endpoint](https://docs.voicelab.uz/api/llm#get-request-status). A failed generation can still be billed when usage is known. An HTTP `200` for an SSE stream does not prove successful completion or settlement. Retain `X-LLM-Request-ID` and inspect its status if the stream ends without `[DONE]`. Developer prompts, completions, reasoning, and tool contents are not saved in chat tables or request logs. Ask activity authenticated with a JWT remains separate from developer LLM usage. ## Request logs **GET https://api.voicelab.uz/api/v1/account/request-logs** List individual developer API requests for troubleshooting. Authentication: `Bearer `. Supported filters: ```text from=2026-08-01 to=2026-08-16 limit=50 cursor= status=500 method=POST route=/v1/tts api_key_id=key_01J... ``` `status` and `code` are accepted as aliases. `limit` defaults to 50 and must be 1–100. Use `next_cursor` unchanged for the next request. Example response: ```json { "items": [ { "request_id": "req_01J...", "timestamp": "2026-08-16T12:00:00Z", "method": "POST", "route": "/v1/tts", "status_code": 200, "latency_ms": 289, "request_bytes": 120, "response_bytes": 184320, "api_key_id": "key_01J...", "api_key_prefix": "vlk_", "usage": { "product": "tts", "operation": "generate", "characters": 24, "duration_ms": 1180, "credits": 24, "billable": true } } ], "next_cursor": null, "period": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-16T00:00:00Z", "timezone": "UTC", "granularity": "day" }, "request_id": "req_01J..." } ``` `usage` is optional. It can include `ttfb_ms`, `cost_micros`, and `currency` in addition to the fields shown above. The raw API key, authorization header, transcript, text body, audio bytes, and signed storage URL are never exposed in request logs. ## Analytics errors | Status | Code | Meaning | | ---: | --- | --- | | `400` | `invalid_analytics_window`, `invalid_pagination`, `invalid_cursor`, `invalid_filter` | Correct the query parameters | | `401` | `session_expired` | Refresh the dashboard user session | | `500` | `internal_error` | Retry and keep `request_id` for support | | `503` | `analytics_unavailable` | Analytics storage is temporarily unavailable | --- Source: https://docs.voicelab.uz/api/authentication # Authentication and API keys Create and manage API keys, set permissions, and issue short-lived WebSocket tickets. ## API keys are server credentials Every developer API request uses this header: ```http Authorization: Bearer vlk_ ``` API keys are intended for backend jobs, CLI tools, and private services. Do not expose them in a browser bundle, mobile app, public Git repository, URL, or client-side local storage. If a key is exposed, revoke it immediately and create a replacement. The backend stores only a hash of the secret. The plaintext secret is returned once, when the key is created. ## Permission model Use `GET /api/v1/account/api-key-permissions` to render the dashboard's key editor. Only entries with `implemented: true` should be offered for selection: | Permission | Levels | Gives access to | | --- | --- | --- | | `text_to_speech` | `none`, `read`, `write`, `access` | TTS generation and/or TTS history | | `tts_realtime` | `none`, `access` | Realtime TTS tickets and WebSocket | | `speech_to_text` | `none`, `read`, `write`, `access` | STT transcription and/or STT history | | `stt_realtime` | `none`, `access` | Realtime STT tickets and WebSocket | | `audio_isolation` | `none`, `read`, `write`, `access` | Voice Isolator jobs, retained audio, and exports | | `meetings` | `none`, `read`, `write`, `access` | Meeting AI uploads, transcripts, notes, and meeting management | | `llm` | `none`, `read`, `write`, `access` | Model discovery, chat completions, files, and LLM request status | | `voices` | `none`, `read`, `write`, `access` | Voice catalog, verified cloning, and owner-only clone management | | `webhooks` | `none`, `access` | Create, read and manage callback subscriptions and deliveries | Meaning of levels: - `none`: no access; omit it from the saved map or send it as `none`. - `read`: list, inspect, or download existing resources. For Voice Isolator, requesting a new export conversion requires write access. - `write`: create, edit, or delete resources. - `access`: all operations for that permission, including read and write where supported. Entries with `implemented: false` are unavailable. Keep them disabled in your UI. The current legacy scope equivalents are: | Scope | Equivalent permission | | --- | --- | | `tts:read` | `text_to_speech: read` | | `tts:write` | `text_to_speech: write` | | `tts:realtime` | `tts_realtime: access` | | `stt:read` | `speech_to_text: read` | | `stt:write` | `speech_to_text: write` | | `stt:realtime` | `stt_realtime: access` | | `llm:read` | `llm: read` | | `llm:write` | `llm: write` | | `voices:read` | `voices: read` | | `voices:write` | `voices: write` | | `audio_isolation:read` | `audio_isolation: read` | | `audio_isolation:write` | `audio_isolation: write` | | `meetings:read` | `meetings: read` | | `meetings:write` | `meetings: write` | | `webhooks:write` | `webhooks: access`, including reads | Use `permissions` for new integrations. `scopes` remains available for backwards compatibility, but a request must not contain both. ## LLM access For [LLM completions](https://docs.voicelab.uz/api/llm), grant `{"llm":"access"}` in the permission map. Read access lists models and retrieves account-scoped request status. Write access generates completions and uploads/deletes [files](https://docs.voicelab.uz/api/files). Read access also lists and inspects those files. A write-only key cannot read model prices or inspect request usage; grant both for the complete workflow. Existing unrestricted keys work. Restricted keys need an explicit LLM grant. When editing a key, preserve its other required permissions: `permissions` replaces the complete map. The compatibility scopes are `llm:read` and `llm:write`. Developer LLM endpoints require a server-side API key and spend credits. Platform JWTs authorize Ask's `/api/v1/llm/*` and `/api/v1/chats/*` routes, which remain uncharged; they cannot authorize developer completions. ## Voice Cloning access Grant `{"voices":"access"}` for [Voice Cloning](https://docs.voicelab.uz/api/voice-cloning): upload a reference, review its transcript, issue and record a live challenge, then use or manage the verified private clone. `read` permits catalog/session/metadata reads; `write` permits enrollment, editing and deletion. Separate `tts:write` access is required to synthesize speech. Active paid plans qualify for enrollment; consent and both content/speaker checks remain mandatory for API keys. For restricted keys, add write permission and preserve the rest of the map. Unrestricted keys can use all implemented endpoints. See the cloning reference for ownership, rate limits and retries. ## Webhook access Grant `{"webhooks":"access"}` for [Webhooks](https://docs.voicelab.uz/api/webhooks). All subscription and delivery operations, including GET requests, require `webhooks:write`. Grant the relevant product read permission separately to retrieve a generation or transcription mentioned in an event. The webhook signing secret verifies callbacks and cannot authenticate API requests. ## Voice Isolator access For the complete [Voice Isolator workflow](https://docs.voicelab.uz/api/voice-isolator), grant `audio_isolation: access` in the permission map. Read access lists jobs, retrieves their status, and downloads available audio or exports. Write access submits files or batches, requests new export conversions, and hides jobs. A write-only key cannot poll results. Restricted keys need an explicit grant; existing keys do not gain this permission automatically. Unrestricted keys can use all implemented endpoints. Check the permission catalog for support before offering Voice Isolator in a key editor. The developer routes use `/v1/voice-isolations`; dashboard `/api/v1/voice-isolations` routes require a user JWT. Revoking a key blocks new access but does not cancel accepted jobs. Revoke keys referenced by retained jobs instead of permanently deleting them. ## Meeting AI access For the complete [Meeting AI workflow](https://docs.voicelab.uz/api/meetings), grant `meetings: access` in the permission map. Read access checks capabilities, lists meetings, and retrieves status, transcripts and notes. Write access uploads recordings, renames and deletes meetings, and regenerates failed notes. A write-only key cannot poll results. Meeting AI is available to personal workspace keys only; team workspace keys return `403 workspace_product_unavailable`. Restricted keys need an explicit grant. The developer routes use `/v1/meetings`; dashboard `/api/v1/meetings` routes require a user JWT. Revoking a key does not cancel accepted meetings. ## List permission and scope catalogs These endpoints require a user access JWT, not a developer API key: **GET https://api.voicelab.uz/api/v1/account/api-key-permissions** List the current permission catalog for the API-key editor. Authentication: `Bearer `. **GET https://api.voicelab.uz/api/v1/account/api-key-scopes** List legacy API-key scopes for backwards compatibility. Authentication: `Bearer `. Permission response: ```json { "data": { "permissions": [ { "id": "text_to_speech", "name": "Text to speech", "description": "Read TTS history and generate speech from text.", "levels": ["none", "read", "write", "access"], "implemented": true } ] }, "request_id": "req_01J..." } ``` Scope response: ```json { "scopes": [ { "id": "tts:write", "name": "TTS write", "description": "Create and delete TTS generations." } ], "request_id": "req_01J..." } ``` ## Create a key **POST https://api.voicelab.uz/api/v1/account/api-keys** Create a scoped developer API key with a user access JWT. Authentication: `Bearer `. Content-Type: `application/json`. Example using permissions: ```json { "name": "Production speech service", "permissions": { "text_to_speech": "access", "speech_to_text": "access", "tts_realtime": "access", "voices": "read" }, "expires_at": null, "restrict_key": true, "credit_limit": null, "allowed_ips": ["203.0.113.10"], "auto_disable_if_leaked": true } ``` Fields: | Field | Required | Rules | | --- | --- | --- | | `name` | yes | 1–80 characters; control characters are rejected | | `permissions` | no* | Map of supported permission IDs to levels; `{}` clears all access | | `scopes` | no* | Legacy array; use this only when `permissions` is absent | | `expires_at` | no | Future RFC3339 timestamp or `null` for no expiry | | `restrict_key` | no | Defaults to `true` | | `credit_limit` | no | Non-negative integer or `null` | | `allowed_ips` | no | Up to 100 IPv4, IPv6, or CIDR entries | | `auto_disable_if_leaked` | no | Defaults to `true` | \* Keys without product permissions cannot call product endpoints. Legacy request example: ```json { "name": "Legacy TTS key", "scopes": ["tts:read", "tts:write", "voices:read"] } ``` Do not send both `permissions` and `scopes`. `201 Created`: ```json { "data": { "key": { "id": "key_01J...", "name": "Production speech service", "masked_key": "vlk_••••••••9x2a", "prefix": "vlk_", "permissions": { "text_to_speech": "access", "speech_to_text": "access", "tts_realtime": "access", "voices": "read" }, "scopes": ["tts:read", "tts:write", "tts:realtime", "stt:read", "stt:write", "voices:read"], "created_at": "2026-08-16T12:00:00Z", "last_used_at": null, "expires_at": null, "enabled": true, "revoked_at": null, "restrict_key": true, "credit_limit": null, "allowed_ips": ["203.0.113.10"], "auto_disable_if_leaked": true, "leaked_at": null }, "secret": "vlk_" }, "request_id": "req_01J..." } ``` Show the secret once and ask the user to copy it. It is never returned by list, update, or any product endpoint. ## List keys **GET https://api.voicelab.uz/api/v1/account/api-keys?limit=50&cursor=** List API keys with cursor pagination. Authentication: `Bearer `. `limit` defaults to 50 and must be 1–100. The response is: ```json { "data": { "keys": [ { "id": "key_01J...", "name": "Production speech service", "masked_key": "vlk_••••••••9x2a", "enabled": true, "created_at": "2026-08-16T12:00:00Z", "expires_at": null, "revoked_at": null, "permissions": {"text_to_speech": "access"} } ], "next_cursor": null }, "request_id": "req_01J..." } ``` ## Edit a key **PATCH https://api.voicelab.uz/api/v1/account/api-keys/{key_id}** Update an API key's name, permissions, restrictions, or lifecycle settings. Authentication: `Bearer `. Content-Type: `application/json`. The body is a partial update: ```json { "name": "Production speech service v2", "enabled": true, "expires_at": "2027-08-16T00:00:00Z", "permissions": { "text_to_speech": "access", "tts_realtime": "access", "voices": "read" } } ``` - Omitted fields stay unchanged. - `expires_at: null` removes expiry. Omit it to keep the current expiry. - `permissions` replaces the complete permission map. Omitted permissions are removed; `{}` removes all permissions. - Use either `permissions` or legacy `scopes`, never both. - The secret is never returned by `PATCH`. - The update is atomic. `200 OK` returns `{ "data": { "key": { ... } }, "request_id": "..." }`. ## Revoke versus delete Revoke a key to stop access while keeping its audit history: **POST https://api.voicelab.uz/api/v1/account/api-keys/{key_id}/revoke** Immediately stop an API key from authenticating. Authentication: `Bearer `. `204 No Content` means the key stops authenticating immediately. Its metadata and audit history remain available. Delete is permanent: **DELETE https://api.voicelab.uz/api/v1/account/api-keys/{key_id}** Permanently delete an API-key record. Authentication: `Bearer `. `204 No Content` confirms deletion. If existing work still references the key, the API returns `409 api_key_in_use`; revoke it instead. ## Key-management errors | Status | Code | Meaning | | ---: | --- | --- | | `400` | `invalid_json`, `validation_error`, `invalid_pagination` | Request or fields are invalid | | `401` | `session_expired` | User JWT is missing, invalid, or expired | | `403` | `origin_forbidden` | Browser origin is not allowed for this dashboard action | | `404` | `not_found` | Key is not owned by this account | | `409` | `api_key_in_use` | Permanent deletion is blocked by existing work | | `500` | `internal_error` | Unexpected server error | | `503` | `auth_unavailable`, `service_unavailable` | Required service is unavailable | --- Source: https://docs.voicelab.uz/api/errors # Errors, limits, and security Read error codes, handle rate limits, and retry without duplicating paid work. ## Error envelope Standard JSON errors follow this shape: ```json { "message": "A user-safe explanation.", "error": { "code": "stable_machine_code", "fields": { "language": "Choose a supported language." } }, "request_id": "req_01J..." } ``` Use `error.code` in program logic. `error.fields` is optional. Message wording can change; do not parse it for error handling. LLM handler errors have a different message location: ```json { "error": { "message": "LLM usage needs reconciliation. Contact support before making another request.", "type": "request_error", "code": "llm_usage_pending" }, "request_id": "req_example" } ``` Shared authentication errors can still use the standard envelope. LLM clients must handle both. After SSE starts, an error can arrive inside a data event while HTTP status remains `200`. See [LLM errors](https://docs.voicelab.uz/api/llm#errors). ## Common HTTP statuses | Status | Meaning | What to do | | ---: | --- | --- | | `200` | Successful response or stream opened | Read the response; LLM SSE must finish with `[DONE]` | | `201` | Resource or realtime ticket created | Store the returned ID/ticket | | `202` | Audio processing or webhook delivery accepted for background processing | Poll the returned resource, export or delivery | | `204` | Successful request with no body | Do not parse JSON; follow the endpoint's update or deletion rules | | `400` | Malformed JSON, multipart, cursor, or idempotency key | Correct the request | | `401` | Missing, invalid, expired, disabled, or revoked credential | Replace or refresh the credential | | `402` | Insufficient credits or API-key credit limit | Check the balance and the key's configured limit | | `403` | Scope, IP, or origin policy denied the request | Change the key policy; do not retry unchanged | | `404` | Resource or feature is not available | Refresh IDs/catalog; do not guess a route | | `409` | Idempotency, edit, or resource state conflict | Follow the endpoint-specific recovery | | `413` | Body or audio is too large | Upload a smaller payload | | `415` | Media type is unsupported | Use a supported audio format | | `422` | Unsupported fields, model settings, or audio | Read `error.code` and any `error.fields`; correct the request | | `429` | Rate or capacity limit | Wait for `Retry-After` when present | | `500` | Unexpected server error | Retry only with the same idempotency key | | `503` | Dependency outage or unresolved LLM usage | Inspect the code; `llm_model_temporarily_unavailable` / legacy `llm_usage_pending` require support, otherwise follow endpoint retry rules | Do not blindly retry `POST` requests without an idempotency key. For TTS, STT, and Voice Isolator uploads, retries after a timeout should reuse the same key and identical input, including processing options. For LLM completions, identical retries with the same key return `409 request_already_submitted` and `X-LLM-Request-ID`. No response is replayed and no new generation starts. Read the original request's status. A changed body with the same key returns `409 idempotency_conflict`. See [LLM retry rules](https://docs.voicelab.uz/api/llm#idempotency-and-retries). ## Rate limits and backoff LLM and current REST TTS/STT admission allows 70 requests per second, with a burst of 70, per account on each API replica. Product reads and submissions share their product's account budget. Multiple keys do not multiply it. This is an HTTP admission limit, not 70 completed model generations per second or a cluster-wide quota. | Capacity | Bound | | --- | --- | | Running developer LLM requests | 70/account across keys, models and API replicas; 256 total/replica | | Unsettled LLM usage | 8/account/upstream model, counted separately from healthy running requests | | LLM file uploads | 4/account, 32/replica | | Queued/processing TTS or STT jobs | 70/account/product | | Pending STT upload storage | 2 GiB/account | `X-RateLimit-Policy` distinguishes `rps`, `concurrency` and `queue` limits. LLM model discovery also returns current rate and concurrency values in `limits`. Provider capacity and available credits still apply; simultaneous requests require enough credits for their reservations. Voice enrollment has separate [account abuse limits](https://docs.voicelab.uz/api/voice-cloning#limits-and-errors). The exact account policy can vary. When the response includes `Retry-After`, wait at least that many seconds. For retryable errors, otherwise use exponential backoff with jitter, for example 1s, 2s, 4s, then stop after a small number of attempts. Do not retry: - `401`, `403`, `404`, or validation `422` without changing the request; - an idempotency conflict with the same changed payload; - a permanently revoked key; - any error explicitly marked `retryable:false`; - `llm_model_temporarily_unavailable` or legacy `llm_usage_pending`, which requires support reconciliation rather than waiting for a cooldown. ## Voice Isolator errors and limits [Voice Isolator](https://docs.voicelab.uz/api/voice-isolator) accepts up to 300 MiB of uploaded audio, or that combined total for a batch of 1–4 files. Each file must contain at least 0.5 seconds of decodable audio. There is no separate maximum recording duration; decode resource budgets and processing timeouts still apply. | Status | Code | Recovery | | ---: | --- | --- | | `402` | `insufficient_credits` | Add enough account credits for the request | | `402` | `api_key_credit_limit` | Check the key limit, recorded billable usage, and pending Voice Isolator reservations | | `404` | `voice_isolation_disabled`, `not_found` | Check feature availability and account ownership | | `409` | `idempotency_conflict` | Reuse the original input for a retry; use a fresh UUID for changed audio, title, or options | | `409` | `audio_not_ready` | Continue polling before requesting an export | | `413` | `audio_too_large` | Reduce the uploaded audio size | | `422` | `invalid_audio`, `invalid_speech_restoration`, `invalid_restoration_model`, `invalid_export_format` | Correct the audio or options described in the endpoint reference | | `429` | `queue_full` | Wait for capacity and respect `Retry-After` | | `503` | `voice_isolation_unavailable`, `speech_restoration_unavailable` | Retry after `Retry-After`; changed processing options require a new idempotency key | A job with `status=completed` may still have a pending MP3 export. Wait for `audio_status=completed` and `audio_url` before downloading the default result. Refresh the job to get fresh signed URLs, and do not send an API key to storage URLs. Deleting a job only hides it from history; it does not erase stored audio or cancel accepted work. ## API-key security checklist - Store keys in a server-side secret manager. - Give each service its own key and the smallest permissions it needs. - Set `expires_at` for temporary or employee-owned integrations. - Set `allowed_ips` for fixed server egress addresses. - Keep `restrict_key` and `auto_disable_if_leaked` enabled unless you have a documented reason not to. - Revoke a leaked key immediately; do not wait for expiration. - Do not put API keys in Git, Docker images, frontend JavaScript, URLs, query strings, analytics events, or exception messages. - Treat signed audio URLs and realtime tickets as secrets too. - Keep `request_id` when reporting a failure, but never include credentials in support logs. ## Ownership and privacy Every API-key request is owner-scoped. A key from account A cannot read or delete account B's TTS generations, STT transcriptions, voices, or audio. Audio URLs are short-lived and private. Download the object when needed rather than storing the URL permanently. The API never returns provider credentials, internal model names, raw upstream responses, or account IDs in normal product responses. ## What is not a developer API yet These dashboard and internal routes do not accept developer API keys: - `/api/v1/account/*` product/profile and dashboard voice-cloning workflows, except the API-key management and dashboard analytics endpoints documented here; use [developer Voice Cloning](https://docs.voicelab.uz/api/voice-cloning) at `/v1/voice-enrollments` and `/v1/voices/{id}` for API-key integrations; - `/api/v1/voice-isolations*` dashboard JWT routes; use the documented `/v1/voice-isolations` developer API instead; - `/api/v1/llm/*` and `/api/v1/chats/*` JWT-only Ask routes; use the [LLM developer API](https://docs.voicelab.uz/api/llm) for server integrations; - `/api/v1/voice-agent/*` website/LiveKit voice-agent session routes; - `/api/v1/public-preview/*` anonymous preview routes; - retired TTS job aliases and retired subtitle list/download aliases; - arbitrary LiveKit token generation. Use the documented `/v1` routes for product integrations. Undocumented routes and legacy proxy behavior can change without notice. --- Source: https://docs.voicelab.uz/api/files # LLM files Upload images and PDFs, then reference the returned file IDs in chat completions. Files belong to the account that uploaded them. ## Authentication and model support Use a server-side API key with `llm:write` to upload or delete, and `llm:read` to list or inspect files. `{"llm":"access"}` grants both. Read [model capabilities](https://docs.voicelab.uz/api/llm#list-models) before uploading. Images require `vision`; PDFs require both `document` and `vision`. Only use advertised capabilities. Each completion rechecks file ownership, expiry and compatibility with the selected model's provider. ## Upload a file **POST https://api.voicelab.uz/v1/files** Upload one image or PDF for a compatible LLM. Requires llm:write. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. | Field | Required | Rules | | --- | --- | --- | | `file` | Yes | Exactly one nonempty file, at most 4 MiB | | `model` | No | Public model ID from `/v1/models`; choose one that supports the file type | | `purpose` | No | `vision` for images or `document` for PDFs; must match the detected type | Supported formats are PNG, JPEG, WebP, GIF and PDF. PDFs must have at most 100 pages and pass provider validation. Audio, video, DOCX, external URLs, data URIs and arbitrary provider file IDs are unsupported. The server checks the file contents. Omit unused optional fields instead of sending empty values. Unknown or repeated fields are rejected. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/files' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -F "model=${VOICELAB_MODEL:?Choose a model with document and vision capabilities}" \ -F 'purpose=document' \ -F 'file=@report.pdf;type=application/pdf' ``` If `model` is omitted, the server chooses a configured upload-capable provider. This does not guarantee that every model can later use that file. `201 Created` returns metadata directly, without a `data` wrapper: ```json { "id": "file_EXAMPLE", "object": "file", "bytes": 24576, "filename": "report.pdf", "purpose": "document", "created_at": 1790856000, "expires_at": 1793448000 } ``` Both timestamps are Unix seconds. Save the returned opaque ID. Uploads have no `Idempotency-Key` deduplication contract; an automatic retry can create another file. After a lost response, inspect your file list before uploading again. ## Use a file in a completion Send the VoiceLab file ID in a `user` message to [`POST /v1/chat/completions`](https://docs.voicelab.uz/api/llm#create-a-chat-completion): ```json { "model": "MODEL_FROM_CATALOG", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "Summarize this report."}, {"type": "file", "file": {"file_id": "file_EXAMPLE"}} ] }], "max_completion_tokens": 1024 } ``` Replace the model and file placeholders with returned IDs. Content arrays allow 1–12 parts and at most four files per message. A developer conversation can reference at most 16 files in total. File parts are accepted only in user messages. Media requests reserve a model-context-sized input budget plus the output cap. The server settles actual provider usage and releases the unused reservation. A short file ID can therefore require a larger available credit balance than a short text prompt. Uploading a file does not start a completion. ## List files **GET https://api.voicelab.uz/v1/files** List your available uploads. Requires llm:read. Authentication: `Bearer vlk_...`. | Query | Rules | | --- | --- | | `limit` | 1–100; default 20 | | `after` | An account-owned file ID from the previous page's `last_id` | | `purpose` | Optional `vision` or `document` filter | `200 OK` returns `{"object":"list","data":[...],"has_more":false,"first_id":"file_EXAMPLE","last_id":"file_EXAMPLE"}`. Entries use the upload metadata shape. When `has_more` is true, request the next page with `after=last_id`. Empty pages have empty `first_id` and `last_id` strings. ## Get a file **GET https://api.voicelab.uz/v1/files/{file_id}** Read account-owned file metadata. Requires llm:read. Authentication: `Bearer vlk_...`. Returns `200` with the same metadata shape as upload. There is no public file download endpoint. Expired, deleted or inaccessible files cannot be reused. ## Delete a file **DELETE https://api.voicelab.uz/v1/files/{file_id}** Revoke reuse and schedule provider file cleanup. Requires llm:write. Authentication: `Bearer vlk_...`. No body is required. `200 OK` returns: ```json {"id":"file_EXAMPLE","object":"file","deleted":true} ``` Deletion blocks reuse immediately. The server removes the provider's copy in the background and retries failed cleanup. Files expire after 30 days or at the provider's expiry, whichever comes first. Deleting a chat does not delete its uploads. The configured model provider processes uploaded content. ## Limits and errors Each account can have 25 active uploads and 100 upload attempts per rolling day. Uploads have a two-minute server deadline, with four concurrent uploads per account and 32 per API replica. These slots are separate from generation slots. | HTTP | Code | Recovery | | --- | --- | --- | | `413` | `file_too_large` | Reduce the multipart upload to one file within 4 MiB | | `415` | `unsupported_media_type` | Send multipart form data | | `422` | `validation_error` | Check fields, file size, filename and pagination | | `422` | `unsupported_attachment`, `invalid_attachment` | Check detected format, purpose and PDF contents/page count | | `422` | `attachment_unavailable` | Check ownership, expiry, provider binding and model capabilities | | `429` | `attachment_quota_exceeded` | Remove unneeded active files or wait for the daily limit to clear | | `503` | `chat_unavailable` | Storage or file processing is unavailable; inspect existing files before retrying an upload | File errors use the standard [error envelope](https://docs.voicelab.uz/api/errors), including `request_id`, even though successful file responses use the shapes above. --- Source: https://docs.voicelab.uz/api/llm # LLM API Generate text or function calls as JSON or an SSE stream. Completions cost credits based on token usage; model discovery and status lookups are free. ## Authentication Send `Authorization: Bearer $VOICELAB_API_KEY`. Restricted keys need `{"llm":"access"}` in their permission map for the complete workflow. The compatibility scopes are `llm:read` for models and request status, and `llm:write` for completions. Existing unrestricted keys can use these routes. See [API-key permissions](https://docs.voicelab.uz/api/authentication#llm-access). The developer API uses `/v1`. Ask's `/api/v1/llm/*` and `/api/v1/chats/*` routes require a platform JWT and remain uncharged. Developer completions use the messages you supply; they do not add Ask's prompts, knowledge, or history. ## List models **GET https://api.voicelab.uz/v1/models** List configured, priced models and their effective API limits. Requires llm:read. Authentication: `Bearer `. ```bash curl -fS 'https://api.voicelab.uz/v1/models' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` The response is an object with `object: "list"` and a `data` array. Each entry contains: | Field | Meaning | | --- | --- | | `id` | Public model ID to send in a completion request | | `object` | `model` | | `name`, `description` | Optional display metadata | | `parameters`, `context_window` | Optional model parameter count and context capacity | | `owned_by` | `voicelab` | | `created` | `0`; no model creation timestamp is supplied | | `pricing.version`, `pricing.model` | Active price version and public model ID | | `pricing.input_usd_cents_per_million_tokens` | Input price in USD cents per million tokens | | `pricing.output_usd_cents_per_million_tokens` | Output price in USD cents per million tokens | | `pricing.credits_per_usd` | Conversion used for this price version | | `limits.max_input_bytes` | Effective serialized messages/tools limit, currently `65536` | | `limits.max_output_tokens` | Effective output-token limit, currently `4096` | | `limits.requests_per_second_per_replica` | Account admission rate on each API replica, currently `70` | | `limits.max_concurrent_requests_per_account` | Running requests across keys, models and replicas, currently `70` | | `capabilities` | Advertised features, including `vision`, `document` or `tool_calling` when supported | | `reasoning_efforts`, `reasoning_effort_default` | Optional model-specific effort values and default | Resolve model IDs and prices from this response. The examples below use `aisha-comet`; use a model returned for your account. Only configured models with active pricing appear. A model's context capacity does not override the [endpoint limits](https://docs.voicelab.uz/api/llm#limits). ### Aisha Muon `aisha-muon` is VoiceLab's fast long-context model for reasoning, coding, and high-volume work. Its reference metadata describes 284 billion parameters, a 1-million-token context window, and up to 128,000 model output tokens. Always use the effective `limits`, `capabilities`, and `pricing` returned by `GET /v1/models`; endpoint limits can be lower than the model's reference capacity. Muon is available only when `aisha-muon` appears in that response. To send a request with Muon: ```bash curl -fS 'https://api.voicelab.uz/v1/chat/completions' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: muon-turn-001' \ -d '{ "model": "aisha-muon", "messages": [ {"role": "system", "content": "Answer briefly."}, {"role": "user", "content": "Explain optimistic locking."} ], "max_tokens": 512 }' ``` Use each model's current capabilities and effort values. Missing capabilities do not imply support. Discovery failure or stale metadata can leave text generation available while new media, tools and explicit reasoning efforts are unavailable. [Upload images and PDFs](https://docs.voicelab.uz/api/files) before referencing them in a completion; PDFs require both `vision` and `document`. ## Create a chat completion **POST https://api.voicelab.uz/v1/chat/completions** Generate a text or function-call completion, billed in credits. Requires llm:write. Authentication: `Bearer `. Content-Type: `application/json`. ```bash curl -fS -D llm-response.headers 'https://api.voicelab.uz/v1/chat/completions' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: hello-turn-001' \ -d '{ "model": "aisha-comet", "messages": [ {"role": "system", "content": "Answer briefly."}, {"role": "user", "content": "Salom!"} ], "max_tokens": 128 }' ``` ### Request fields | Field | Required | Rules | | --- | --- | --- | | `model` | yes | Public ID from `GET /v1/models` | | `messages` | yes | 1 to 101 messages with `role` and text or supported text/file `content` parts | | `stream` | no | Boolean; defaults to `false` | | `stream_options.include_usage` | no | Set to `true` for a final SSE usage chunk | | `max_tokens` | no | Integer from 1 to 4096; defaults to 1024 | | `max_completion_tokens` | no | Alias for `max_tokens`; send only one of these fields | | `temperature` | no | Number from 0 to 2; model support varies | | `top_p` | no | Number from 0 to 1; model support varies | | `stop` | no | One string or 1 to 4 strings, each 1 to 200 bytes | | `seed` | no | Integer; model support varies | | `n` | no | Only `1` is accepted | | `tools` | no | Up to 32 function definitions; see [function calls](https://docs.voicelab.uz/api/llm#function-calls) | | `tool_choice` | no | `auto`, `none`, `required`, or a supplied function selector | | `parallel_tool_calls` | no | Boolean; model support varies | | `thinking` | no | Boolean reasoning control; send at most one reasoning control | | `reasoning` | no | Object with `enabled` boolean and optional `effort` | | `reasoning_effort` | no | One value from the selected model's advertised `reasoning_efforts` | Messages support `system`, `user`, `assistant`, and `tool` roles. Content can be UTF-8 text or an array of text/file parts. Images and PDFs must first be uploaded through [Files](https://docs.voicelab.uz/api/files); file parts are allowed only in user messages. Audio, video, external URLs and inline data URIs are unsupported. An assistant message with function calls may have empty content. Tool messages must include `tool_call_id` matching a preceding assistant call. Choose at most one of `thinking`, `reasoning`, or `reasoning_effort`. The `reasoning.effort` field accepts the same effort names as `reasoning_effort`. Unknown fields, provider URLs/keys, and multiple choices are rejected. `thinking: false` can select a model's lowest effort rather than disable reasoning. It does not guarantee zero reasoning tokens or faster responses. Use the model's advertised effort values. ### JSON response Example `200 OK` response with `stream: false`: ```json { "id": "chatcmpl_example", "object": "chat.completion", "created": 1788998400, "model": "aisha-comet", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "Salom!"}, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 20, "completion_tokens": 4, "total_tokens": 24 } } ``` `created` is Unix time in seconds. `message.content` can be `null` for a tool-only response; `message.tool_calls` and `message.reasoning_content` are optional. Finish reasons are `stop`, `length`, `tool_calls`, or `content_filter`. Usage may also contain `prompt_tokens_details.cached_tokens` and `completion_tokens_details.reasoning_tokens`. Read `X-LLM-Request-ID` from the response headers for the billable request ID. It matches the completion `id`. `X-Credits-Charged` reports the whole credits debited when billing is settled. A completed answer with unverified usage can instead return `usage: null` and `X-LLM-Billing-Status: pending`, without `X-Credits-Charged`. Unknown usage is not zero cost. Successful LLM bodies have no generic `request_id` wrapper; the HTTP trace ID in error bodies is separate from the LLM request ID. ## Stream a completion Use the same completion endpoint with `stream: true`. `curl -N` displays SSE events as they arrive: ```bash curl -fS -N -D llm-stream.headers 'https://api.voicelab.uz/v1/chat/completions' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: hello-stream-001' \ -d '{ "model": "aisha-comet", "messages": [{"role": "user", "content": "Say hello."}], "max_tokens": 128, "stream": true, "stream_options": {"include_usage": true} }' ``` The response uses `Content-Type: text/event-stream`. Events are separated by a blank line. An illustrative stream is: ```text data: {"id":"chatcmpl_example","object":"chat.completion.chunk","model":"aisha-comet","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}],"created":1788998400} data: {"id":"chatcmpl_example","object":"chat.completion.chunk","model":"aisha-comet","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"created":1788998400} data: {"id":"chatcmpl_example","object":"chat.completion.chunk","model":"aisha-comet","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12},"created":1788998400} data: [DONE] ``` Append `choices[].delta.content` as it arrives. Reasoning can arrive in `delta.reasoning_content`. Assemble function-call arguments from fragments using each `delta.tool_calls[].index`. Network reads can split or combine events. Parse SSE event boundaries. The final usage chunk appears only with `stream_options.include_usage: true`. `[DONE]` is sent after a complete answer and either credit settlement or durable recording of unresolved usage. A usage footer can contain `usage: null` when counts are unverified. An HTTP `200` alone does not prove a stream completed: it can end with an `llm_request_incomplete` error event and no `[DONE]`, or disconnect. Read the error inside the SSE data, retain `X-LLM-Request-ID`, and check [request status](https://docs.voicelab.uz/api/llm#get-request-status) before retrying. Streaming responses do not provide `X-Credits-Charged`; use request status for the settled charge. Treat any SSE `error` or EOF without `[DONE]` as incomplete. Never execute partial tool-call arguments. Analytics record streaming failures separately from the HTTP status, which cannot change once the stream starts. ## Function calls Supply functions as data. Your application validates and executes the selected function; VoiceLab does not run it. This completion request asks the model to call one function: ```json { "model": "aisha-comet", "messages": [{"role": "user", "content": "What is the weather in Tashkent?"}], "max_tokens": 256, "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a city.", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } } ], "tool_choice": {"type": "function", "function": {"name": "get_weather"}} } ``` Function names must be unique and match `[a-zA-Z0-9_-]{1,64}`. Each function's `parameters` must be a JSON object schema. `tool_choice: "required"` requires at least one tool; a named selector must refer to a supplied function. After executing a returned call, send a new completion request with the original messages, the assistant's calls, and a `tool` result for every call. Use the actual call IDs and arguments returned by the model. For example: ```json { "model": "aisha-comet", "max_tokens": 256, "messages": [ {"role": "user", "content": "What is the weather in Tashkent?"}, { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_example", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"Tashkent\"}"} } ] }, {"role": "tool", "tool_call_id": "call_example", "content": "{\"temperature_c\":28}"} ] } ``` Function arguments are a JSON-encoded string. Complete all pending tool results before adding another conversation message. The follow-up is a new paid generation and needs a new idempotency key. Resending history and tool results adds input tokens to that request. ## Get request status **GET https://api.voicelab.uz/v1/llm/requests/{id}** Read account-scoped LLM status, token usage, and credit details. Requires llm:read. Authentication: `Bearer `. Use the `X-LLM-Request-ID` captured from a completion response: ```bash curl -fS "https://api.voicelab.uz/v1/llm/requests/$LLM_REQUEST_ID" \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` The response is a plain object with these fields: | Field | Meaning | | --- | --- | | `id`, `model` | LLM request ID and public model ID | | `status` | `running`, `completed`, `failed`, `usage_pending`, or `waived` | | `reserved_credits` | Whole credits reserved before generation | | `charged_credits` | Whole credits debited; nullable until settlement | | `credit_units` | Exact request charge in fractional credit units; nullable | | `credit_unit_scale` | `100000000` units per credit | | `prompt_tokens`, `completion_tokens` | Reported input/output counts; nullable | | `cached_tokens`, `reasoning_tokens` | Optional token details represented as nullable counts | | `elapsed_ms`, `ttft_ms` | Elapsed time and time to first text token in milliseconds; nullable | | `price_version` | Price version retained for this request | | `created_at` | RFC3339 timestamp | | `failure_code` | Optional sanitized diagnostic code, when known | | `resolution_required` | `true` when usage requires reconciliation | Nullable values mean the corresponding result is not available. A `failed` request can still have a charge when usage is known. `usage_pending` means usage needs reconciliation; contact support with the LLM request ID. `waived` means support has resolved the unknown charge by waiving it. Only requests belonging to the authenticated account are returned. The endpoint does not return completion text, prompts, reasoning, tool contents, or a full price object. Developer conversation content is not retained in chat tables or request logs. Save responses in your application if you need them later. ## Idempotency and retries Send `Idempotency-Key` for every logical completion request. The header is optional, but without it every call starts a new request. Use 1 to 200 printable ASCII characters with no spaces or control characters. Keys are scoped to the account and API key. | Retry | Result | | --- | --- | | Same key and same request body | `409 request_already_submitted`; no second generation or charge | | Same key with changed input or options | `409 idempotency_conflict` | | New key | A new generation, subject to admission and billing checks | Both `409` responses include the original `X-LLM-Request-ID`. Read its status; the API does not store or replay the completion. Keep the same body and key for transport retries. Use a new key only for an intentionally new generation. A broken stream, disconnect, or missing final usage can leave a request in `usage_pending`. Independent requests can continue while balance, concurrency and unresolved-exposure limits allow. Exposure is capped at eight unsettled requests per account and upstream model (including active requests). At that limit, new calls for the affected model return `503 llm_model_temporarily_unavailable`, `error.retryable: false`, with no `Retry-After`. This is an accounting hold, not a timed provider cooldown: support must reconcile or explicitly resolve it. Each model has its own limit. Unresolved requests can block multiple models; switching keys does not bypass the hold. Ask and other products are outside this developer-LLM hold. A request proven not to have been sent upstream is recorded as `failed` with `failure_code: not_dispatched`, its reservation released, without consuming an unresolved slot. A disconnect after dispatch is ambiguous: neither zero visible tokens nor an upstream `499` proves zero cost. The API cancels upstream work on client disconnect and does not silently start another generation. Pending usage still requires reconciliation. ## Billing Read current rates from `GET /v1/models`. Before generation, VoiceLab reserves a conservative input budget plus the maximum output budget and checks the API key's credit limit. This can require more balance than the final charge. Actual reported usage settles the charge and releases unused reserved credits. Each request retains its original price version. Input includes messages, history, function definitions, and tool results. Output includes generated function arguments and reported reasoning tokens. Reasoning tokens count once within completion tokens; cached input uses the normal input rate. Lower visible output does not necessarily mean lower reasoning usage. Fractions accumulate per account at 1/100,000,000 of a credit. A small request can debit zero whole credits while adding fractional usage. Whole credits debit when the accumulated amount crosses a whole-credit boundary. Unlimited platform plans do not bypass developer LLM billing. See [pricing](https://docs.voicelab.uz/guide/pricing#llm-billing) and [LLM analytics](https://docs.voicelab.uz/api/analytics#llm-usage) for usage tracking. ## Limits | Limit | Value | | --- | --- | | HTTP request body | 128 KiB | | Serialized messages and tools | 64 KiB | | Messages | 1 to 101 | | Function definitions | Up to 32 | | Output tokens | 1 to 4096; default 1024 | | Completion timeout | 120 seconds | | Concurrent generations | 70 per account across API replicas; 256 total on each API replica | | File parts | 4 per message, 16 across the conversation; see [Files](https://docs.voicelab.uz/api/files) | Rate and capacity limits also apply. Respect `Retry-After` when present. The concurrency limit is shared across keys and models. Queue excess work client-side and follow [rate and capacity limits](https://docs.voicelab.uz/api/errors#rate-limits-and-backoff). 64 KiB means 65,536 bytes of serialized messages/tools, not tokens or characters. The 4,096-token output cap is an API limit, not model context size. The 120-second deadline includes waiting for upstream response headers; connection establishment has a shorter timeout. Allow additional network time in your client deadline. A shorter deadline cancels the request. Streaming can show text sooner without reducing total generation time. ## Errors LLM handler errors use this envelope: ```json { "error": { "message": "This request was already submitted. Check X-LLM-Request-ID; no new generation was started.", "type": "request_error", "code": "request_already_submitted" }, "request_id": "req_example" } ``` Shared authentication failures can use the [standard error envelope](https://docs.voicelab.uz/api/errors) with a top-level `message`. Read `error.code` for program logic and handle both message locations. | Status | Code | Recovery | | --- | --- | --- | | `400` | `invalid_request` | Correct JSON, fields, limits, content type, or idempotency key | | `401` | `invalid_api_key` | Replace or correct the developer key | | `403` | `insufficient_scope` | Grant the required LLM permission | | `402` | `insufficient_credits`, `api_key_credit_limit` | Check balance, key limit, and maximum token budget | | `404` | `not_found` | Refresh models or verify the request ID and account | | `409` | `request_already_submitted`, `idempotency_conflict` | Read the original request status using `X-LLM-Request-ID` | | `422` | `llm_request_unsupported` | Change settings unsupported by the selected model | | `429` | `rate_limited` | Respect `Retry-After` when present | | `503` | `llm_usage_pending` | Contact support; do not automatically retry | | `503` | `llm_model_temporarily_unavailable` | Account/model unresolved-usage hold; `retryable:false`, contact support; waiting does not reset it | | `503` | `llm_unavailable` | Check any admitted request's status before a same-key retry | | `502` | `llm_upstream_canceled` | Upstream cancellation is ambiguous; inspect the request's usage state before retrying | After SSE starts, an error can arrive as a data event with `error.code: "llm_request_incomplete"` while HTTP status remains `200`. A stream without `[DONE]` is incomplete. Retain both the HTTP trace `request_id`, when available, and `X-LLM-Request-ID` for support. --- Source: https://docs.voicelab.uz/api/meetings # Meeting AI Upload a meeting recording, poll until processing finishes, then read the speaker-labelled transcript, summary, decisions, and action items. ## Authentication Use a server-side developer API key with **Meeting AI → Access**, permission ID `meetings`. Access grants `meetings:read` and `meetings:write`. Restricted keys need this permission added explicitly. See [API keys](https://docs.voicelab.uz/api/authentication). | Credential | Route prefix | Purpose | | --- | --- | --- | | Developer API key | `/v1/meetings` | Server integrations documented here | | User access JWT | `/api/v1/meetings` | VoiceLab dashboard | Meeting AI is available to personal workspace keys only. Team workspace keys return `403 workspace_product_unavailable`. Meetings belong to the account: a permitted key can retrieve meetings created by another key or in the web app, and meetings created with a key appear in the web Meeting AI list. Other accounts cannot access them; their IDs return `404 meeting_not_found`. ## Endpoints | Method | Path | Scope | Purpose | | --- | --- | --- | --- | | `GET` | `/v1/meetings/capabilities` | `meetings:read` | Supported languages and feature flags | | `POST` | `/v1/meetings` | `meetings:write` | Upload a recording; returns `202` | | `GET` | `/v1/meetings` | `meetings:read` | List meetings or refresh selected IDs | | `GET` | `/v1/meetings/{id}` | `meetings:read` | Poll status and read results | | `PATCH` | `/v1/meetings/{id}` | `meetings:write` | Rename a meeting | | `DELETE` | `/v1/meetings/{id}` | `meetings:write` | Delete a meeting | | `POST` | `/v1/meetings/{id}/notes/generations` | `meetings:write` | Regenerate notes after a notes failure | Developer responses never include audio playback URLs. Audio download, transcript and notes editing, speaker renaming, translations, and webhooks are not available with API keys. Meeting AI has no completion webhooks; poll instead. ## Check capabilities **GET https://api.voicelab.uz/v1/meetings/capabilities** List supported recording languages before uploading. Authentication: `Bearer vlk_...`. Requires `meetings:read`. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/meetings/capabilities' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Returns `200`. `data` lists supported languages with `code`, `name`, and `native_name`. Send one `code` as `language`, or omit it for automatic detection. Allow unknown fields in this response. ## Create a meeting **POST https://api.voicelab.uz/v1/meetings** Upload a recording for asynchronous transcription and notes. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. Requires `meetings:write`. ### Headers and fields | Header | Required | Value | | --- | --- | --- | | `Authorization` | yes | `Bearer ` | | `Idempotency-Key` | yes | UUID generated once per logical upload; reuse for retries | | `Content-Type` | yes | Multipart boundary generated by your HTTP client | | Multipart field | Required | Value | | --- | --- | --- | | `audio` | yes | One audio file | | `title` | no | 1–160 characters, single line; defaults to the filename without its extension | | `language` | no | `auto` or a code from capabilities; default `auto` | Send each field at most once. Unknown or repeated parts return `400 invalid_multipart`. API-key uploads are always recorded as uploads; the web app's `source` field is not accepted. The same idempotency key and content return the original meeting with `Idempotent-Replayed: true` and no second credit hold. Changing the audio, title, or language with that key returns `409 idempotency_key_reused`. Keys are scoped per developer API key, separately from web app uploads. ### Limits | Limit | Value | | --- | --- | | Upload size | 500 MiB, or 524,288,000 bytes | | Duration | 0.5 seconds to 24 hours | | Formats | MP3, WAV, M4A/AAC, OGG/Opus, WebM/Opus, or FLAC | | Pending meetings | 10 queued or running meetings per account | | Write rate | Shared per-account budget for uploads and notes regeneration | The API inspects the file contents and ignores the client-supplied content type. Write requests share their per-account rate limit with the Meeting AI web app, so dashboard activity and API traffic draw from the same budget. Reads have a separate per-account budget. When ten meetings are already queued or running, new uploads return `429 meeting_queue_full`; wait for one to finish. Respect `Retry-After` on every `429`. ### Request examples These examples run on your server. Set `VOICELAB_API_KEY` in the environment. Let the client generate the multipart boundary. Retain the generated UUID if you retry the upload. ```bash request_key=$(uuidgen) curl --fail-with-body -i 'https://api.voicelab.uz/v1/meetings' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H "Idempotency-Key: $request_key" \ -F 'audio=@weekly-sync.mp3' \ -F 'title=Weekly sync' \ -F 'language=en' ``` ```python import os import time import uuid import requests api = "https://api.voicelab.uz/v1/meetings" headers = {"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"} request_key = str(uuid.uuid4()) with open("weekly-sync.mp3", "rb") as audio: response = requests.post( api, headers={**headers, "Idempotency-Key": request_key}, files={"audio": ("weekly-sync.mp3", audio, "audio/mpeg")}, data={"title": "Weekly sync", "language": "en"}, timeout=900, ) response.raise_for_status() meeting = response.json()["meeting"] while meeting["status"] in ("queued", "running"): time.sleep(int(response.headers.get("Retry-After", "2"))) response = requests.get(f"{api}/{meeting['id']}", headers=headers, timeout=30) response.raise_for_status() meeting = response.json()["meeting"] if meeting["status"] == "failed": raise RuntimeError(meeting["failure"]["code"]) print(meeting["transcript"]["text"]) print(meeting["notes"]["summary"]) ``` ```ts import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const requestKey = randomUUID(); const form = new FormData(); form.append("audio", new Blob([new Uint8Array(await readFile("weekly-sync.mp3"))]), "weekly-sync.mp3"); form.append("title", "Weekly sync"); form.append("language", "en"); const response = await fetch("https://api.voicelab.uz/v1/meetings", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Idempotency-Key": requestKey, }, body: form, signal: AbortSignal.timeout(900_000), }); if (!response.ok) throw new Error(`Upload failed: ${response.status}`); console.log((await response.json()).meeting.id); ``` ### Response and polling Returns `202 Accepted`, `Location: /v1/meetings/{id}`, and `Retry-After: 2`. Retries with the same key and content return the existing meeting: ```json { "meeting": { "id": "mtg_550e8400-e29b-41d4-a716-446655440000", "title": "Weekly sync", "status": "queued", "capture_source": "upload", "created_at": "2026-10-14T09:00:00Z", "updated_at": "2026-10-14T09:00:00Z", "audio": { "filename": "weekly-sync.mp3", "content_type": "audio/mpeg", "size_bytes": 7340032, "duration_ms": 1830000 }, "transcript": { "text": "", "language": "", "language_probability": 0, "segments": [] }, "notes": { "version": 0, "summary": "", "decisions": [], "action_items": [] }, "versions": [], "processing": { "transcription": { "status": "queued", "retryable": false }, "notes": { "status": "waiting", "retryable": false } } }, "async": true, "request_id": "req_example" } ``` Poll `GET /v1/meetings/{id}` every two seconds, or follow `Retry-After`, until `status` is no longer `queued` or `running`: | `status` | Meaning | Next step | | --- | --- | --- | | `queued` | Waiting for the processor | Keep polling | | `running` | Transcription or notes in progress | Keep polling | | `transcribed` | Transcript saved; notes generation failed | Read the transcript; optionally [regenerate notes](https://docs.voicelab.uz/api/meetings#regenerate-notes) | | `ready` | Transcript and notes complete | Read results | | `failed` | Processing failed; no transcript | Inspect `failure.code`; upload again if appropriate | `processing.transcription` and `processing.notes` report each stage separately: `waiting`, `queued`, `running`, `ready`, or `failed`, with a `retryable` flag and a safe `failure` object when a stage fails. A meeting can return to `queued` or `running` with its transcript intact while notes are regenerated. When the meeting is `ready`, read these fields: | Field | Meaning | | --- | --- | | `transcript.text` | Full transcript text | | `transcript.language`, `transcript.language_probability` | Detected or requested language and confidence | | `transcript.segments` | Timed segments with `speaker_id`, `speaker`, `text`, and `words` | | `notes.summary` | Meeting summary | | `notes.decisions` | Decisions, each with `evidence.segment_ids` | | `notes.action_items` | Action items with `owner` and `evidence.segment_ids` | | `failure` | `code` and `message` when processing fails | ## List meetings **GET https://api.voicelab.uz/v1/meetings** List the account's meetings, newest first. Authentication: `Bearer vlk_...`. Requires `meetings:read`. Optional `limit` defaults to 20 and accepts 1–50. Pass the returned opaque `next_cursor` as `cursor` for the next page. To refresh specific meetings, send `ids` as a comma-separated list of up to 50 meeting IDs instead of `cursor`. IDs that do not belong to the account are omitted. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/meetings?limit=20' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Returns `200` with `meetings` in descending creation order and optional `next_cursor`. Invalid `limit`, `cursor`, or `ids` values return `400 invalid_meeting_query`. ## Get a meeting **GET https://api.voicelab.uz/v1/meetings/{id}** Read meeting status, transcript, and notes. Authentication: `Bearer vlk_...`. Requires `meetings:read`. Returns `200` with `meeting`. Unknown meetings, deleted meetings, and meetings belonging to another account return `404 meeting_not_found`. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/meetings/mtg_REPLACE_ME' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ## Rename a meeting **PATCH https://api.voicelab.uz/v1/meetings/{id}** Change a meeting's title. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Requires `meetings:write`. Send only `title`, 1–160 characters on one line. Unknown fields return `422 validation_error`. ```bash curl --fail-with-body -X PATCH 'https://api.voicelab.uz/v1/meetings/mtg_REPLACE_ME' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Weekly sync — product"}' ``` Returns `200` with the updated `meeting`. ## Delete a meeting **DELETE https://api.voicelab.uz/v1/meetings/{id}** Delete a meeting and its results. Authentication: `Bearer vlk_...`. Requires `meetings:write`. Returns `204 No Content`. Deleting a queued or running meeting releases its credit hold. Deleted meetings also disappear from the web app. ```bash curl --fail-with-body -X DELETE 'https://api.voicelab.uz/v1/meetings/mtg_REPLACE_ME' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ## Regenerate notes **POST https://api.voicelab.uz/v1/meetings/{id}/notes/generations** Retry notes for a meeting whose notes generation failed. Authentication: `Bearer vlk_...`. Requires `meetings:write`. Only meetings with status `transcribed` qualify: the transcript exists and notes generation failed. Other meetings return `409 meeting_notes_not_retryable`. Regeneration is billed again at the meeting's full duration and counts toward the key's credit limit and the shared write rate limit. ```bash curl --fail-with-body -X POST 'https://api.voicelab.uz/v1/meetings/mtg_REPLACE_ME/notes/generations' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Returns `202 Accepted` with `meeting`, `Location: /v1/meetings/{id}`, and `Retry-After: 2`. Poll the meeting as after an upload. ## Billing and errors Credits are reserved when the upload is accepted, prorated by recording duration at the configured per-minute rate and rounded up to a whole credit. They are debited once when processing succeeds. Processing failures and deletion before completion release the hold. Polling, listing, renaming, and deleting are not charged. Notes regeneration reserves and charges again. See [pricing](https://docs.voicelab.uz/guide/pricing). A key's credit limit includes its recorded billable usage and pending reservations across Meeting AI, Voice Isolator, and LLM requests. Uploads or regenerations that would exceed it return `402 api_key_credit_limit`. Revoking a key prevents new access but does not cancel accepted meetings. Usage is attributed to the submitting key under `meeting_ai` in [analytics](https://docs.voicelab.uz/api/analytics). | HTTP status | Code | Meaning | | --- | --- | --- | | `400` | `invalid_idempotency_key` | Missing or non-UUID `Idempotency-Key` | | `400` | `invalid_multipart` | Malformed form, missing `audio`, or unknown/repeated parts | | `400` | `invalid_meeting_query` | Invalid `limit`, `cursor`, or `ids` | | `401` | `invalid_api_key` | Missing, invalid, expired, disabled or revoked API key | | `402` | `insufficient_credits` | Account balance cannot cover the reservation | | `402` | `api_key_credit_limit` | Request would exceed the key's credit limit | | `403` | `insufficient_scope` | Key lacks `meetings:read` or `meetings:write` | | `403` | `workspace_product_unavailable` | Key belongs to a team workspace | | `403` | `workspace_mismatch`, `workspace_forbidden` | Workspace header or membership does not match the key | | `404` | `meeting_not_found` | Unknown, deleted, or another account's meeting | | `409` | `idempotency_key_reused` | Same key sent with different content | | `409` | `meeting_processing` | The original request with this key is still in progress | | `409` | `meeting_notes_not_retryable` | Notes regeneration requested for a meeting that is not `transcribed` | | `409` | `meeting_not_ready` | Operation requires completed processing | | `413` | `audio_too_large` | Upload exceeds 500 MiB | | `415` | `unsupported_audio_format` | File contents are not a supported audio format | | `422` | `invalid_audio` | Unreadable audio, or duration outside 0.5 seconds–24 hours | | `422` | `invalid_title`, `invalid_language`, `validation_error` | Invalid title, language, or rename body | | `429` | `rate_limited` | Shared per-account request budget exceeded | | `429` | `meeting_queue_full` | Ten meetings are already queued or running | | `429` | `meeting_upload_capacity` | Upload capacity is temporarily busy | | `503` | `meeting_billing_unavailable` | Pricing unavailable; nothing was queued | | `503` | `meeting_feature_unavailable`, `meeting_worker_unavailable` | Meeting AI or its processor is unavailable; nothing was queued | | `503` | `developer_auth_unavailable` | API key verification is temporarily unavailable | | `504` | `meeting_request_timeout` | Request timed out; check status before retrying | | `500` | `meeting_internal_error` | Unexpected failure | Honor `Retry-After` for `429` and `503` responses. Retry transient upload failures with the original idempotency key and identical content; after a timeout or lost response, the retry returns the existing meeting instead of creating a duplicate. Do not retry authorization or validation failures unchanged. Keep `request_id` for support. See [errors and limits](https://docs.voicelab.uz/api/errors). --- Source: https://docs.voicelab.uz/api/realtime-stt # Realtime STT over WebSocket Send raw PCM audio over a WebSocket and receive one final transcript per commit. Browser clients use a short-lived ticket issued by your backend. Server clients can use `xvl-api-key` on the handshake instead. Send either a ticket or a key, never both. Keep API keys and user JWTs out of URLs and browser bundles. ## 1. Create an STT ticket **POST https://api.voicelab.uz/v1/ticket** Mint a short-lived ticket for one realtime STT connection. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. ```json {"transport":"websocket","service":"stt"} ``` The API key needs `stt:realtime`. `stt:write` alone does not grant realtime access. Both `transport` and `service` are required. Tickets are service-bound: an STT ticket cannot authorize TTS and a TTS ticket cannot authorize STT. `201 Created` returns the ticket and WebSocket URL: ```json { "ticket": "eyJhbGciOiJIUzI1NiIs...", "service": "stt", "transport": "websocket", "scope": "stt:realtime", "expires_at": "2026-08-16T12:02:00Z", "websocket_url": "wss://api.voicelab.uz/v1/stt/stream", "request_id": "req_01J..." } ``` Use the ticket immediately and keep it out of logs and storage. ## 2. Connect **WSS wss://api.voicelab.uz/v1/stt/stream?ticket=** Open one realtime STT connection with the short-lived ticket. The server sends `ready` after the connection opens: ```json {"event":"ready","audio_format":"pcm_s16le","sample_rate":16000,"channels":1} ``` The examples transcribe `audio.pcm`, a headerless 16 kHz mono PCM16 file. Python uses `requests` and `websocket-client`. TypeScript uses `ws`. The Java example uses Java 17+ and Jackson databind. Use a client that can send binary PCM frames. ```python import json import os from pathlib import Path from urllib.parse import urlencode import requests import websocket response = requests.post( "https://api.voicelab.uz/v1/ticket", headers={ "Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}", "Content-Type": "application/json", }, json={"transport": "websocket", "service": "stt"}, timeout=15, ) response.raise_for_status() ticket = response.json() url = f"{ticket['websocket_url']}?{urlencode({'ticket': ticket['ticket']})}" ws = websocket.create_connection(url, timeout=45) print(json.loads(ws.recv())) # ready ws.send(json.dumps({ "type": "start", "language": "uz", "audio_format": "pcm_s16le", "sample_rate": 16000, "channels": 1, "word_timestamps": True, })) audio = Path("audio.pcm").read_bytes() for offset in range(0, len(audio), 3200): ws.send_binary(audio[offset:offset + 3200]) ws.send(json.dumps({"type": "commit"})) result = json.loads(ws.recv()) print(result) ws.close() ``` ```ts import { once } from "node:events"; import { readFile } from "node:fs/promises"; import WebSocket from "ws"; const response = await fetch("https://api.voicelab.uz/v1/ticket", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ transport: "websocket", service: "stt" }), }); if (!response.ok) throw new Error(await response.text()); const ticket = await response.json() as { ticket: string; websocket_url: string; }; const url = new URL(ticket.websocket_url); url.searchParams.set("ticket", ticket.ticket); const socket = new WebSocket(url); const readyMessage = once(socket, "message"); await once(socket, "open"); const [ready] = await readyMessage; console.log(JSON.parse(ready.toString())); socket.send(JSON.stringify({ type: "start", language: "uz", audio_format: "pcm_s16le", sample_rate: 16000, channels: 1, word_timestamps: true, })); const audio = await readFile("audio.pcm"); for (let offset = 0; offset < audio.length; offset += 3200) { socket.send(audio.subarray(offset, offset + 3200), { binary: true }); } const finalMessage = once(socket, "message"); socket.send(JSON.stringify({ type: "commit" })); const [result] = await finalMessage; console.log(JSON.parse(result.toString())); socket.close(); ``` ```java import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.CompletionStage; import java.util.concurrent.LinkedBlockingQueue; public final class RealtimeStt { public static void main(String[] args) throws Exception { var mapper = new ObjectMapper(); var http = HttpClient.newHttpClient(); var ticketRequest = HttpRequest.newBuilder(URI.create("https://api.voicelab.uz/v1/ticket")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "{\"transport\":\"websocket\",\"service\":\"stt\"}")) .build(); var ticketResponse = http.send(ticketRequest, HttpResponse.BodyHandlers.ofString()); if (ticketResponse.statusCode() != 201) throw new IllegalStateException(ticketResponse.body()); JsonNode ticket = mapper.readTree(ticketResponse.body()); String socketUrl = ticket.get("websocket_url").asText() + "?ticket=" + URLEncoder.encode(ticket.get("ticket").asText(), StandardCharsets.UTF_8); var events = new LinkedBlockingQueue(); WebSocket.Listener listener = new WebSocket.Listener() { private final StringBuilder text = new StringBuilder(); @Override public void onOpen(WebSocket socket) { socket.request(1); } @Override public CompletionStage onText( WebSocket socket, CharSequence data, boolean last) { text.append(data); if (last) { events.add(text.toString()); text.setLength(0); } socket.request(1); return null; } }; WebSocket socket = http.newWebSocketBuilder() .buildAsync(URI.create(socketUrl), listener).join(); System.out.println(mapper.readTree(events.take())); // ready socket.sendText(""" {"type":"start","language":"uz","audio_format":"pcm_s16le", "sample_rate":16000,"channels":1,"word_timestamps":true} """, true).join(); byte[] audio = Files.readAllBytes(Path.of("audio.pcm")); for (int offset = 0; offset < audio.length; offset += 3200) { int length = Math.min(3200, audio.length - offset); socket.sendBinary(ByteBuffer.wrap(audio, offset, length), true).join(); } socket.sendText("{\"type\":\"commit\"}", true).join(); System.out.println(mapper.readTree(events.take())); // final or error socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); } } ``` For browser clients, let your backend mint the ticket. Redact ticket query strings in reverse-proxy access logs. ## 3. Start an utterance ```json { "type": "start", "language": "uz", "audio_format": "pcm_s16le", "sample_rate": 16000, "channels": 1, "word_timestamps": true } ``` Realtime STT accepts the same 33 explicit [language codes as uploaded STT](https://docs.voicelab.uz/api/stt#supported-languages), including Chinese (`zh`) and Azerbaijani (`az`). The `language` field is required; send `"language":"auto"` to detect the primary language of each committed clip, or choose an explicit code. Final events contain the detected language, not `auto`. Very short clips may not contain enough speech for reliable detection; an explicit language is useful when it is already known. Audio must be signed 16-bit little-endian PCM, mono, and 16,000 Hz. Each commit must contain at least 100 ms and at most 35 seconds of audio. Browser `MediaRecorder` WebM/Opus frames are not accepted. Capture microphone samples with an `AudioWorklet`, resample to 16 kHz and encode as PCM16 before sending. ## 4. Send audio and commit Send one or more binary WebSocket frames containing raw PCM bytes, then send: ```json {"type":"commit"} ``` `{"action":"stop"}` is an alias for commit. Cancel or clear the active utterance with: ```json {"action":"interrupt"} ``` ## Final response The server returns one final result. It does not send interim transcripts. ```json { "event": "final", "text": "Salom dunyo", "language": "uz", "duration_ms": 1420, "segments": [ { "ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Salom dunyo", "words": [ {"ordinal": 1, "start_ms": 120, "end_ms": 500, "text": "Salom"}, {"ordinal": 2, "start_ms": 520, "end_ms": 1320, "text": "dunyo"} ] } ] } ``` Realtime results do not enter STT history. Use `POST /v1/stt` for saved transcriptions, source audio, editing, or SRT/VTT export. ## Error events ```json { "event": "error", "code": "invalid_audio", "message": "Send at least 100 ms of PCM audio before commit." } ``` WebSocket error codes include `invalid_message`, `validation_error`, `not_started`, `busy`, `invalid_audio`, `audio_too_long`, `no_speech_detected`, `insufficient_credits`, `overloaded`, `timeout`, and `service_unavailable`. Errors before the WebSocket upgrade use HTTP JSON: | Status | Code | | ---: | --- | | `401` | `invalid_realtime_ticket` | | `403` | `origin_forbidden` | | `404` | `not_found` when realtime is disabled | After the upgrade, validation and processing errors are WebSocket JSON events, not HTTP responses. --- Source: https://docs.voicelab.uz/api/stt # Speech-to-Text (STT) Transcribe audio with timestamps and optional speaker labels. Save, edit, and export the result. ## Languages STT accepts the 33 explicit language codes below for uploaded recordings and [realtime STT](https://docs.voicelab.uz/api/realtime-stt). Send `language=auto` to detect the primary language automatically. Endpoints, authentication, billing, and retry contracts are unchanged; there is no new model ID to send. ### Supported languages | Language | `language` code | | --- | --- | | Uzbek | `uz` | | English | `en` | | Russian | `ru` | | Japanese | `ja` | | Chinese (Mandarin) | `zh` | | Spanish | `es` | | French | `fr` | | German | `de` | | Portuguese | `pt` | | Italian | `it` | | Korean | `ko` | | Kazakh | `kk` | | Arabic | `ar` | | Turkish | `tr` | | Tajik | `tg` | | Filipino | `tl` | | Malay | `ms` | | Armenian | `hy` | | Hindi | `hi` | | Georgian | `ka` | | Nepali | `ne` | | Hungarian | `hu` | | Persian | `fa` | | Finnish | `fi` | | Polish | `pl` | | Latvian | `lv` | | Danish | `da` | | Belarusian | `be` | | Greek | `el` | | Ukrainian | `uk` | | Vietnamese | `vi` | | Thai | `th` | | Azerbaijani | `az` | ### Language rules - Send the code, not the country or language name: `language=az`, not `Azerbaijan`. The `language` field is required: `auto` or an explicit code. - STT and TTS have separate catalogs: use `ar`, `tl`, and `ne` here, not TTS's `arb`, `fil`, and `npi`. Kyrgyz (`ky`) is not available. - Accuracy and word timing vary by language and recording; review important transcripts, especially noisy or mixed-language audio. ### Automatic language detection - Send `-F 'language=auto'` for uploads, or `"language":"auto"` in a realtime STT start message. In Studio, choose **Auto** under Primary language. - Completed responses, history, and exports contain the detected code (for example `"language":"zh"`), never `auto`. Queued or failed jobs may retain the requested `auto` mode. - On retry, poll the original job and reuse its original request fields and idempotency key; do not replace `auto` with the detected code. - Auto detects one primary language. It does not do per-word detection and is unreliable for silence, very short phrases, or heavily mixed-language audio; send an explicit code for a known language. - Unsupported or missing detection fails the job instead of silently choosing Uzbek; failed jobs release their transcription credit reservation. Timestamp and speaker-label options work the same as with an explicit language. ## Check credits before upload **GET https://api.voicelab.uz/v1/stt/preflight** Estimate required credits before sending audio. Requires stt:write. Authentication: `Bearer vlk_...`. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/stt/preflight?duration_ms=60000' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` `duration_ms` is an integer estimate of the recording length, at least 500 ms and within the supported audio duration. Omission or `0` checks only the minimum 0.5-second cost. Optional `idempotency_key` can identify an existing upload on the same account; a recognized UUID returns `estimated_credits: 0`, so a retry can recover its existing result even when no credits remain. `200 OK` returns `can_upload: true`, integer `estimated_credits`, and `request_id`. Insufficient balance returns `402 insufficient_credits`; invalid duration returns `422 invalid_audio_duration`. This check reserves no credits and does not guarantee that a later upload will be accepted. Processing uses the actual decoded duration. Keep the same UUID on retries of the original upload. ## Transcribe audio **POST https://api.voicelab.uz/v1/stt** Upload an audio file for speech-to-text transcription. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. ### Headers | Header | Required | Value | | --- | --- | --- | | `Authorization` | yes | `Bearer ` | | `Idempotency-Key` | yes | A UUID in standard 36-character form | | `Content-Type` | yes | `multipart/form-data` with the generated boundary | | `X-Audio-Duration-Ms` | no | Estimated duration in milliseconds for the pre-upload credit check; actual decoded duration determines billing | Example idempotency key: `550e8400-e29b-41d4-a716-446655440000`. ### Multipart fields | Field | Required | Value | | --- | --- | --- | | `audio` | yes | One audio file | | `language` | yes | `auto` to detect the primary language, or one of the 33 [supported language codes](https://docs.voicelab.uz/api/stt#supported-languages) | | `include_speakers` | no | `true` or `false`; default: `false` | ### Upload limits | Limit | Value | | --- | --- | | Formats | MP3, WAV, M4A/AAC, OGG/Opus, WebM/Opus, or FLAC | | Minimum audio duration | 0.5 seconds | | Upload size | 500 MiB maximum | | Submission | `202 Accepted` for all newly accepted files, including short clips | The API validates the audio container and codec, not just the filename or MIME type. ### Request examples All examples send `meeting.mp3` with `language=en` and `include_speakers=false`. Submission requires `stt:write`; polling requires `stt:read` on the same account. Save a new UUID per upload and reuse it for retries. Python and TypeScript examples poll for the transcript. cURL and Java print a job ID; use it with [Get a transcription](https://docs.voicelab.uz/api/stt#get-a-transcription). ```bash curl -fS 'https://api.voicelab.uz/v1/stt' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ -F 'audio=@meeting.mp3;type=audio/mpeg' \ -F 'language=en' \ -F 'include_speakers=false' ``` ```python import os import time import uuid import requests base = "https://api.voicelab.uz" headers = {"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"} request_key = str(uuid.uuid4()) # Save for retries of this exact file/options. with open("meeting.mp3", "rb") as audio_file: response = requests.post( "https://api.voicelab.uz/v1/stt", headers={ **headers, "Idempotency-Key": request_key, }, files={"audio": ("meeting.mp3", audio_file, "audio/mpeg")}, data={"language": "en", "include_speakers": "false"}, timeout=120, ) response.raise_for_status() job_id = response.json()["id"] # Persist this ID to resume polling later. deadline = time.monotonic() + 900 while time.monotonic() < deadline: poll = requests.get(f"{base}/v1/stt/transcriptions/{job_id}", headers=headers, timeout=30) poll.raise_for_status() result = poll.json() if result["status"] == "completed": print(result["transcript"]) break if result["status"] == "failed": raise RuntimeError(result.get("error", "Transcription failed")) time.sleep(max(1, min(10, int(poll.headers.get("Retry-After", "1"))))) else: raise TimeoutError(f"Still pending: {job_id}; resume polling this ID, do not resubmit.") ``` ```ts import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const base = "https://api.voicelab.uz"; const headers = { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` }; const requestKey = randomUUID(); // Save for retries of this exact file/options. const form = new FormData(); const audio = new Uint8Array(await readFile("meeting.mp3")); form.append("audio", new Blob([audio], { type: "audio/mpeg" }), "meeting.mp3"); form.append("language", "en"); form.append("include_speakers", "false"); const response = await fetch("https://api.voicelab.uz/v1/stt", { method: "POST", headers: { ...headers, "Idempotency-Key": requestKey, }, body: form, signal: AbortSignal.timeout(120_000), }); if (!response.ok) throw new Error(await response.text()); const { id } = await response.json(); // Persist to resume polling later. const deadline = Date.now() + 900_000; let completed = false; while (Date.now() < deadline) { const poll = await fetch(`${base}/v1/stt/transcriptions/${encodeURIComponent(id)}`, { headers, signal: AbortSignal.timeout(30_000), }); if (!poll.ok) throw new Error(await poll.text()); const result = await poll.json(); if (result.status === "completed") { console.log(result.transcript); completed = true; break; } if (result.status === "failed") throw new Error(JSON.stringify(result.error ?? result)); const retryAfter = Number(poll.headers.get("Retry-After") ?? "1"); const seconds = Number.isFinite(retryAfter) ? Math.max(1, Math.min(10, retryAfter)) : 1; await new Promise(resolve => setTimeout(resolve, seconds * 1000)); } if (!completed) throw new Error(`Still pending: ${id}; resume polling this ID, do not resubmit.`); ``` ```java import java.io.ByteArrayOutputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.UUID; public final class Main { private static void addField( ByteArrayOutputStream body, String boundary, String name, String value ) throws Exception { body.write(("--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n").getBytes(StandardCharsets.UTF_8)); } public static void main(String[] args) throws Exception { String boundary = "VoiceLabBoundary" + System.currentTimeMillis(); ByteArrayOutputStream body = new ByteArrayOutputStream(); body.write(("--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"audio\"; filename=\"meeting.mp3\"\r\n" + "Content-Type: audio/mpeg\r\n\r\n").getBytes(StandardCharsets.UTF_8)); body.write(Files.readAllBytes(Path.of("meeting.mp3"))); body.write("\r\n".getBytes(StandardCharsets.UTF_8)); addField(body, boundary, "language", "en"); addField(body, boundary, "include_speakers", "false"); body.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.voicelab.uz/v1/stt")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .header("Idempotency-Key", UUID.randomUUID().toString()) // Persist for retries. .header("Content-Type", "multipart/form-data; boundary=" + boundary) .timeout(Duration.ofSeconds(120)) .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray())) .build(); HttpResponse response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); // 202 acknowledgement: poll its id, not transcript. } } ``` ### Submission response All newly accepted recordings return `202 Accepted` with a job ID: ```json {"id":"stt_01J...","status":"queued","request_id":"req_01J..."} ``` Poll `GET /v1/stt/transcriptions/{id}` using the same account's `stt:read` key. Respect `Retry-After` when present; otherwise poll about once per second with a bounded client deadline. A polling `200 OK` can still mean `queued` or `processing`. Only read `transcript` after `status=completed`; handle `status=failed` as a job failure, even when the polling HTTP status is `200`. ```bash curl -fS "https://api.voicelab.uz/v1/stt/transcriptions/$STT_JOB_ID" \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Processing time depends on duration, diarization, and queue load, not only file size. ### Completed polling response ```json { "id": "stt_01J...", "status": "completed", "transcript": "Hello world.", "language": "en", "duration_ms": 1420, "audio_available": true, "audio_url": "https://storage.example/signed-url...", "segments": [ { "ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Hello world.", "words": [ {"ordinal": 1, "start_ms": 120, "end_ms": 520, "text": "Hello"}, {"ordinal": 2, "start_ms": 540, "end_ms": 1320, "text": "world."} ] } ], "speakers": [], "speakers_available": false, "request_id": "req_01J..." } ``` `audio_url` is optional and short-lived. The API returns it only when private source-audio storage is configured. Word timing is returned when the provider supplies valid alignment, including for Chinese text without spaces; incomplete timing keeps the original caption instead of inventing word boundaries. If `words` is absent, use the segment's `start_ms` and `end_ms`; do not calculate word timing by dividing a segment. With `include_speakers=true`, segments can contain labels such as `speaker_1`, and `speakers` contains local display metadata. Labels apply only to that transcription, do not identify people across recordings, and ambiguous speech can be unassigned. ## Retry safely with idempotency - Use a new UUID for different audio, language, or fields. - After an upload timeout, retry the same file and fields with the same UUID. The API identifies the existing job without processing or charging again. Even a completed POST replay is an acknowledgement; GET the result by ID. - Reusing a UUID with different audio or fields returns `409 idempotency_key_reused`. ```json { "message": "Use a new Idempotency-Key for a different request.", "error": {"code": "idempotency_key_reused"}, "request_id": "req_01J..." } ``` ## Read transcription history ### List transcriptions **GET https://api.voicelab.uz/v1/stt/transcriptions?limit=10&cursor=** List saved transcriptions with cursor pagination. Authentication: `Bearer vlk_...`. Requires `stt:read`. The list limit is `10`. Treat `next_cursor` as opaque and send it unchanged. ```bash curl -fS 'https://api.voicelab.uz/v1/stt/transcriptions?limit=10&cursor=NEXT_CURSOR' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests response = requests.get( "https://api.voicelab.uz/v1/stt/transcriptions", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, params={"limit": 10, "cursor": "NEXT_CURSOR"}, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const url = new URL("https://api.voicelab.uz/v1/stt/transcriptions"); url.searchParams.set("limit", "10"); url.searchParams.set("cursor", "NEXT_CURSOR"); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; public final class Main { public static void main(String[] args) throws Exception { String cursor = URLEncoder.encode("NEXT_CURSOR", StandardCharsets.UTF_8); var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions?limit=10&cursor=" + cursor)) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` ```json { "data": [ { "id": "stt_01J...", "title": "meeting", "language": "en", "duration_ms": 1420, "audio_available": true, "created_at": "2026-08-16T12:00:00Z" } ], "next_cursor": null, "request_id": "req_01J..." } ``` ### Get a transcription **GET https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}** Read a complete transcription and its metadata. Authentication: `Bearer vlk_...`. The response contains the transcript, ordered segments, word timing, local speaker labels, revision, and optional signed source-audio URL. ```bash curl -fS 'https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests response = requests.get( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const response = await fetch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", { headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` ## Rename, edit, and delete ### Rename a transcription **PATCH https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}** Rename a saved transcription. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Requires `stt:write`. ```json {"title":"Customer interview"} ``` ```bash curl -fS -X PATCH 'https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Customer interview"}' ``` ```python import os import requests response = requests.patch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, json={"title": "Customer interview"}, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const response = await fetch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: "Customer interview" }), }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .header("Content-Type", "application/json") .method("PATCH", HttpRequest.BodyPublishers.ofString( "{\"title\":\"Customer interview\"}")) .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` `200 OK`: ```json { "id": "stt_01J...", "title": "Customer interview", "updated_at": "2026-08-16T12:05:00Z", "request_id": "req_01J..." } ``` ### Edit a transcription **PUT https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/editor** Atomically replace the editable transcript document. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Send the current `revision` from the `GET` response: ```json { "revision": 1, "title": "Customer interview", "segments": [ { "ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Hello world.", "speaker": "speaker_1" } ], "speakers": [ {"id": "speaker_1", "display_name": "Speaker 1"} ] } ``` ```bash curl -fS -X PUT 'https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../editor' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ --data-binary @- <<'JSON' { "revision": 1, "title": "Customer interview", "segments": [ {"ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Hello world.", "speaker": "speaker_1"} ], "speakers": [{"id": "speaker_1", "display_name": "Speaker 1"}] } JSON ``` ```python import os import requests document = { "revision": 1, "title": "Customer interview", "segments": [{ "ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Hello world.", "speaker": "speaker_1", }], "speakers": [{"id": "speaker_1", "display_name": "Speaker 1"}], } response = requests.put( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../editor", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, json=document, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const document = { revision: 1, title: "Customer interview", segments: [{ ordinal: 1, start_ms: 120, end_ms: 1320, text: "Hello world.", speaker: "speaker_1", }], speakers: [{ id: "speaker_1", display_name: "Speaker 1" }], }; const response = await fetch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../editor", { method: "PUT", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(document), }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { String document = """ { "revision": 1, "title": "Customer interview", "segments": [ {"ordinal": 1, "start_ms": 120, "end_ms": 1320, "text": "Hello world.", "speaker": "speaker_1"} ], "speakers": [{"id": "speaker_1", "display_name": "Speaker 1"}] } """; var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../editor")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .header("Content-Type", "application/json") .PUT(HttpRequest.BodyPublishers.ofString(document)) .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` A stale revision returns `409 stt_edit_conflict`. Reload and merge before retrying. For a historical result without timing segments, send `transcript` instead of `segments` and `speakers`. ### Delete a transcription **DELETE https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}** Delete a transcription and retained source audio. Authentication: `Bearer vlk_...`. ```bash curl -fS -X DELETE 'https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests response = requests.delete( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, timeout=30, ) response.raise_for_status() ``` ```ts const response = await fetch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` }, }, ); if (!response.ok) throw new Error(await response.text()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J...")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .DELETE() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.discarding()); if (response.statusCode() >= 400) { throw new RuntimeException("HTTP " + response.statusCode()); } } } ``` Requires `stt:write`. A successful deletion returns `204 No Content`. ## Export TXT, JSON, SRT, or VTT **GET https://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/export?format=json** Export a transcript as TXT, JSON, SRT, or VTT. Authentication: `Bearer vlk_...`. The response includes `Content-Disposition: attachment`. | Format | Contents | | --- | --- | | `txt` | Plain transcript | | `json` | Transcript, language, duration, speakers, and segments | | `srt` | SubRip captions | | `vtt` | WebVTT captions | SRT and VTT require timing segments. Without them, the API returns `409 stt_timestamps_not_available`. ```bash curl -fS 'https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../export?format=vtt' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -o meeting.vtt ``` ```python import os from pathlib import Path import requests response = requests.get( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../export", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, params={"format": "vtt"}, timeout=30, ) response.raise_for_status() Path("meeting.vtt").write_bytes(response.content) ``` ```ts import { writeFile } from "node:fs/promises"; const response = await fetch( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../export?format=vtt", { headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } }, ); if (!response.ok) throw new Error(await response.text()); await writeFile("meeting.vtt", Buffer.from(await response.arrayBuffer())); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; public final class Main { public static void main(String[] args) throws Exception { var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/stt/transcriptions/stt_01J.../export?format=vtt")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() >= 400) { throw new RuntimeException(new String(response.body())); } Files.write(Path.of("meeting.vtt"), response.body()); } } ``` ## STT errors | Status | Code | Meaning | | ---: | --- | --- | | `400` | `invalid_multipart`, `invalid_idempotency_key`, `invalid_pagination` | Fix fields or cursor | | `401` | `invalid_api_key` | API key is missing, expired, disabled, or invalid | | `402` | `insufficient_credits` | Account does not have enough credits | | `403` | `insufficient_scope` | Key permissions or allowed IP policy blocks the operation | | `404` | `stt_transcription_not_found` | ID is unknown or belongs to another account | | `409` | `idempotency_key_reused`, `stt_edit_conflict`, `stt_timestamps_not_available` | Resolve the state conflict | | `413` | `audio_too_large` | Use a smaller file | | `415` | `unsupported_audio_format` | Use a supported audio format | | `422` | `invalid_audio`, `unsupported_language`, `no_speech_detected`, `validation_error` | Correct the request | | `429` | `rate_limited` | Wait for `Retry-After` | | `503` | `stt_unavailable` | Retry later with the same idempotency key | --- Source: https://docs.voicelab.uz/api/tts # Text to Speech Generate mono, 16-bit PCM WAV audio at 24 kHz. Use a server-side API key with `tts:write` permission. ## Choose a model and compatible voice Use a key with `tts:read` to discover the models enabled for your account. Do not assume that every model is available: ```bash curl -fS 'https://api.voicelab.uz/v1/tts/languages' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Choose a public ID from `models[].id`, then list voices that support that model and language. For example, if the catalog returns `lison-v2`: ```bash curl -fS 'https://api.voicelab.uz/v1/voices?language=en&model_id=lison-v2' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Use the selected `models[].id` as `model_id` and a returned `data[].id` as `voice_id` in the generation request. Omit `model_id` to use the default Lison model (`lison`). `lison-v1` is accepted as an alias for `lison`. ## Generate speech **POST https://api.voicelab.uz/v1/tts** Generate a complete WAV file from text. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. | Field | Type | Required | Notes | | --- | --- | :---: | --- | | `text` | string | Yes | 1 to 5,000 Unicode characters; complete JSON body at most 64 KiB | | `language` | string | Yes | Code returned by `/v1/tts/languages` | | `voice_id` | string | Yes | Opaque `voice_...` ID from `/v1/voices` | | `model_id` | string | No | ID from `models[].id` in `/v1/tts/languages`; defaults to `lison` | | `speed` | number | No | `0.5` to `2.0`, default `1` | The selected voice must support both `model_id` and `language`. Omitted `model_id` defaults to `lison`. Omitted `speed` defaults to `1`. Explicit `0`, `null`, and strings are invalid for `speed`. Send a unique `Idempotency-Key` with each new generation. Reuse that key only when retrying the same body. Returns `200 audio/wav` if generation finishes within the request's wait budget. Otherwise, returns `202` JSON with `id` and `status_url`. Save only `200` audio as a WAV file. For `202`, [poll the job](https://docs.voicelab.uz/api/tts#generate-speech-in-the-background). The examples handle both responses. ```bash tts_http_status=$(curl -fS -w '%{http_code}' 'https://api.voicelab.uz/v1/tts' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: hello-world-001' \ -d '{ "text": "Hello from VoiceLab.", "language": "en", "voice_id": "voice_01J9NEUTRAL0000000000000001", "model_id": "lison-v2", "speed": 1 }' \ -o tts-response) case "$tts_http_status" in 200) mv tts-response hello.wav ;; 202) cat tts-response ;; # JSON job metadata; poll status_url *) cat tts-response >&2; exit 1 ;; esac ``` ```python import os import requests response = requests.post( "https://api.voicelab.uz/v1/tts", headers={ "Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}", "Idempotency-Key": "hello-world-001", }, json={ "text": "Hello from VoiceLab.", "language": "en", "voice_id": "voice_01J9NEUTRAL0000000000000001", "model_id": "lison-v2", "speed": 1, }, timeout=60, ) response.raise_for_status() if response.status_code == 202: print(response.json()) # Poll status_url; this is not audio yet. else: with open("hello.wav", "wb") as audio: audio.write(response.content) ``` ```ts import { writeFile } from "node:fs/promises"; const response = await fetch("https://api.voicelab.uz/v1/tts", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "hello-world-001", }, body: JSON.stringify({ text: "Hello from VoiceLab.", language: "en", voice_id: "voice_01J9NEUTRAL0000000000000001", model_id: "lison-v2", speed: 1, }), }); if (!response.ok) throw new Error(await response.text()); if (response.status === 202) { console.log(await response.json()); // Poll status_url; this is not audio yet. } else { await writeFile("hello.wav", Buffer.from(await response.arrayBuffer())); } ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; var json = """ {"text":"Hello from VoiceLab.","language":"en", "voice_id":"voice_01J9NEUTRAL0000000000000001", "model_id":"lison-v2","speed":1} """; var request = HttpRequest.newBuilder(URI.create("https://api.voicelab.uz/v1/tts")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", "hello-world-001") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() == 200) { Files.write(Path.of("hello.wav"), response.body()); } else if (response.statusCode() == 202) { System.out.println(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)); } else { throw new RuntimeException("TTS failed: " + response.statusCode()); } ``` `200 OK` returns the audio bytes, not JSON. ```http Content-Type: audio/wav X-VoiceLab-Characters-Used: 24 X-VoiceLab-Audio-Duration-Ms: 1180 X-VoiceLab-Sample-Rate: 24000 X-VoiceLab-Model-ID: lison-v2 ``` One Unicode character uses one credit. VoiceLab releases reserved credits if generation fails. ## Generate speech in the background **POST https://api.voicelab.uz/v1/tts/async** Queue speech generation and return a status URL. Requires tts:write. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Uses the same request fields, 5,000-character text limit and idempotency rules as `/v1/tts`. Grant `tts:read` as well to poll. The response is `202 Accepted`, with `Location`, `Retry-After: 1` and `X-VoiceLab-Generation-ID` headers: ```json { "id": "gen_EXAMPLE", "model_id": "lison-v2", "status": "queued", "status_url": "/v1/tts/generations/gen_EXAMPLE", "request_id": "req_EXAMPLE" } ``` Poll `GET /v1/tts/generations/{id}` using your API key and bounded backoff. Polling returns `200` JSON even while work is pending. Inspect `status`: | Status | Next step | | --- | --- | | `queued`, `processing` | Wait, then poll again | | `completed` | Download `audio_url`; the result includes `credits_used` and `credits_status: "charged"` | | `failed` | Read `error`; `credits_used` is 0 and `credits_status` is `released` | Download the signed audio URL without an API key or Authorization header. Refresh the generation to obtain another URL after expiry. Repeating an async submission returns `202` for the existing job, even when its status is already terminal; poll it to retrieve the result. Keep the job ID after a timeout and resume polling. Disconnecting or deleting does not cancel pending work. Use `/v1/tts/generations/{id}`; `/v1/tts/jobs` is retired. ## Available languages and models **GET https://api.voicelab.uz/v1/tts/languages** Return supported languages, models, formats, and sample rates. Authentication: `Bearer vlk_...`. Requires `tts:read`. Fetch this catalog instead of hardcoding language or model names. Send the chosen `models[].id` as `model_id` to `/v1/tts` or `/v1/tts/async`, and use the same ID to filter `/v1/voices`. ```json { "provider": "Lison-VoiceLab", "sample_rate": 24000, "data": [ {"code": "en", "name": "English", "native_name": "English"}, {"code": "uz", "name": "Uzbek", "native_name": "O'zbek"} ], "models": [ { "id": "lison", "languages": ["en", "uz", "ru"], "outputs": [{"format": "wav", "sample_rate": 24000, "streaming": false}] } ] } ``` ## Idempotency Keys must contain 8 to 128 letters, numbers, `.`, `_`, or `-` characters. - Retry a timed-out request with the same key and the same JSON body. - Use a new key when any input changes. - A synchronous replay returns the stored WAV if completed, or a `202` job acknowledgement while pending. Async replays return `202` job metadata. Both set `Idempotent-Replayed: true`. - Reusing a key with different input returns `409 idempotency_key_reused`. ## List generations **GET https://api.voicelab.uz/v1/tts/generations?limit=30&cursor=** List generated audio with cursor pagination. Authentication: `Bearer vlk_...`. ```bash curl -fS 'https://api.voicelab.uz/v1/tts/generations?limit=30' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests response = requests.get( "https://api.voicelab.uz/v1/tts/generations", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, params={"limit": 30}, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const response = await fetch( "https://api.voicelab.uz/v1/tts/generations?limit=30", { headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var request = HttpRequest.newBuilder( URI.create("https://api.voicelab.uz/v1/tts/generations?limit=30")) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` Requires `tts:read`. `limit` defaults to `30` and accepts `1` to `100`. ```json { "data": [ { "id": "gen_01J...", "text_preview": "Your order is ready.", "voice": {"id": "voice_01J...", "name": "Lison Neutral", "language": "en"}, "duration_ms": 1180, "created_at": "2026-08-16T12:00:00Z", "audio_available": true } ], "next_cursor": null, "request_id": "req_01J..." } ``` ## Get a generation **GET https://api.voicelab.uz/v1/tts/generations/{generation_id}** Read one generation and its short-lived audio URL. Authentication: `Bearer vlk_...`. ```bash curl -fS 'https://api.voicelab.uz/v1/tts/generations/gen_01J...' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests generation_id = "gen_01J..." response = requests.get( f"https://api.voicelab.uz/v1/tts/generations/{generation_id}", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, timeout=30, ) response.raise_for_status() print(response.json()) ``` ```ts const generationId = "gen_01J..."; const response = await fetch( `https://api.voicelab.uz/v1/tts/generations/${generationId}`, { headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var generationId = "gen_01J..."; var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/tts/generations/" + generationId)) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) throw new RuntimeException(response.body()); System.out.println(response.body()); } } ``` Pending generations return `status: "queued"` or `"processing"`; a `200` poll does not itself mean completion. See [background generation](https://docs.voicelab.uz/api/tts#generate-speech-in-the-background). For completed generations, `?download=1` requests a signed URL configured for attachment download; the API response remains JSON. Signed audio URLs expire after about 10 minutes and must not be exposed publicly. ```json { "id": "gen_01J...", "status": "completed", "text": "Your order is ready.", "voice": {"id": "voice_01J...", "name": "Lison Neutral", "language": "en"}, "speed": 1, "duration_ms": 1180, "sample_rate": 24000, "audio_url": "https://storage.example/signed-url...", "audio_url_expires_at": "2026-08-16T12:10:00Z", "request_id": "req_01J..." } ``` ## Delete a generation **DELETE https://api.voicelab.uz/v1/tts/generations/{generation_id}** Hide a completed generation from history. Requires tts:write. Authentication: `Bearer vlk_...`. ```bash curl -fS -X DELETE \ 'https://api.voicelab.uz/v1/tts/generations/gen_01J...' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ```python import os import requests generation_id = "gen_01J..." response = requests.delete( f"https://api.voicelab.uz/v1/tts/generations/{generation_id}", headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"}, timeout=30, ) response.raise_for_status() assert response.status_code == 204 ``` ```ts const generationId = "gen_01J..."; const response = await fetch( `https://api.voicelab.uz/v1/tts/generations/${generationId}`, { method: "DELETE", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` }, }, ); if (!response.ok) throw new Error(await response.text()); if (response.status !== 204) throw new Error(`Unexpected status: ${response.status}`); ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public final class Main { public static void main(String[] args) throws Exception { var generationId = "gen_01J..."; var request = HttpRequest.newBuilder(URI.create( "https://api.voicelab.uz/v1/tts/generations/" + generationId)) .header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY")) .DELETE() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.discarding()); if (response.statusCode() != 204) { throw new RuntimeException("Unexpected status: " + response.statusCode()); } } } ``` Requires `tts:write` and returns `204 No Content`. Deletion hides a completed generation from history; retained audio and database records follow the service's retention policy. It does not cancel pending work. Deleting a queued, processing or failed current-pipeline generation returns `409 generation_not_completed`. ## Realtime speech **WSS wss://api.voicelab.uz/v1/tts/stream** Stream raw 24 kHz PCM16 audio over one WebSocket. Authentication: `xvl-api-key: vlk_...`. Server clients authenticate during the WebSocket upgrade with `xvl-api-key`. The key needs `tts:realtime` permission. ```python import json import os from pathlib import Path import websocket ws = websocket.create_connection( "wss://api.voicelab.uz/v1/tts/stream", header=[f"xvl-api-key: {os.environ['VOICELAB_API_KEY']}"], timeout=30, ) print(json.loads(ws.recv())) # ready ws.send(json.dumps({ "text": "Hello from realtime VoiceLab.", "language": "en", "voice_id": "voice_01J9NEUTRAL0000000000000001", "speed": 1, })) with Path("speech.pcm").open("wb") as output: while True: frame = ws.recv() if isinstance(frame, bytes): output.write(frame) continue event = json.loads(frame) if event.get("event") in {"done", "error"}: break ws.close() ``` ```ts import { writeFile } from "node:fs/promises"; import WebSocket from "ws"; const apiKey = process.env.VOICELAB_API_KEY; if (!apiKey) throw new Error("VOICELAB_API_KEY is not set"); const ws = new WebSocket("wss://api.voicelab.uz/v1/tts/stream", { headers: { "xvl-api-key": apiKey }, }); const chunks: Buffer[] = []; ws.on("message", async (data, isBinary) => { if (isBinary) { chunks.push(Buffer.from(data)); return; } const event = JSON.parse(data.toString()); if (event.event === "ready") { ws.send(JSON.stringify({ text: "Hello from realtime VoiceLab.", language: "en", voice_id: "voice_01J9NEUTRAL0000000000000001", speed: 1, })); } if (event.event === "done") { await writeFile("speech.pcm", Buffer.concat(chunks)); ws.close(); } if (event.event === "error") throw new Error(event.message); }); ``` ```java import java.io.ByteArrayOutputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.CompletionStage; import java.util.concurrent.CountDownLatch; public final class RealtimeTts { public static void main(String[] args) throws Exception { var audio = new ByteArrayOutputStream(); var finished = new CountDownLatch(1); WebSocket.Listener listener = new WebSocket.Listener() { private final StringBuilder text = new StringBuilder(); @Override public void onOpen(WebSocket socket) { socket.request(1); } @Override public CompletionStage onText( WebSocket socket, CharSequence data, boolean last) { text.append(data); if (last) { String event = text.toString(); text.setLength(0); if (event.contains("\"ready\"")) { socket.sendText(""" {"text":"Hello from realtime VoiceLab.","language":"en", "voice_id":"voice_01J9NEUTRAL0000000000000001","speed":1} """, true); } if (event.contains("\"done\"") || event.contains("\"error\"")) { finished.countDown(); } } socket.request(1); return null; } @Override public CompletionStage onBinary( WebSocket socket, ByteBuffer data, boolean last) { byte[] chunk = new byte[data.remaining()]; data.get(chunk); audio.writeBytes(chunk); socket.request(1); return null; } }; WebSocket socket = HttpClient.newHttpClient().newWebSocketBuilder() .header("xvl-api-key", System.getenv("VOICELAB_API_KEY")) .buildAsync(URI.create("wss://api.voicelab.uz/v1/tts/stream"), listener) .join(); finished.await(); Files.write(Path.of("speech.pcm"), audio.toByteArray()); socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); } } ``` ```bash wscat -c 'wss://api.voicelab.uz/v1/tts/stream' \ -H "xvl-api-key: $VOICELAB_API_KEY" # Send after the ready event: {"text":"Hello from realtime VoiceLab.","language":"en","voice_id":"voice_01J9NEUTRAL0000000000000001","speed":1} ``` The server sends `ready` first: ```json {"event":"ready","format":"pcm_s16le","sample_rate":24000,"channels":1} ``` Send one text request per connection. `text` accepts up to 8 KiB, and `speed` accepts `0.5` to `2.0`. Binary frames contain signed 16-bit little-endian mono PCM. They are not WAV files. The final event is `{"event":"done","duration_ms":1420}`. Send `{"action":"interrupt"}` to stop active synthesis. The server returns `{"event":"interrupted"}`. ### Browser authentication Browsers cannot set `xvl-api-key` on a WebSocket. Your backend must mint a short-lived ticket: **POST https://api.voicelab.uz/v1/ticket** Mint a ticket for one browser WebSocket connection. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. ```json {"transport":"websocket","service":"tts"} ``` Both `transport` and `service` are required. Returns `201 Created`. Connect to `wss://api.voicelab.uz/v1/tts/stream?ticket=`. Both TTS and STT accept tickets or server-side `xvl-api-key`, but never both on one handshake. A ticket minted with `service=stt` cannot authorize TTS; create a fresh `service=tts` ticket. Tickets expire after about two minutes. Do not store them or include them in logs. Keep the long-lived key on your backend. Realtime errors include `invalid_message`, `validation_error`, `busy`, `not_busy`, `insufficient_credits`, `overloaded`, `timeout`, `service_unavailable`, and `generation_failed`. ## Errors | Status | Code | Fix | | ---: | --- | --- | | `400` | `invalid_json`, `invalid_idempotency_key` | Fix the body or key | | `401` | `invalid_api_key` | Send a valid API key | | `402` | `insufficient_credits` | Add credits | | `403` | `insufficient_scope` | Update key permissions or allowed IPs | | `404` | `voice_unavailable`, `generation_not_found` | Refresh the voice or generation ID | | `409` | `idempotency_key_reused` | Use a new key for changed input | | `422` | `validation_error` | Correct fields listed in `error.fields` | | `429` | `rate_limited`, `overloaded` | Wait for `Retry-After`, then retry | | `500`, `503` | `internal_error`, `service_unavailable` | Retry with the same idempotency key | --- Source: https://docs.voicelab.uz/api/voice-cloning # Voice Cloning Clone your voice for TTS with a reference recording and explicit terms/rights consent. Live confirmation is temporarily not required for developer API creation. ## Authentication and safeguards Use `Authorization: Bearer ` on every endpoint below. Grant `{"voices":"access"}` for the complete flow: `voices:read` reads capabilities, sessions and metadata; `voices:write` uploads, approves, verifies, edits and deletes. Existing read-only keys do not gain write access. Keys belong on your server, never in browser code. Proxy microphone recordings through your trusted backend. These developer routes do not accept a user JWT. Dashboard `/api/v1/account/voice-enrollments` routes remain JWT-only. All active paid plans qualify to create a clone. Free users cannot create a clone. Credit top-ups on a free plan do not unlock cloning. Reading or managing an existing clone does not require a currently active paid plan. Before uploading, obtain the voice owner's explicit agreement to the [Terms of Service](https://voicelab.uz/terms) and confirmation that they have the rights and consent to clone this voice. Send `consent_accepted=true` only after that agreement. Missing or false consent is rejected, even for paid accounts. Consent is a declaration of rights, **not proof of legal identity or guaranteed detection of synthetic voices**. The integration is responsible for obtaining the agreement; an API request cannot prove that a person read the terms. New developer clones require no manual transcript approval, Comet challenge or live speaker match. They are private, marked `not_required`, and must not be advertised as biometrically verified. The app's verified cloning flow is unchanged. ## Check capabilities **GET https://api.voicelab.uz/v1/voice-enrollments/capabilities** Check availability, account eligibility and reference limits. Authentication: `Bearer vlk_...`. ```json { "eligible": true, "available": true, "verification_required": false, "consent_required": true, "min_seconds": 10, "max_seconds": 30, "max_bytes": 10485760 } ``` `available` means reference cloning is configured, not that a speaker matcher is configured. Later requests can still fail if a provider is unavailable. ## Create a cloned voice **POST https://api.voicelab.uz/v1/voice-enrollments** Create a private clone from a reference recording and explicit terms/rights consent, without live confirmation. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. | Field | Required | Contract | | --- | --- | --- | | `audio` | Yes | Exactly one clear, single-speaker audio recording; 10–30 seconds; at most 10 MiB. Browser WebM/M4A and common audio formats are normalized server-side. | | `language` | Yes | A code supported by both [TTS](https://docs.voicelab.uz/api/tts) and [STT](https://docs.voicelab.uz/api/stt), such as `uz`, `en`, or `ru`. Fixed for the clone. | | `name` | Yes | 1–100 Unicode characters. | | `consent_accepted` | Yes | Literal `true`, after the voice owner accepts the terms of usage and confirms cloning rights. | | `description` | No | Up to 1,000 Unicode characters; defaults to empty. | | `labels` | No | JSON object; defaults to `{"language":""}`. See metadata rules below. | | `orb_palette` | No | Defaults to `cloud`; see allowed values below. | | `remove_noise` | No | Defaults to `true`. Literal `true` or `false`. `true` runs Voice Isolator denoising **and speech restoration** on the reference. Provider failure fails closed; no denoising-only fallback. | Send fields in the multipart body, not the URL. Unknown or repeated fields, including `make_public`, `expected_text` and provider IDs, are rejected. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/voice-enrollments' \ -H "Authorization: Bearer ${VOICELAB_API_KEY:?Set your server-side API key}" \ -F 'audio=@reference.wav' -F 'language=en' -F 'name=My narration voice' \ -F 'consent_accepted=true' -F 'remove_noise=true' ``` Returns `201 Created` after normalization, optional cleanup, ASR and registration. Save `voice_clone.id` for state lookup and `voice_clone.voice_id` for TTS. No separate confirmation requests are needed. Enrollment responses use this shape: ```json { "voice_clone": { "id": "vcs_EXAMPLE", "status": "ready", "language": "en", "reference_transcript": "The words spoken in the reference recording.", "name": "My narration voice", "consent_accepted_at": "2026-10-01T08:00:00Z", "verification_status": "not_required", "verification_method": "developer_consent_declared", "verification_attempts": 0, "voice_id": "voice_EXAMPLE", "public_requested": false, "created_at": "2026-10-01T08:00:00Z", "updated_at": "2026-10-01T08:00:00Z" }, "labels": { "language": "en" }, "orb_palette": "cloud", "remove_noise": true, "request_id": "req_EXAMPLE" } ``` Additional stage-dependent fields include name, description, `transcript_approved_at`, `consent_accepted_at`, `challenge_text`, `challenge_language`, `challenge_expires_at`, `verification_transcript`, `verification_method`, `verified_at`, `failure_code`, `voice_id`, and top-level `score`. New consent-only clones omit `verified_at`, `score`, live recordings and challenges. `transcript_approved_at` records automatic acceptance of the ASR reference text, not a claim that a human reviewed it. Missing optional fields are not proof of successful verification. Private storage paths, provider speaker references and account IDs are never returned. ## Legacy verified sessions The routes in this section remain available to finish a session created under the previous multi-step flow. Do not call them after new consent-only creation: a `ready` clone does not need another confirmation. Existing sessions are not silently upgraded or marked verified. Dashboard sessions still require their original transcript, consent and live checks. ### Approve the transcript **POST https://api.voicelab.uz/v1/voice-enrollments/{id}/transcript** Persist the owner's reviewed and corrected reference transcript. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Have the voice owner review and correct the transcript before submitting it. Send the full text, 1 to 20,000 Unicode characters. Returns `200` with `status: "details_pending"`. ```bash curl --fail-with-body -X POST \ 'https://api.voicelab.uz/v1/voice-enrollments/vcs_REPLACE_ME/transcript' \ -H "Authorization: Bearer ${VOICELAB_API_KEY:?Set your server-side API key}" \ -H 'Content-Type: application/json' \ -d '{"transcript":"The exact corrected words spoken in my reference recording."}' ``` ### Issue a live challenge **POST https://api.voicelab.uz/v1/voice-enrollments/{id}/challenge** Save metadata and consent, and issue a fresh microphone challenge. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. ```json { "name": "My narration voice", "description": "A calm voice for stories.", "labels": { "language": "en", "gender": "male", "age": "young", "accent": "Standard" }, "orb_palette": "cloud", "consent_accepted": true } ``` `name` is required, 1–100 Unicode characters; `description` can be empty and is limited to 1,000 characters. `labels.language` must match the reference language. Optional labels: `gender` (`male`, `female`, `neutral`), `age` (`young`, `middle-aged`, `old`), and a nonempty `accent` up to 80 UTF-8 bytes. Omit a label to remove it; do not send an empty label value. No other labels are accepted. `orb_palette` must be one of `cloud`, `support`, `storytelling`, `narrator`, `energetic`, `calm`, `delicate`, `steady`, `deep`, or `bright`. Consent must be explicitly `true` after the owner has acknowledged their rights. The API cannot change visibility or publish a voice. Returns `200` with `status: "verification_pending"`. Display `voice_clone.challenge_text` exactly as returned. New challenges contain reading text only, with no appended numbers or spoken verification code. Comet generates a neutral passage for roughly 12–15 seconds of reading. It receives only the language and a random seed, never the voice or transcript. Each request uses a fresh seed; repeated or nearly identical retry passages are rejected. A hidden server token identifies the single-use attempt. The challenge expires after ten minutes. Each verification submission consumes its challenge. To retry after a failed attempt or expiry, call this endpoint again with the metadata and consent, then record the **new** text. The session allows at most five verification attempts. Concurrent submissions cannot share a challenge. ### Verify and save **POST https://api.voicelab.uz/v1/voice-enrollments/{id}/verify** Check live speech content and speaker similarity before enabling a private voice. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. Send exactly one `audio` part containing a fresh microphone recording of the issued text, 3–60 seconds and at most 10 MiB. Do not send `expected_text`, replacement reference audio, or a client-supplied similarity score: the persisted server challenge and reference are authoritative. Voice matching runs first and requires a positive match with a finite score of at least `0.72`. STT then checks the whole reading, allowing ordinary transcription differences. Both checks must pass; correct text cannot override a failed voice match. Existing unexpired challenges keep their original spoken-code rules. Request a new challenge to use reading text only. ```bash curl --fail-with-body \ 'https://api.voicelab.uz/v1/voice-enrollments/vcs_REPLACE_ME/verify' \ -H "Authorization: Bearer ${VOICELAB_API_KEY:?Set your server-side API key}" \ -F 'audio=@fresh-live-challenge.wav' ``` Success is `200`. Require `voice_clone.status == "ready"`, `voice_clone.verification_status == "passed"` and a nonempty `voice_clone.voice_id`. `score` is returned at the top level. Do not use the session ID (`vcs_...`) as a TTS voice ID. The clone is private and accessible only to the owning account. ## Get enrollment state **GET https://api.voicelab.uz/v1/voice-enrollments/{id}** Read an account-owned enrollment and its persisted challenge/result. Authentication: `Bearer vlk_...`. Returns `200` with the shared envelope. After a lost response, inspect this state before resubmitting anything. Initialization does **not** implement `Idempotency-Key`; blindly repeating an upload may create a second session. Polling does not renew a challenge or restart processing. A timed-out verification can require a new challenge; never replay an old recording automatically. ## Use the cloned voice List voices with `GET /v1/voices?kind=custom`, or use the returned `voice_id` directly in [TTS](https://docs.voicelab.uz/api/tts). TTS requires a separate `tts:write` permission and normal usage billing applies. A voice ID does not grant another account access. Use these HTTP routes if your SDK has no cloning helper. ## Edit a cloned voice **GET https://api.voicelab.uz/v1/voices/{id}** Read editable metadata for your own enabled cloned voice. Authentication: `Bearer vlk_...`. Returns the metadata object directly, without a `data` wrapper: ```json { "name": "My narration voice", "description": "Calm", "labels": { "language": "en" }, "orb_palette": "cloud" } ``` Only your own enabled clones are accessible here. System voices, other users' clones, missing clones and deleted clones return `404`. The catalog's `can_manage` indicates ownership; changes still require write permission. **PATCH https://api.voicelab.uz/v1/voices/{id}** Replace editable metadata for your own cloned voice. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Send the complete metadata object above. Success is `204 No Content`. Name, description, labels and orb follow the same validation rules as enrollment. Reference audio, language, ownership, provider mapping, consent, verification and visibility cannot be edited. Unknown JSON fields are rejected. Omit optional labels from the complete `labels` map to remove them. ## Delete a cloned voice **DELETE https://api.voicelab.uz/v1/voices/{id}** Permanently revoke and remove your cloned voice and its private recordings. Authentication: `Bearer vlk_...`. Success is `204 No Content`; no body is needed. This is destructive: obtain the owner's explicit confirmation first. Deletion immediately revokes synthesis and removes saved links, then removes the private reference/live recordings, generated preview and inference registration. The audit record remains marked as deleted. System voices and other users' clones cannot be deleted. Repeating deletion for the same owned clone is safe. If cleanup fails, the clone stays revoked; retry the same DELETE to finish cleanup rather than uploading a replacement. ## Limits and errors Enrollment requests have a three-minute server deadline. Allow slightly longer on your client. Keep API keys out of URLs, audio filenames and logs. Enrollment abuse limits are separate from the general developer API's 70 RPS: initialization permits five/hour with burst two; transcript approval, challenge issuance and verification each permit ten/hour with burst three, per account per API replica. They are shared with dashboard attempts, not multiplied per key. At most five active custom voices are permitted per account. | HTTP | `error.code` | Action | | --- | --- | --- | | `401` / `403` | `invalid_api_key` / `insufficient_scope` | Check key, expiry, revocation, IP restrictions and read/write permission. | | `402` | `voice_clone_subscription_required` | Activate a paid plan before creation or legacy challenge/verification. | | `400` / `413` | `invalid_multipart`, `invalid_json`, `audio_too_large` | Fix the encoding/body or file size. | | `404` | `voice_clone_not_found` | Missing or inaccessible session/voice; never probe other accounts. | | `409` | `voice_clone_invalid_state`, `voice_limit_reached` | Inspect session state; complete prior steps or remove an owned clone. | | `410` | `voice_clone_challenge_expired` | Issue a fresh challenge and record its new text. | | `422` | `validation_failed`, `invalid_audio` | Correct metadata or use valid, clear audio within the duration limits. | | `422` | `challenge_mismatch`, `speaker_mismatch` | Obtain a new challenge and fresh recording; do not reuse audio. | | `429` | `voice_clone_rate_limited` | Respect `Retry-After`; do not rotate keys to bypass the account limit. | | `503` | `voice_cloning_unavailable`, `speaker_verification_unavailable` | No bypass; inspect state and retry with bounded backoff. | See [errors and security](https://docs.voicelab.uz/api/errors) for the common error envelope. Keep `request_id` for support, never API keys, private recordings or signed URLs. --- Source: https://docs.voicelab.uz/api/voice-isolator # Voice Isolator Remove background noise and optionally restore speech with Sidon. Upload audio, poll the job, and download the result. ## Authentication Use a server-side developer API key with **Voice Isolator → Access**, permission ID `audio_isolation`. Access grants `audio_isolation:read` and `audio_isolation:write`. Restricted keys need this permission added explicitly. See [API keys](https://docs.voicelab.uz/api/authentication). | Credential | Route prefix | Purpose | | --- | --- | --- | | Developer API key | `/v1/voice-isolations` | Server integrations documented here | | User access JWT | `/api/v1/voice-isolations` | VoiceLab dashboard | Jobs belong to the account. A permitted key can retrieve jobs created by another key or by the dashboard on that account. Other accounts cannot access them. ## Isolate audio **POST https://api.voicelab.uz/v1/voice-isolations** Submit an asynchronous audio isolation job. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. Requires `audio_isolation:write`. ### Headers and fields | Header | Required | Value | | --- | --- | --- | | `Authorization` | yes | `Bearer ` | | `Idempotency-Key` | yes | UUID generated once per logical upload; reuse for retries | | `Content-Type` | yes | Multipart boundary generated by your HTTP client | | Multipart field | Required | Value | | --- | --- | --- | | `file` | yes | One audio file | | `title` | no | Up to 512 UTF-8 bytes; default `Isolated audio` | | `speech_restoration` | no | Text `true` or `false`; default `false` | | `restoration_model` | no | `sidon`; valid only when restoration is `true` | Denoising always runs. Enabling restoration runs Sidon afterwards. New jobs do not accept `lavasr`. An unavailable restoration stage returns an error rather than silently changing the requested processing. The same idempotency key and content return the original job without another credit hold. Changing audio, title, or processing options with that key returns `409`. Keys are scoped per developer API key, separately from dashboard uploads. ### Limits | Limit | Value | | --- | --- | | Single upload | 300 MiB, or 314,572,800 bytes | | Batch upload | 1–4 files, 300 MiB combined | | Minimum decoded duration | 0.5 seconds per file | | Maximum recording duration | No separate duration limit; decode resource budgets and execution timeouts still apply | | Input | Exactly one audio stream; common MP3, WAV, M4A, AAC, OGG, WebM, FLAC and AIFF inputs | The API validates and decodes the audio contents. Multipart metadata has a separate small allowance. Capacity limits can return `429`; respect `Retry-After`. ### Request examples These examples run on your server. Set `VOICELAB_API_KEY` in the environment. Let the client generate the multipart boundary. Retain the generated UUID if you retry the upload. ```bash request_key=$(uuidgen) curl --fail-with-body -i 'https://api.voicelab.uz/v1/voice-isolations' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H "Idempotency-Key: $request_key" \ -F 'file=@speech.mp3' \ -F 'title=Interview' \ -F 'speech_restoration=true' ``` ```python import os import uuid import requests request_key = str(uuid.uuid4()) with open("speech.mp3", "rb") as audio: response = requests.post( "https://api.voicelab.uz/v1/voice-isolations", headers={ "Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}", "Idempotency-Key": request_key, }, files={"file": ("speech.mp3", audio, "audio/mpeg")}, data={"title": "Interview", "speech_restoration": "true"}, timeout=900, ) response.raise_for_status() print(response.json()["id"]) ``` ```ts import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const requestKey = randomUUID(); const form = new FormData(); form.append("file", new Blob([new Uint8Array(await readFile("speech.mp3"))]), "speech.mp3"); form.append("title", "Interview"); form.append("speech_restoration", "true"); const response = await fetch("https://api.voicelab.uz/v1/voice-isolations", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`, "Idempotency-Key": requestKey, }, body: form, signal: AbortSignal.timeout(900_000), }); if (!response.ok) throw new Error(`Upload failed: ${response.status}`); console.log((await response.json()).id); ``` ### Response and polling Returns `202 Accepted`, `Location: /v1/voice-isolations/{id}`, and `Retry-After: 3`. Retries return the existing job's current status. This example uses an illustrative quote of 120 credits: ```json { "id": "iso_550e8400-e29b-41d4-a716-446655440000", "title": "Interview", "status": "queued", "duration_ms": 60000, "credits": 120, "audio_available": false, "audio_format": "mp3", "speech_restoration": true, "restoration_model": "sidon", "created_at": "2026-09-08T10:00:00Z", "updated_at": "2026-09-08T10:00:00Z", "request_id": "req_example" } ``` `credits` is the reserved quote. The configured rate determines the amount; this example is not a fixed tariff. | Field | Meaning | | --- | --- | | `status` | `queued`, `running`, `completed`, or `failed` | | `processing_stage` | When present: `noise_reduction`, `speech_restoration`, or `finalizing` | | `audio_available` | A retained processed master is available; this alone does not mean MP3 is ready | | `audio_status` | Default MP3 preparation: `queued`, `running`, `completed`, or `failed`, when present | | `audio_url` | Temporary MP3 download URL once preparation completes | | `error_code` | Safe failure code when the job fails | | `completed_at` | Processing completion timestamp, when present | | `original_filename`, `original_content_type`, `original_available` | Retained upload metadata | | `original_audio_url`, `input_audio_url`, `denoised_audio_url` | Detail-only signed links for available original, normalized input, and denoised audio | | `output_sample_rate` | Output sample rate; does not establish job completion | | `batch_id`, `batch_index` | Batch membership and upload order | Poll every three seconds until both `status` and `audio_status` are `completed`, then download `audio_url`. Stop if processing or MP3 conversion fails. Voice Isolator has no completion webhooks. ## Batch isolation **POST https://api.voicelab.uz/v1/voice-isolations/batch** Submit one to four files atomically. Authentication: `Bearer vlk_...`. Content-Type: `multipart/form-data`. Requires `audio_isolation:write` and a UUID `Idempotency-Key`. Send repeated `files` parts with the same optional `speech_restoration` and `restoration_model` fields as a single upload. Processing options apply to every file; filenames become job titles. ```bash request_key=$(uuidgen) curl --fail-with-body 'https://api.voicelab.uz/v1/voice-isolations/batch' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H "Idempotency-Key: $request_key" \ -F 'files=@first.mp3' \ -F 'files=@second.wav' \ -F 'speech_restoration=true' ``` Returns `202` with an `isb_…` batch `id` and jobs in `data`, preserving `batch_index`. Validation and credit reservation are atomic: an invalid or unaffordable batch admits no jobs. Preserve file order, filenames, bytes and options on retry. Poll each child job by ID; there is no separate batch polling route. ## List isolations **GET https://api.voicelab.uz/v1/voice-isolations** List the account's visible isolation history. Authentication: `Bearer vlk_...`. Requires `audio_isolation:read`. Optional `limit` defaults to 20 and accepts 1–50. Pass the returned opaque `next_cursor` as `cursor` for the next page. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/voice-isolations?limit=20' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Returns `200` with `data` containing jobs in descending creation order and optional `next_cursor`. History includes all job states. List responses omit signed audio URLs; request detail for playback or downloads. ## Get an isolation **GET https://api.voicelab.uz/v1/voice-isolations/{id}** Read job state and renew available audio URLs. Authentication: `Bearer vlk_...`. Requires `audio_isolation:read`. Returns `200` with the job. Unknown jobs and jobs belonging to another account return `404`. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/voice-isolations/iso_REPLACE_ME' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Signed audio URLs expire after ten minutes. Fetch detail again for fresh URLs. Download the returned storage URL without forwarding your API key. Store the job ID for future access rather than relying on an old URL. ## Hide an isolation **DELETE https://api.voicelab.uz/v1/voice-isolations/{id}** Hide a job from history without erasing its audio. Authentication: `Bearer vlk_...`. Requires `audio_isolation:write`. Returns `204 No Content` on success. Hiding does not cancel processing, refund completed work, or delete stored files. Original uploads, normalized inputs, denoised/restored audio and exports are retained indefinitely, including hidden jobs. ```bash curl --fail-with-body -X DELETE 'https://api.voicelab.uz/v1/voice-isolations/iso_REPLACE_ME' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` ## Create an export **POST https://api.voicelab.uz/v1/voice-isolations/{id}/exports** Prepare a download in the requested format. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Requires `audio_isolation:write`. Send `{"format":"flac"}`. Supported values are `mp3`, `wav`, `flac`, `ogg`, and `original` for the untouched upload. Processed exports require an available final master; otherwise the API returns `409 audio_not_ready`. The original upload can be available before processing completes. ```bash curl --fail-with-body -X POST 'https://api.voicelab.uz/v1/voice-isolations/iso_REPLACE_ME/exports' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"format":"flac"}' ``` Queued/running exports return `202` with `Retry-After: 2`. Completed or failed exports return `200`; inspect `status`. Exports reuse stored audio, do not rerun inference, and do not charge additional credits. ## Get an export **GET https://api.voicelab.uz/v1/voice-isolations/{id}/exports/{format}** Poll an export or retrieve its current download URL. Authentication: `Bearer vlk_...`. Requires `audio_isolation:read`. A read-only key can retrieve existing exports; requesting a new conversion requires write permission. Poll queued/running conversions every two seconds. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/voice-isolations/iso_REPLACE_ME/exports/flac' \ -H "Authorization: Bearer $VOICELAB_API_KEY" ``` Example completed response: ```json { "job_id": "iso_550e8400-e29b-41d4-a716-446655440000", "format": "flac", "status": "completed", "filename": "Interview.flac", "audio_url": "https://storage.example.com/audio.flac?signature=EXAMPLE" } ``` Download `audio_url` without an Authorization header. ## Billing and errors Credits are reserved at admission, rounded up per file at the configured per-minute rate, and debited once when processing succeeds. Terminal processing failures release the hold. Sidon restoration and exports have no separate surcharge. MP3 readiness is separate from successful inference and billing. See [pricing](https://docs.voicelab.uz/guide/pricing). A key's credit limit includes its recorded billable usage and pending isolation reservations. Revoking a key prevents new access but does not cancel accepted jobs. Usage is attributed to the submitting key under `voice_enhancer` in [analytics](https://docs.voicelab.uz/api/analytics). | HTTP status | Meaning | | --- | --- | | `400` | Missing/invalid UUID, malformed multipart or export JSON, invalid pagination | | `401` | Invalid, expired, disabled or revoked API key | | `403` | Missing scope or denied source IP | | `402` | Insufficient credits or `api_key_credit_limit` | | `404` | Feature unavailable, unknown job or another account's job | | `409` | Conflicting idempotent retry or `audio_not_ready` | | `413` | Upload exceeds the size limit | | `422` | Invalid audio, processing options or export format | | `429` | Upload or job capacity exceeded | | `503` | Processing service or requested restoration stage unavailable | Honor `Retry-After` for capacity failures. Retry transient upload failures with the original idempotency key and identical content. Do not retry authorization or validation failures unchanged. Keep `request_id` for support. See [errors and limits](https://docs.voicelab.uz/api/errors). --- Source: https://docs.voicelab.uz/api/voices # Voices and TTS capabilities Find voices and supported languages, models, and audio formats for TTS. ## List voices **GET https://api.voicelab.uz/v1/voices?language=en** List voices available for a supported language. Authentication: `Bearer vlk_...`. Requires `voices:read`. `language` is optional; if supplied it must be a code returned by `GET /v1/tts/languages`. Use `/v1/voices` as the canonical route. `GET /v1/tts/voices` is a compatibility alias with identical authentication, visibility, filters and pagination. Example response: ```json { "data": [ { "id": "voice_01J9NEUTRAL0000000000000001", "display_name": "Lison Neutral", "description": "A clear, balanced voice for everyday narration.", "language": "en", "kind": "system", "visibility": "public", "short_description": "Tiniq · muvozanatli", "orb_palette": "calm" } ], "request_id": "req_01J..." } ``` Use the returned opaque `id` as `voice_id` in TTS requests. Do not use a display name or an internal provider/model identifier. The catalog is account-scoped. Unrestricted system voices are available to all authenticated accounts; restricted system voices appear only for allowed users. Private custom voices are returned only to their owner. Approved public custom voices may also appear, subject to language and availability rules. A voice ID does not bypass authorization for synthesis. Own cloned voices include `can_manage: true`; system and foreign voices do not. This flag indicates ownership, not this key's write permission. Create a verified private clone or edit/delete your existing clone through [Voice Cloning](https://docs.voicelab.uz/api/voice-cloning). Mutation requires `voices:write`. ## List languages and model metadata **GET https://api.voicelab.uz/v1/tts/languages** List supported languages and public TTS model metadata. Authentication: `Bearer vlk_...`. Requires `tts:read`. The response contains: - `provider`: public product name, currently `Lison-VoiceLab`. - `sample_rate`: current default output rate, currently `24000`. - `data`: enabled language codes and names. - `models`: public model cards and output formats. The catalog shows `VoiceLab Lison` with the `Studio Quality` badge. Internal provider and model names are not exposed. Example model output metadata: ```json { "id": "lison", "name": "VoiceLab Lison", "badge": "Studio Quality", "description": "Natural, expressive multilingual speech for voiceovers, audiobooks, narration, and content creation.", "recommended": true, "languages": ["en", "uz", "ru"], "outputs": [ { "format": "wav", "label": "WAV 24 kHz (Lossless)", "content_type": "audio/wav", "sample_rate": 24000, "streaming": false } ] } ``` Use `models[].id` as the public `model_id` in TTS requests. Only select a model returned by the catalog for the current account. ## Language selection in a client 1. Call `/v1/tts/languages` with a key that has `tts:read`. 2. Choose a model from `models[].id` and a supported language from that model's `languages` array. 3. Call `/v1/voices?language={code}&model_id={model_id}` with a key that has `voices:read`. 4. Present the returned voice names to the user. 5. Send the selected model ID as `model_id` and `data[].id` as `voice_id` to `/v1/tts`. Do not hardcode a list of languages or assume every voice supports every language. The catalog can change without changing the API version. ## Current output limitations REST TTS returns complete WAV audio: 24,000 Hz, mono, 16-bit PCM. Offer other formats, bitrates or sample rates only when the catalog's `outputs` array lists them. Realtime TTS uses a separate [raw PCM protocol](https://docs.voicelab.uz/api/tts#realtime-speech). --- Source: https://docs.voicelab.uz/api/webhooks # Webhooks Receive signed audio processing events and track or retry their delivery. ## Authentication and availability Every endpoint on this page requires a server-side API key with `{"webhooks":"access"}`, equivalent to `webhooks:write`. This scope is required for reads too; there is no separate `webhooks:read` scope. Subscriptions and delivery history belong to the account, not an individual key. Requests return `503 webhooks_unavailable` when webhooks are disabled. TTS emits completion events. The STT event types listed below apply only to the older transcription pipeline. Current `/v1/stt` jobs require [polling](https://docs.voicelab.uz/api/stt#get-a-transcription); they do not emit webhook events. ## Create a subscription **POST https://api.voicelab.uz/v1/webhooks** Create an account-owned webhook and return its signing secret once. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. ```bash curl --fail-with-body 'https://api.voicelab.uz/v1/webhooks' \ -H "Authorization: Bearer $VOICELAB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"url":"https://your-app.example/webhooks/voicelab","event_types":["tts.generation.completed"]}' ``` Replace the example URL with your HTTPS receiver. The URL must use a public DNS hostname and port 443, with no credentials or fragment. IP literals, localhost, private network destinations and redirects are not supported. URLs are limited to 2,048 characters. JSON request bodies are limited to 16 KiB; unknown fields are rejected. `event_types` must contain one to four distinct supported values: | Event | `data` fields | | --- | --- | | `tts.generation.completed` | `object: "tts_generation"`, `id`, `status: "completed"`, `duration_ms`, `credits_used` | | `stt.transcription.queued` | `object: "transcription"`, `id`, `status: "queued"`, `duration_ms` | | `stt.transcription.completed` | `object: "transcription"`, `id`, `status: "completed"`, `language`, `duration_ms` | | `stt.transcription.failed` | `object: "transcription"`, `id`, `status: "failed"`, `error_code` | Each account can have up to 20 active subscriptions. `201 Created` returns: ```json { "webhook": { "id": "wh_EXAMPLE", "url": "https://your-app.example/webhooks/voicelab", "event_types": ["tts.generation.completed"], "secret_suffix": "abcd", "enabled": true, "revision": 1, "created_at": "2026-10-01T08:00:00Z", "updated_at": "2026-10-01T08:00:00Z", "disabled_at": null }, "secret": "whsec_EXAMPLEabcd", "request_id": "req_EXAMPLE" } ``` Store `secret` securely. It is returned only on creation and rotation, never in normal reads. It is a signing secret, not an API key. Creation has no idempotency-key support. If the response is lost, check the subscription list before creating another one. ## List subscriptions **GET https://api.voicelab.uz/v1/webhooks** List account-owned webhook subscriptions. Authentication: `Bearer vlk_...`. Accepts `page` from 1–100000 and `page_size` from 1–100, defaulting to 1 and 50. Unknown or repeated query parameters are rejected. Returns `200` with `webhooks`, `pagination`, and `request_id`. Pagination contains `total`, `total_pages`, `page`, `page_size`, `has_next`, `has_previous`, `next_page`, and `previous_page`; unavailable next/previous pages are `null`. ## Get a subscription **GET https://api.voicelab.uz/v1/webhooks/{id}** Read a webhook's configuration and current revision. Authentication: `Bearer vlk_...`. Returns `200` with `webhook` and `request_id`, without `secret`. No query parameters are accepted. ## Update a subscription **PATCH https://api.voicelab.uz/v1/webhooks/{id}** Change a webhook URL, event types or enabled state. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Send at least one of `url`, `event_types`, or `enabled`. Omitted fields remain unchanged. `event_types` replaces the complete list and cannot be empty or null. Include the last observed `expected_revision` to prevent overwriting concurrent changes: ```json {"enabled":false,"expected_revision":1} ``` Returns `200` with `webhook` and `request_id`. A stale revision returns `409 webhook_revision_conflict`; fetch the latest configuration before retrying. Disabling cancels pending/retrying deliveries. A delivery already in flight may still reach your receiver. Re-enabling does not replay canceled deliveries. ## Delete a subscription **DELETE https://api.voicelab.uz/v1/webhooks/{id}** Disable a subscription and cancel pending/retrying deliveries. Authentication: `Bearer vlk_...`. Content-Type: `application/json`. Send a JSON object, preferably `{"expected_revision":2}` using the current revision, or `{}` to omit the revision check. An empty body is invalid. Success is `204 No Content`. This operation disables the subscription; it does not erase its metadata or delivery history. In-flight delivery can still finish. ## Rotate the signing secret **POST https://api.voicelab.uz/v1/webhooks/{id}/secret/rotate** Issue a new signing secret for future deliveries. Authentication: `Bearer vlk_...`. No body or query parameters are needed. Returns `200` with `webhook`, the new `secret`, and `request_id`. Deliveries retain the URL and secret captured when they were enqueued. Keep the previous secret available in your receiver until old pending/retrying deliveries are resolved. URL changes also apply only to newly queued deliveries. ## Send a test event **POST https://api.voicelab.uz/v1/webhooks/{id}/test** Queue a test callback for an enabled subscription. Authentication: `Bearer vlk_...`. No body or query parameters are needed. Returns `202` with `delivery` and `request_id`. This confirms queueing, not successful delivery. The callback type is `webhook.test`, with `data: {"object":"webhook_test"}`. Do not include this test type in the subscription's `event_types` list. ## List deliveries **GET https://api.voicelab.uz/v1/webhooks/{id}/deliveries** Inspect delivery status and response metadata. Authentication: `Bearer vlk_...`. Accepts the same `page` and `page_size` parameters as subscriptions. Returns `200` with `deliveries`, `pagination`, and `request_id`. Each delivery contains `id`, `event_id`, `event_type`, `status`, `attempt_count`, `response_status`, optional `error_code`, `created_at`, `updated_at`, `delivered_at`, and `next_attempt_at`. Pending response/timestamp values may be `null`. States are `pending`, `delivering`, `retrying`, `succeeded`, `failed`, or `canceled`. Receiver response bodies and event payloads are not exposed in this list. ## Retry a delivery **POST https://api.voicelab.uz/v1/webhooks/{id}/deliveries/{delivery_id}/retry** Queue another attempt for a failed delivery on an enabled subscription. Authentication: `Bearer vlk_...`. No body or query parameters are needed. Returns `202` with `delivery` and `request_id`. Only failed deliveries on enabled subscriptions can be retried; otherwise the response is `409 webhook_delivery_not_retryable`. Retrying keeps the same event/delivery IDs, captured URL/secret, and attempt count. ## Verify callbacks Callbacks are JSON POST requests. They contain resource metadata only, never transcript text, synthesis text, audio bytes, API keys, or signed audio URLs. After verification, retrieve results through the authenticated resource endpoint. ```json { "id": "whevt_EXAMPLE", "type": "tts.generation.completed", "created_at": "2026-10-01T08:00:00Z", "api_version": "2026-08-22", "data": {"object":"tts_generation","id":"gen_EXAMPLE","status":"completed","duration_ms":1180,"credits_used":24} } ``` Headers include `X-VoiceLab-Event`, `X-VoiceLab-Event-ID`, `X-VoiceLab-Delivery-ID`, `X-VoiceLab-Timestamp` and `X-VoiceLab-Signature`. The timestamp is Unix seconds. The signature is `v1=` followed by the lowercase hex HMAC-SHA256 of `timestamp + "." + rawBody`, keyed by the literal signing secret, including its `whsec_` prefix. Do not decode that secret or reserialize the body before verification. This Node.js verifier accepts a five-minute timestamp window as a receiver policy. Keep your server clock synchronized. Pass the raw request bytes before any JSON body parser, and header values from the names above. ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verifyWebhook(rawBody, timestamp, signature, secrets, now = Date.now()) { if (!Buffer.isBuffer(rawBody) || typeof timestamp !== "string" || !/^\d+$/.test(timestamp) || typeof signature !== "string" || !/^v1=[0-9a-f]{64}$/.test(signature)) return false; const seconds = Number(timestamp); if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(now / 1000) - seconds) > 300) return false; const supplied = Buffer.from(signature.slice(3), "hex"); return secrets.some(secret => { const expected = createHmac("sha256", secret) .update(timestamp + ".").update(rawBody).digest(); return timingSafeEqual(expected, supplied); }); } ``` Reject invalid signatures before parsing or processing the payload. After verification, deduplicate using the signed body's event `id`, durably enqueue your work, and return a `2xx` response promptly. The receiver timeout is ten seconds. Delivery is at least once; handle duplicates and do not depend on order. Network failures, `408`, `409`, `425`, `429`, and `5xx` responses are retried, up to eight attempts. Backoff is 5 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 3 hours, then 12 hours. Other non-`2xx` responses fail without automatic retry. Redirects are not followed. Delivery timestamps/signatures are renewed per attempt; event IDs remain stable. ## Errors | HTTP | Code | Recovery | | --- | --- | --- | | `400` | `invalid_json`, `invalid_filter`, `invalid_pagination` | Correct the body or query | | `404` | `webhook_not_found` | Check account ownership and resource IDs | | `409` | `webhook_limit_reached` | Disable an unused subscription | | `409` | `webhook_revision_conflict` | Fetch the latest revision | | `409` | `webhook_delivery_not_retryable` | Inspect delivery and subscription states | | `422` | `validation_error` | Check the URL, event list and editable fields | | `503` | `webhooks_unavailable` | Check feature availability; use resource polling while unavailable | Authentication errors follow the [common API rules](https://docs.voicelab.uz/api/errors).