Transcribe recordings

The transcription endpoint turns a recording of Thai speech, or of another language, into a verbatim transcript, with word timestamps on request. Billing is by recording length, charged before transcription and refunded automatically on failure.

View as Markdown

POST /v1/stt reads a recording and returns what was said. Send the file as base64 in audio with the model id paxa-stt-lite-v1-preview. The transcript comes back verbatim, in the language spoken: a Thai sentence that switches into English mid-way is written as spoken. Nothing is translated. Like every endpoint, the request is charged before inference and refunded automatically when inference fails (Credits).

Note

paxa-stt-lite-v1-preview is a research preview. Requests bill at the published rate. The transcript quality and the option set may change between releases, and the changelog on the product page records each change.

# Encode without line wrapping: wrapped base64 breaks the JSON string.
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
# --max-time covers an hour-long recording; curl defaults to no limit.
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\"}"
Response
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
  "usage": {
    "seconds": 11.952,
    "credits": 1.66
  }
}

Billing#

A recording costs 8.33 credits per minute of audio, 500 credits per hour, billed by its exact length. The length is read from the file itself before the charge. The cost is known before any listening starts. Every request costs at least 0.1 credits. Word timestamps, speaker turns, the style, the language hint, the convention, and the vocabulary cost nothing extra.

The charge is reported only in the body's usage.credits, beside usage.seconds, the length that was billed. This endpoint sends no x-credits-charged header. A fast response and a slow one carry the same wire surface. When transcription fails after the charge, the refund is automatic and the response is 502 provider_error. A recording with no speech is a delivered empty transcript and is billed for its length.

Recordings and formats#

audio carries the raw file bytes as base64. Seven formats are read: MP3, WAV (PCM), FLAC, Ogg (Opus or Vorbis), M4A, AAC (ADTS), and WebM. The format and the length are read from the bytes. No declared field is consulted. A file that cannot be read as one of the seven answers 400 audio_invalid. A file whose length cannot be read from its container, such as a FLAC written without a sample count, answers the same code. None of this charges.

Base64 grows a payload by about a third, and a compressed file travels faster: Opus at 24 kbps or MP3 at 64 kbps carries an hour of speech under the size limit. A recording captured in the browser with MediaRecorder is a WebM file. Its length is read from its blocks, and it needs no re-encoding.

Length and size limits#

One request transcribes at most 60 minutes, and a longer recording answers 400 audio_too_long. The decoded file may be at most 25 MiB, and a larger one answers 413 audio_too_large. Neither refusal charges anything. Split longer recordings at a pause and send the parts as separate requests.

Speakers have a shorter ceiling of their own: a recording of 9 minutes or longer with diarization set to true answers 400 diarization_audio_too_long, uncharged. The same recording transcribes normally with the field off.

Language#

language tells the model what to expect, as a BCP 47 tag such as "th" or "en". The hint filters nothing. Speech in any other language is still transcribed as spoken. A Thai recording with English words and phrases inside it needs no setting. Omitted, the model detects the language itself. Set the tag when detection has picked the wrong language for a recording.

Word timestamps#

Set timestamps to "word" and the response carries a words array: one span per recognized word with its text and its start and end in seconds on the recording's own clock. Thai words are segmented for you, and Latin words and numerals arrive whole. A punctuation mark joins the word before it, or the word after it when it opens. It is the same span shape the speech API returns. A caption file built from one round-trips through the other. Thai writes no spaces between words. The spans are what say where a word ends. For a subtitle file, subtitles cuts one from them for you. A recording with no speech carries an empty array.

curl
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"timestamps\": \"word\"}"
Response
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
  "words": [
    {
      "text": "สวัสดี",
      "start": 0.12,
      "end": 0.44
    },
    {
      "text": "ค่ะ",
      "start": 0.52,
      "end": 0.66
    },
    {
      "text": "คุณ",
      "start": 1.08,
      "end": 1.24
    },
    {
      "text": "สมชาย",
      "start": 1.32,
      "end": 1.88
    },
    {
      "text": "ยอด",
      "start": 2.36,
      "end": 2.56
    },
    {
      "text": "ชำระ",
      "start": 2.6,
      "end": 2.879
    },
    {
      "text": "ของ",
      "start": 3,
      "end": 3.14
    },
    {
      "text": "ท่าน",
      "start": 3.2,
      "end": 3.359
    },
    {
      "text": "จำนวน",
      "start": 3.48,
      "end": 3.879
    },
    {
      "text": "พันสองร้อยห้าสิบ",
      "start": 4.24,
      "end": 5.4
    },
    {
      "text": "บาท",
      "start": 5.56,
      "end": 5.759
    },
    {
      "text": "ครบ",
      "start": 6.32,
      "end": 6.52
    },
    {
      "text": "กำหนด",
      "start": 6.56,
      "end": 6.84
    },
    {
      "text": "วัน",
      "start": 6.92,
      "end": 7.039
    },
    {
      "text": "ที่",
      "start": 7.04,
      "end": 7.119
    },
    {
      "text": "สิบห้า",
      "start": 7.24,
      "end": 7.48
    },
    {
      "text": "สิงหาคม",
      "start": 7.68,
      "end": 8.26
    },
    {
      "text": "นี้",
      "start": 8.36,
      "end": 8.5
    },
    {
      "text": "ชำระ",
      "start": 9.08,
      "end": 9.36
    },
    {
      "text": "ผ่าน",
      "start": 9.48,
      "end": 9.68
    },
    {
      "text": "แอป",
      "start": 9.76,
      "end": 9.92
    },
    {
      "text": "ได้",
      "start": 10,
      "end": 10.14
    },
    {
      "text": "ตลอด",
      "start": 10.2,
      "end": 10.46
    },
    {
      "text": "ยี่สิบสี่",
      "start": 10.56,
      "end": 10.96
    },
    {
      "text": "ชั่วโมง",
      "start": 11.08,
      "end": 11.4
    },
    {
      "text": "ค่ะ",
      "start": 11.52,
      "end": 11.66
    }
  ],
  "usage": {
    "seconds": 11.952,
    "credits": 1.66
  }
}

Subtitles#

Set subtitles to "srt" or "vtt" and the response carries a subtitles string: the whole file, ready to write to disk. SubRip numbers its cues and writes a comma before the milliseconds; WebVTT opens with its signature and writes a period. Word timing is read for you. timestamps need not be set. Setting it as well returns the words array beside the file. Neither costs anything extra.

Cues break at a speaker change, at sentence punctuation, and at a pause, then at the line budget. A cue holds at most two lines of 60 columns, or 32 (16 Chinese characters) when most of the transcript is Chinese, Japanese, or Korean. It stays on screen for at least a second, and stops short of the cue after it.

Every line break falls on a word boundary. On Thai that is the whole difference. Thai writes no spaces. A subtitle wrapped at a character count splits words down the middle. The word spans say where a word ends, and the breaks land there. Thai vowels and tone marks sit above and below their consonant and take no width of their own. They are not counted either. A Chinese, Japanese, or Korean character takes two columns. A Thai line and an English line of 60 columns fill the same box.

Set subtitle_line_chars to your own house figure, between 10 and 120. With diarization on, a cue never runs across a speaker change, and no speaker is named in the file. A recording with no speech renders an empty SubRip file, or a WebVTT file carrying its signature alone.

curl
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"subtitles\": \"srt\"}" \
  | jq -r .subtitles > meeting.srt
Response
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
  "subtitles": "1\n00:00:00,120 --> 00:00:01,000\nสวัสดีค่ะ\n\n2\n00:00:01,080 --> 00:00:02,080\nคุณสมชาย\n\n3\n00:00:02,360 --> 00:00:05,759\nยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาท\n\n4\n00:00:06,320 --> 00:00:08,500\nครบกำหนดวันที่ สิบห้า สิงหาคมนี้\n\n5\n00:00:09,080 --> 00:00:11,660\nชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ\n",
  "usage": {
    "seconds": 11.952,
    "credits": 1.66
  }
}

Spoken or written#

convention selects how the transcript writes what was said, and defaults to "spoken". The words are the same under both. What changes is numbers, units, dates, and repeated words. The recording behind the samples on this page says an amount, a date, and a duration:

What was saidspoken (default)written
1,250 baht, said in Thaiพันสองร้อยห้าสิบบาท1,250บาท
the 15th of Augustสิบห้าสิงหาคม15สิงหาคม
24 hoursยี่สิบสี่ชั่วโมง24ชั่วโมง

The whole transcript under "spoken" reads สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ. Under "written" it reads สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน 1,250 บาทครบกำหนดวันที่ 15 สิงหาคมนี้ชำระผ่านแอปได้ตลอด 24 ชั่วโมงค่ะ.

"spoken" writes numbers as they were pronounced and a word said twice as two words. Use it for a verbatim record, a subtitle that must match the audio, or text a language model will read, since the spoken form carries exactly what the speaker said. "written" keeps display conventions: digits and units, the repetition mark ๆ. Use it for dictated text that will be read on a page, such as a memo or a message. Word timestamps are identical under both.

curl
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"convention\": \"written\"}"
Response
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน 1,250 บาทครบกำหนดวันที่ 15 สิงหาคมนี้ชำระผ่านแอปได้ตลอด 24 ชั่วโมงค่ะ",
  "usage": {
    "seconds": 11.952,
    "credits": 1.66
  }
}

Verbatim or clean#

style selects how closely the transcript follows the recording, and defaults to "verbatim": fillers, false starts, and self-corrections are written as they were said. "clean" drops the fillers and merges broken phrases into one. It is a readability choice. A clean transcript departs further from what was said, and our own measurement found it less exact. Pick it for a transcript that readers skim. A record of what was said keeps the default. Any other value answers 400 validation. The convention applies on top of either style.

Speakers#

Set diarization to true and the response carries a segments array: one entry per speaker turn with its speaker number, its text, and its start and end in seconds. Speakers are numbered from 0 in order of first appearance in the recording. Nobody is identified. A turn's text is a substring of the transcript. The two never disagree. With timestamps set to "word" as well, every word span names its speaker. A diarized recording is read in one pass. Speaker numbers hold across the whole file. That pass is also what limits it. A diarized recording must be under 9 minutes, and one that long or longer answers 400 diarization_audio_too_long before any charge. It also takes longer to return than an undiarized one. Split a longer recording at a pause and read the speaker numbers per part. Each part numbers its speakers from 0. The recording behind the samples on this page has one voice. It comes back as one turn:

curl
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"diarization\": true}"
Response
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
  "segments": [
    {
      "speaker": 0,
      "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
      "start": 0.12,
      "end": 11.66
    }
  ],
  "usage": {
    "seconds": 11.952,
    "credits": 1.66
  }
}

Vocabulary#

vocabulary lists terms the recording is likely to contain, spelled the way they should appear in the transcript. Product names, people, places, and jargon qualify, in Thai or any other language. The list holds up to 50 terms of 50 characters each, and a longer list or term answers 400 validation. The terms bias recognition and are never inserted. A term that was not said does not appear. It is the lever for domain content: a meeting about your own product transcribes the product's name the way you spell it. A vocabulary combines with every other option.

curl
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
  --max-time 300 \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"language\": \"th\", \"vocabulary\": [\"Paxa Labs\", \"quarterly review\"]}"

Reliability and retries#

The response is one buffered JSON body. An hour-long recording can take a minute or two. The recording is read in parallel pieces cut at pauses, and the wait grows slowly with the length. Set the client timeout generously. The samples above allow 300 seconds.

A request that runs long enough writes its response headers before the outcome is known. Intermediate proxies abandon a connection that has been silent too long, and the early headers prevent that. Such a response always has status 200. It reports a failure in the body, as the same problem document a failed request returns: a title and a status where text would be. Long recordings are the requests that reach it. Treat a body carrying title as the error it describes. Shorter recordings always carry their status on the status line.

Retries are safe with an Idempotency-Key. A replay of a delivered request serves the same transcript again without a second charge. The key covers the recording and every option. A replay with a different convention, style, or vocabulary is refused. When transcription fails, the charge is refunded and a fresh request needs a new key.

Realtime, with turn detection#

WS /v1/stt/live transcribes speech as it is spoken, over one WebSocket that stays open for the conversation. The model is paxa-stt-lite-realtime-v1-preview, tuned for voice agents. It listens to the microphone and notices when the user starts and stops talking. It delivers one finished transcript per turn. The agent never decides where a turn ends and never splits audio into requests. Send start with the model id and the audio format. Then stream raw audio as binary frames as it is captured. The audio is 16-bit PCM at 16 or 48 kHz, or mu-law at 8 kHz as telephony delivers it, mono either way. Send end when the conversation is over and wait for done.

import { open } from "node:fs/promises";

// Runs on Bun as-is; on Node, `import WebSocket from "ws"` accepts
// the same constructor options. Browsers cannot send this header, and
// API keys never ship to browsers anyway: relay the audio through your
// server.
const socket = new WebSocket("wss://api.paxalabs.com/v1/stt/live", {
  headers: { Authorization: `Bearer ${process.env.PAXA_API_KEY}` },
});

socket.onopen = async () => {
  socket.send(JSON.stringify({
    type: "start",
    model: "paxa-stt-lite-realtime-v1-preview",
    audio: { encoding: "pcm_s16le", sample_rate: 16000 },
    language: "th",
  }));
  // 16 kHz signed 16-bit mono is 32,000 bytes a second. A microphone
  // delivers frames at this pace on its own; a file has to be paced.
  const file = await open("caller.pcm");
  const frame = new Uint8Array(640); // 20 ms
  for (;;) {
    const { bytesRead } = await file.read(frame, 0, frame.length);
    if (bytesRead === 0) break;
    socket.send(frame.subarray(0, bytesRead));
    await new Promise((resolve) => setTimeout(resolve, 20));
  }
  socket.send(JSON.stringify({ type: "end" }));
};

socket.onmessage = (event) => {
  const message = JSON.parse(String(event.data));
  switch (message.type) {
    case "speech_started":
      // The user is talking: stop the agent's audio, cancel its reply.
      break;
    case "transcript":
      if (message.is_final) console.log(`turn ${message.turn}: ${message.text}`);
      break;
    case "done":
      socket.close();
  }
};

Each turn produces three events in order. speech_started arrives as soon as the user begins talking. An agent that is speaking stops its playback and cancels the reply it was generating. speech_ended arrives when the user has stopped. transcript with is_final true follows with the turn's text. The agent sends that text to the language model. Between the first and the last, a transcript with is_final false may arrive at a natural pause, carrying the turn so far. Use it to warm up the reply and replace it with the final. Every event names its turn, numbered from 1. A reply built for turn 3 is discarded the moment turn 4 starts. Times are seconds on the connection's clock, which starts at the first audio frame.

Server events
{"type":"started","connection":"7d9c2b7e-4a41-4c8e-9a35-1f26d8c04b7a","model":"paxa-stt-lite-realtime-v1-preview"}
{"type":"speech_started","turn":1,"time":0.3}
{"type":"speech_ended","turn":1,"time":12.252}
{"type":"charged","seq":1,"seconds":12.252,"credits":2.56,"total_credits":2.56}
{"type":"transcript","turn":1,"is_final":true,"text":"สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ","start":0.3,"end":12.252}
{"type":"done","total_seconds":12.252,"turns":1,"total_credits":2.56}

The session decides where a turn ends; there is no setting to tune. When the agent plays its own audio into the same room, send playback with speaking true while it plays and false when it stops. The turn detector then expects the user to cut in and does not mistake the agent's echo for a turn. The language, style, convention, vocabulary, and timestamps options work as they do on a recording and are fixed for the connection.

Realtime transcription costs 12.5 credits per minute of audio received, 750 credits per hour. Silence counts, because the model listens to all of it. The charge is taken in periods. A period closes at the end of each turn, after every minute of audio without a turn, and at close. A charged event reports each period with the seconds it covered and the running total. done carries the connection's totals. A turn's final transcript arrives only after its period is charged. A turn the model could not transcribe costs nothing and is reported as an error naming the turn. A connection that sent any audio costs at least 0.1 credits. An open connection holds one of the plan's concurrent transcription slots, and a connection accepts up to 4 hours of audio before it closes with done. Close codes and every frame field are in the endpoint reference.