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.

## 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` |
| `speed` | number | No | `0.5` to `2.0`, default `1` |

Omitted `speed` defaults to `1`. Explicit `0`, `null`, and strings are invalid.

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",
    "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",
        "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",
    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","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
```

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",
  "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.

```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=<opaque>**

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=<short-lived-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 |
