Theme
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
Mint a short-lived ticket for one realtime STT connection.
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
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<String>();
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, 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.