Skip to content

Speech-to-Text (STT)

Upload audio for transcription, speaker labels, timing data, history, editing, and export.

Transcribe audio

POSThttps://api.voicelab.uz/v1/stt

Upload an audio file for speech-to-text transcription.

Headers

HeaderRequiredValue
AuthorizationyesBearer <VOICELAB_API_KEY>
Idempotency-KeyyesA UUID in standard 36-character form
Content-Typeyesmultipart/form-data with the generated boundary

Example idempotency key: 550e8400-e29b-41d4-a716-446655440000.

Multipart fields

FieldRequiredValue
audioyesOne audio file
languageyesuz, en, or ru
include_speakersnotrue or false; default: false

Upload limits

LimitValue
FormatsMP3, WAV, M4A/AAC, OGG/Opus, WebM/Opus, or FLAC
Synchronous duration0.5 to 30 seconds
Synchronous file size10 MiB maximum

The API inspects the actual container and codec. A filename or browser MIME type does not prove that a file is valid.

Request examples

All examples send meeting.mp3 with language=en and include_speakers=false. Generate a new UUID for each new request.

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 requests

with open("meeting.mp3", "rb") as audio_file:
    response = requests.post(
        "https://api.voicelab.uz/v1/stt",
        headers={
            "Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}",
            "Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
        },
        files={"audio": ("meeting.mp3", audio_file, "audio/mpeg")},
        data={"language": "en", "include_speakers": "false"},
        timeout=120,
    )

response.raise_for_status()
print(response.json()["transcript"])
ts
import { readFile } from "node:fs/promises";

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: {
    Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`,
    "Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
  },
  body: form,
});

if (!response.ok) throw new Error(await response.text());
const result = await response.json();
console.log(result.transcript);
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;

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", "550e8400-e29b-41d4-a716-446655440000")
        .header("Content-Type", "multipart/form-data; boundary=" + boundary)
        .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
        .build();

    HttpResponse<String> response = HttpClient.newHttpClient().send(
        request, HttpResponse.BodyHandlers.ofString()
    );
    if (response.statusCode() >= 400) throw new RuntimeException(response.body());
    System.out.println(response.body());
  }
}

Response

Short audio returns 200 OK:

json
{
  "id": "stt_01J...",
  "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.

The API returns word timing when the provider supplies valid alignment. 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 and do not identify people across recordings.

Long audio

When the durable worker is enabled, the same POST /v1/stt endpoint accepts audio longer than 30 seconds and files up to 500 MiB. It returns:

http
202 Accepted
json
{
  "id": "stt_01J...",
  "status": "queued",
  "request_id": "req_01J..."
}

Poll GET /v1/stt/transcriptions/{id} until status is completed or the response reports a failure. If long-audio processing is disabled, the API returns 503 long_stt_unavailable.

Retry safely with idempotency

  • Use a new UUID for different audio, language, or fields.
  • After a timeout, retry the same file and fields with the same UUID. The API replays the existing result or queued job without processing or charging again.
  • 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

GEThttps://api.voicelab.uz/v1/stt/transcriptions?limit=10&cursor=<opaque>

List saved transcriptions with cursor pagination.

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

GEThttps://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}

Read a complete transcription and its metadata.

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

PATCHhttps://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}

Rename a saved transcription.

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

PUThttps://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/editor

Atomically replace the editable transcript document.

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

DELETEhttps://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}

Delete a transcription and retained source audio.

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

GEThttps://api.voicelab.uz/v1/stt/transcriptions/{transcription_id}/export?format=json

Export a transcript as TXT, JSON, SRT, or VTT.

The response includes Content-Disposition: attachment.

FormatContents
txtPlain transcript
jsonTranscript, language, duration, speakers, and segments
srtSubRip captions
vttWebVTT captions

SRT and VTT require stored timing segments. Without them, the API returns 409 stt_timestamps_not_available and does not fabricate timestamps.

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

StatusCodeMeaning
400invalid_multipart, invalid_idempotency_key, invalid_paginationFix fields or cursor
401invalid_api_keyAPI key is missing, expired, disabled, or invalid
402insufficient_creditsAccount does not have enough credits
403insufficient_scopeKey permissions or allowed IP policy blocks the operation
404stt_transcription_not_foundID is unknown or belongs to another account
409idempotency_key_reused, stt_edit_conflict, stt_timestamps_not_availableResolve the state conflict
413audio_too_largeUse a smaller file
415unsupported_audio_formatUse a supported audio format
422invalid_audio, unsupported_language, no_speech_detected, validation_errorCorrect the request
429rate_limitedWait for Retry-After
503stt_unavailable, long_stt_unavailableRetry later