# Transcribe a recording

`POST https://api.paxalabs.com/v1/stt`

POST /v1/stt transcribes a recording of Thai or English speech verbatim, with word timestamps and speaker turns on request. Charged in credits by recording length before transcription, refunded on failure.

Send the file as base64 in `audio`. The format and the length are read from the bytes. The [transcription guide](https://paxalabs.com/docs/speech-to-text) covers the formats, the two conventions, the two styles, word timestamps, speaker turns, the vocabulary, and the length and size limits.

## Headers

- `authorization` (Bearer pxa_..., required): Authorization: Bearer pxa_... (recommended).
- `x-api-key` (pxa_..., optional): Alternative to the Authorization header. Ignored when Authorization is present.
- `idempotency-key` (string, optional, 1 to 200 characters, pattern ^[!-~]+$): Makes a retry safe. Two requests carrying the same key charge once and synthesize the same content. One request per key runs at a time. A concurrent duplicate answers 409 idempotency_in_flight. Reuse a key only to retry an identical request. A changed payload is rejected with 422. Accepts up to 200 printable ASCII characters.

See authentication at https://paxalabs.com/docs/authentication.

## Body

- `audio` (string, required, 1 to 41,000,000 characters): The recording to transcribe, as the base64 encoding of an MP3, WAV, FLAC, Ogg (Opus or Vorbis), M4A, AAC, or WebM file. The format and the length are read from the file itself. No declared field is consulted. Cost is 500 credits per hour of audio, 8.33 per minute, billed by exact length with a minimum of 0.1 credits per request, charged before transcription and refunded automatically when transcription fails. A recording may run up to 60 minutes, and longer answers 400 audio_too_long. The decoded file may be up to 26,214,400 bytes, and larger answers 413 audio_too_large. A file that cannot be read as one of the seven formats, or whose length cannot be read from its container, answers 400 audio_invalid. A recording with no speech is delivered as an empty transcript and billed for its length. GET /v1/models reports the ceilings as max_duration_seconds and max_bytes.
- `model` (string, required, 1 to 100 characters): Transcription model id, for example paxa-stt-lite-v1-preview. GET /v1/models lists the served catalog.
- `language` (string, optional, up to 16 characters, pattern ^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$): The language the recording is expected to be in, as a BCP 47 tag such as "th" or "en". The hint filters nothing. Speech in any other language is still transcribed as spoken, and a Thai recording that switches into English mid-sentence needs no setting. Omitted, the model detects the language itself. Set the tag when detection has picked the wrong language for a recording.
- `timestamps` ("word", optional): Adds a words array to the response, one span per word with its start and end in seconds on the recording's own clock. Thai words are segmented for you. Latin words and numerals arrive whole. The span shape matches the speech API's timing spans. Captions round-trip between the two. Costs nothing extra. Omitted, the response carries the transcript alone.
- `diarization` (boolean, optional, default false): true adds a segments array to the response, one entry per speaker turn with its speaker number, text, and start and end in seconds. Speakers are numbered from 0 in order of first appearance. Nobody is identified. With word timestamps on as well, every word span names its speaker too. Costs nothing extra. A diarized recording is read in one pass, which is slower to return and holds the recording under 9 minutes. One that long or longer answers 400 diarization_audio_too_long, uncharged. The same recording transcribes normally with this field off. GET /v1/models reports the ceiling as diarization_limit_seconds.
- `style` ("verbatim" or "clean", optional, default "verbatim"): How faithfully the transcript follows the recording. "verbatim", the default, writes exactly what was said: fillers, false starts, and self-corrections included. "clean" drops fillers and merges broken phrases into one, for a transcript that reads more easily. It is a readability choice. A clean transcript departs further from what was said, and our own measurement found it less exact. Any other value answers 400 validation.
- `convention` ("spoken" or "written", optional, default "spoken"): How the transcript writes what was said. "spoken", the default, writes numbers as they were said (สามสิบเปอร์เซ็นต์) and a word said twice as two words, which is what a verbatim transcript, a subtitle, or a language model reading the text needs. "written" keeps display conventions: digits and units (30%), the repetition mark ๆ, for dictated text that will be read on a page. The word timestamps are the same under both.
- `vocabulary` (array of strings, optional): Terms the recording is likely to contain that the model should recognize as written: product names, people, places, jargon, in Thai or English. 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. Costs nothing extra and combines with every other option.
- `subtitles` ("srt" or "vtt", optional): Renders the transcript as a subtitle file and returns it in the response's subtitles field. "srt" writes SubRip, "vtt" writes WebVTT. Cues break at a speaker change, at sentence punctuation, and at a pause, then at the line budget, over at most two lines. Every break falls on a word boundary. That is the whole difference on Thai. Thai writes no spaces, and a subtitle cut by character count splits words down the middle. The word spans say where a word ends. Word timing is read for you and timestamps need not be set; setting it as well returns the words array beside the file. Costs nothing extra. A recording with no speech renders an empty SubRip file, or a WebVTT file carrying its signature alone.
- `subtitle_line_chars` (integer, optional, default 60, min 10, max 120): Characters per subtitle line, 10 to 120, default 60. Thai vowels and tone marks sit above and below their consonant and take no width of their own. They are not counted. A Thai line and an English line of the same figure fill the same box. Ignored when subtitles is not set.

## Response

- `text` (string, required): The transcript, verbatim in the language spoken, with no labels, timestamps, or sound annotations. Empty when the recording carried no speech.
- `words` (array of objects, optional): One span per recognized word, in spoken order, each with its text and its start and end in seconds. Present when the request set timestamps to "word"; empty for a recording with no speech. Each span names its speaker when the request set diarization to true.
  - `words[].text` (string, required): The input text this span covers.
  - `words[].start` (number, required): Where the span begins in the audio, in seconds.
  - `words[].end` (number, required): Where the span ends in the audio, in seconds.
  - `words[].speaker` (integer, optional, min 0): The speaker, numbered from 0 in order of first appearance in the recording. Nobody is identified.
- `segments` (array of objects, optional): One entry per speaker turn, in spoken order. Present when the request set diarization to true; empty for a recording with no speech.
  - `segments[].speaker` (integer, required, min 0): The speaker, numbered from 0 in order of first appearance in the recording. Nobody is identified.
  - `segments[].text` (string, required): The turn's text, exactly as the transcript renders it. Consecutive phrases of one speaker are one turn.
  - `segments[].start` (number, required): Where the turn begins in the recording, in seconds.
  - `segments[].end` (number, required): Where the turn ends in the recording, in seconds.
- `subtitles` (string, optional): The subtitle file in the format the request asked for, ready to write to disk. Present when the request set subtitles.
- `usage` (object, required): What the request was billed for.
  - `usage.seconds` (number, required): Length of the recording this request was billed for, in seconds, exact to a thousandth.
  - `usage.credits` (number, required): What this delivery cost, in credits, exact to a hundredth. An idempotent replay reports the ORIGINAL request's charge, since that one charge is what paid for this delivery too; your balance moves only once.

## Errors

- `validation` (400): The request body or headers failed validation against the endpoint schema.
- `unknown_model` (400): The model field does not name a served model.
- `audio_invalid` (400): The audio field could not be read as an MP3, WAV, FLAC, Ogg, M4A, AAC, or WebM recording, or its length could not be read from the container. A damaged or truncated file answers this code. Nothing was charged.
- `audio_too_large` (413): The decoded recording exceeds the model's size ceiling. Nothing was charged.
- `audio_too_long` (400): The recording is longer than the model's per-request ceiling. Nothing was charged.
- `diarization_audio_too_long` (400): The recording reaches the length at which speaker attribution stops, which is well below the transcription ceiling. Nothing was charged.
- `unauthorized` (401): The request carried no API key, or the key is invalid or disabled.
- `insufficient_credits` (402): The account does not have enough credits for this request. Nothing was charged.
- `key_limit` (403): This API key reached its spending cap. Nothing was charged.
- `idempotency_in_flight` (409): Another request with this Idempotency-Key is in flight right now.
- `idempotency_refunded` (409): The original request under this Idempotency-Key failed and was refunded.
- `idempotency_mismatch` (422): This Idempotency-Key was already used for a different request.
- `content_blocked` (422): The upstream safety system declined to process this content. The charge was refunded.
- `rate_limited` (429): Requests per minute for the plan are exhausted. One window covers the whole account, across every product and every key.
- `concurrency_limited` (429): The account holds the plan's full count of concurrent requests for this product. Nothing was charged. Each product is limited separately, and an open live connection holds one speech slot.
- `internal` (500): Request state was inconsistent on the server.
- `provider_error` (502): Model inference failed after the request was charged.
- `provider_unavailable` (503): The model behind this endpoint is not available right now. Nothing was charged.

## Example

curl:

```bash
# 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\"}"
```

TypeScript:

```typescript
import { readFile } from "node:fs/promises";

const audio = (await readFile("meeting.m4a")).toString("base64");

const response = await fetch("https://api.paxalabs.com/v1/stt", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAXA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ audio, model: "paxa-stt-lite-v1-preview" }),
  // A long recording can run for minutes; give it room.
  signal: AbortSignal.timeout(300_000),
});

if (!response.ok) throw new Error(`Transcription failed: ${response.status}`);
const { text, usage } = await response.json();
console.log(text, `${usage.credits} credits`);
```

Python:

```python
import base64
import os

import requests

with open("meeting.m4a", "rb") as file:
    audio = base64.b64encode(file.read()).decode()

response = requests.post(
    "https://api.paxalabs.com/v1/stt",
    headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
    json={"audio": audio, "model": "paxa-stt-lite-v1-preview"},
    # A long recording can run for minutes; give it room.
    timeout=300,
)
response.raise_for_status()
body = response.json()
print(body["text"], body["usage"]["credits"], "credits")
```

Response:

```json
{
  "text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
  "usage": {
    "seconds": 10.25,
    "credits": 1.43
  }
}
```
