# Synthesize speech

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

POST /v1/tts converts text into speech, as MP3, Opus, WAV, or raw PCM, in one request. Charged in credits per character before synthesis, refunded automatically when synthesis fails.

Set `stream: true` for chunked delivery ([Streaming](https://paxalabs.com/docs/streaming)) and [`timestamps`](https://paxalabs.com/docs/timestamps) for timing spans. An [Idempotency-Key](https://paxalabs.com/docs/idempotency) makes retries safe, and text past `max_chars` answers [400 text_too_long](https://paxalabs.com/docs/errors#text_too_long) with no charge. On a voice that reads [emotion tags](https://paxalabs.com/docs/emotion-tags) or [direction tags](https://paxalabs.com/docs/directions), a tag the voice cannot read answers [400 tag_invalid](https://paxalabs.com/docs/errors#tag_invalid), also with no charge.

## 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 are charged once. 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 other than space.

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

## Body

- `text` (string, required, at least 1 character, example "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs"): The text to synthesize, in Thai, English, Mandarin Chinese (Simplified script), or any mix of them. Cost is 10 credits per 1000 characters, billed per character and rounded up to the next hundredth of a credit, minimum 0.1 credit per request. At most 5,000 characters; longer text answers 400 text_too_long, and GET /v1/models reports each model's own ceiling as max_chars. On Paxa TTS Flash v2, every voice reads tags. A bracketed direction such as [whispering] sets the delivery from where it stands. A sound such as [laughs] plays where it is written, [pause] or [pause 300ms] adds silence, at most 500 ms for an exact length, and <emph> and <whisper> mark a span. A tag that model refuses, such as [british accent] or [sniffs], answers 400 tag_invalid. On a Paxa TTS Flash (paxa-tts-flash-v1) voice whose emotion_tags list is not empty, a bracketed tag such as [happy] or [sad:0.5] sets the delivery from where it stands. On those voices a tag outside the list, or a weight that is not a number, answers 400 tag_invalid. Every other v1 voice skips the tags it knows. Tag characters bill like the rest of the text.
- `voice` (string, required, 1 to 100 characters, example "khanomkrok"): The voice id, the stable identifier GET /v1/voices lists, for example "khanomkrok". Case is ignored, and only the id is matched. A designed voice has an id starting "voice_", and GET /v1/voices/designed lists the account's. It speaks only on Paxa TTS Flash v2 (paxa-tts-flash-v2) and only for the account that saved it; on another model or account, or once deleted, it answers 400 unknown_voice. Speech in a designed voice bills at 1.25x the model's per-character rate, or 1.5x with timestamps.
- `model` (string, required, 1 to 100 characters, example "paxa-tts-flash-v2"): TTS model id, for example paxa-tts-flash-v2. GET /v1/models lists the served catalog.
- `format` ("mp3" or "opus" or "wav" or "pcm" or "mulaw" or "alaw", optional, default "mp3"): Audio format. mp3 is compressed and the default. opus is Ogg Opus, 48 kbps on Paxa TTS Flash and 64 on Paxa TTS Flash v2, the smallest, for streaming and web playback. wav is uncompressed 16-bit PCM under a RIFF header, the largest, for audio that feeds further processing. pcm is those samples with no header, 16-bit little-endian mono, for voice agents and phone systems that take raw samples. Both are 24 kHz unless sample_rate says otherwise. mulaw and alaw are G.711 mu-law and A-law with no header, one byte per sample at 8 kHz, for phone lines. Paxa TTS Flash v2 serves them, and Paxa TTS Flash answers unsupported_format.
- `sample_rate` (8000 or 16000 or 22050 or 24000 or 32000 or 44100 or 48000, optional, example 16000): Sample rate of pcm and wav, in Hz. Paxa TTS Flash v2 serves 8000, 16000, 22050, 24000, 32000, 44100, 48000, and Paxa TTS Flash serves 24000 only. For any other format the value may only restate that format's own rate, which is 8000 for mulaw and alaw. A rate the model doesn't serve answers unsupported_format. A request costs the same at every rate.
- `stream` (boolean, optional, default false): When true the audio arrives as a chunked stream while it is synthesized, lowering time to first byte. When false the response is a complete body with a content-length.
- `timestamps` ("word" or "sentence" or "utterance", optional): Adds speech timing to the response: "word" times every word, "sentence" every sentence, "utterance" every continuous run of speech. When set, the response is application/json with base64 audio and timing spans, or application/x-ndjson events when stream is true, and the request bills at 1.25x the model's per-character rate.
- `speed` (number, optional, default 1, min 0.5, max 1.5, example 1.1): Speaking rate, as a multiplier on the voice's own design speed. It runs from 0.5 to 1.5, and 1 is the voice as designed. Rate changes how the audio sounds. It does not change how many characters were sent. A request costs the same at every rate. Values round to the nearest 0.05. Timing spans are measured against the audio that was produced. They already carry the rate.
- `language` ("auto" or "th" or "en" or "zh", optional, default "auto", example "en"): Sets how codes, digits, and symbols are read. Words keep the language of their script, and a number inside Thai or Chinese text keeps that text's language. Elsewhere, "th" reads them in Thai (AB2039 with Thai letter names and digits) and "en" in English. "zh" reads codes in English, a number next to Chinese characters in Mandarin, and other digits and symbols in Thai. "auto", the default, takes the language most of the text is written in, counting English only in words of three or more letters, and Thai on a tie. Under "auto", a bare number, AB2039, or 250 ml reads as Thai, and ISO9001 or 100 USD as English. Text with no Thai, Chinese, digits, or counted English word reads as English when a two-letter word has a lowercase letter, such as Hi or I'm OK. Pin "th" or "en" to choose how a short number or code reads. A request costs the same in every language. Every value but "auto" is a code GET /v1/voices reports as a voice's language. POST /v1/audio/speech ignores this field and reads every request as "auto".
- `temperature` (number, optional, default 0.5, min 0, max 2, example 0.5): Sets how much the pacing, pauses, breaths, and melody of a reading vary from one request to the next. It runs from 0 to 2, and 0.5 is the default. They vary least at 0 and most at 2. Paxa TTS Flash v2 reads it. Any other model accepts only a value that rounds to 0.5, and any other answers validation. A request costs the same at every temperature. Values round to the nearest 0.1. POST /v1/audio/speech and the start frame of WS /v1/tts/live read it too.

## Response

audio/mpeg or audio/ogg or audio/wav or audio/pcm;rate=24000;channels=1 or audio/basic or audio/x-alaw-basic. The synthesized speech as binary audio, in the format and sample rate the request chose.

| Header | Description |
| --- | --- |
| x-request-id | Identifies the request for support and log correlation. Sent on every response, errors included, except the cached catalog (GET /v1/models, /v1/voices, /v1/audio/models, and /v1/audio/voices). Name it in request_ids when you send feedback about the request. |
| x-credits-charged | Credits charged for this request, exact to a hundredth. An idempotent replay reports the original charge. Sent on audio responses and on the OpenAI-compatible transcription endpoint, whose body follows OpenAI's shape and carries no usage object. POST /v1/translate, /v1/ocr, /v1/extract, /v1/stt, and /v1/voices/design report the charge in the usage object of the body instead. |
| x-voice-id | The id of the voice that spoke. When the request named the voice by an accepted alias, such as an OpenAI voice name, it is the Paxa voice the alias maps to. Sent on audio responses. |
| x-ratelimit-limit | Requests per minute the plan allows. |
| x-ratelimit-remaining | Requests left in the current minute window. |
| x-ratelimit-reset | Unix time in seconds when the current window resets. |

### With timestamps

application/json. When timestamps is set, the response body is JSON carrying the audio and its timing. The status and the headers are unchanged.

- `audio` (string, required): The complete audio file in the requested container, base64-encoded.
- `timing` (object, required)
  - `timing.granularity` ("word" or "sentence" or "utterance", required): The granularity that was requested.
  - `timing.spans` (array of objects, required): Spans in text order, together covering the spoken input.
    - `timing.spans[].text` (string, required): The spoken input text this span covers. An emotion tag takes no span.
    - `timing.spans[].start` (number, required): Where the span begins in the audio, in seconds.
    - `timing.spans[].end` (number, required): Where the span ends in the audio, in seconds.

application/x-ndjson. One JSON event per line, arriving as audio is synthesized. Each chunk carries the spans its own audio completed. The stream ends with a done event. A mid-stream failure ends it with an error event and refunds the charge automatically.

**chunk**: One NDJSON line of the streamed timed response.

- `type` ("chunk", required)
- `audio` (string, required): Audio chunk in the requested container, base64-encoded. Decode each chunk and concatenate the bytes in arrival order; never concatenate the base64 strings themselves. Empty on a line that only carries spans completed after the last audio.
- `spans` (array of objects, required): Spans newly completed by the audio delivered up to and including this chunk; empty when this chunk completes none.
  - `spans[].text` (string, required): The spoken input text this span covers. An emotion tag takes no span.
  - `spans[].start` (number, required): Where the span begins in the audio, in seconds.
  - `spans[].end` (number, required): Where the span ends in the audio, in seconds.

**done**: The final NDJSON line. Every span already arrived on the chunk lines. This event carries their count and never repeats them.

- `type` ("done", required)
- `granularity` ("word" or "sentence" or "utterance", required): The granularity that was requested.
- `span_count` (integer, required): How many spans the chunk lines delivered in total. Compare it with the spans you collected.

**error**: Synthesis failed after delivery began. The charge is refunded automatically; this is the final line of the stream.

- `type` ("error", required)
- `error` (object, required): RFC 9457 problem details at the minimum shape, served with content type application/problem+json. Responses carry no prose: the errors reference is the documentation for every code. One code, 400 schema_invalid, adds two fields so a caller can fix the schema without guessing: path and reason.
  - `error.title` (string, required): Stable machine-readable error code, for example "insufficient_credits". Branch on this field; the errors reference documents every code's meaning and the recommended handling.
  - `error.status` (integer, required): HTTP status code, mirrored from the response.
  - `error.path` (string, optional): Present on 400 schema_invalid alone. Names the schema field the reason applies to, in dot form ("line_items[].amount"), or an empty string for the schema as a whole.
  - `error.reason` (string, optional): Present on 400 schema_invalid alone. One stable code from the schema reasons list in the Document Extraction reference, naming what is wrong at path.

## Errors

- `validation` (400): The request body, query, or headers failed validation against the endpoint schema, or broke a rule the endpoint's reference states. A field the schema refused adds two fields to the problem body. path names the first failing field by location and name in dot form, such as body.voice or headers.idempotency-key. A body that could not be read gives the path body alone. reason is one stable code from the validation reasons list.
- `unknown_model` (400): The model field does not name a served model.
- `unsupported_format` (400): The model does not serve this format, or this format at the requested sample rate.
- `unknown_voice` (400): The voice id names no voice on the model's roster and no designed voice this account holds.
- `text_too_long` (400): The text exceeds the model's per-request character ceiling.
- `tag_invalid` (400): The text holds a tag the model refuses. On Paxa TTS Flash v2, the bracket asks for what the model does not do, such as an accent, singing, or a sound outside the supported list. It can also hold no letters or carry a weight that is not a number. On a Paxa TTS Flash voice that takes emotion tags, the bracketed word is outside the tag list, or its weight is not a number. 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.
- `unspeakable_text` (422): The text contains nothing the model can voice: only tags, emoji, punctuation, or a script it does not speak. A text of tags alone is refused before the charge; any other is charged and 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 slot of its product for its lifetime.
- `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
curl -X POST https://api.paxalabs.com/v1/tts \
  -H "Authorization: Bearer $PAXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs",
    "voice": "khanomkrok",
    "model": "paxa-tts-flash-v2",
    "format": "mp3",
    "stream": false
  }' \
  --output speech.mp3
```

TypeScript:

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

const response = await fetch("https://api.paxalabs.com/v1/tts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAXA_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs",
    voice: "khanomkrok",
    model: "paxa-tts-flash-v2",
  }),
});

if (!response.ok) throw new Error(`TTS failed: ${response.status}`);
await writeFile("speech.mp3", Buffer.from(await response.arrayBuffer()));
```

Python:

```python
import os
import requests

response = requests.post(
    "https://api.paxalabs.com/v1/tts",
    headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
    json={"text": "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs", "voice": "khanomkrok", "model": "paxa-tts-flash-v2"},
)
response.raise_for_status()

with open("speech.mp3", "wb") as f:
    f.write(response.content)
```
