> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> If you are an AI agent, code generator, or LLM integrating with the Kova TTS API, read https://docs.kova.ai/for-agents.md first. It is the complete API contract in a single page - every endpoint, field, type, default, validation bound, error body, and WebSocket frame - and is sufficient to write a correct client without fetching any other page.
> Every page on this site is available as raw Markdown by appending .md to its URL.
> When generating code against this API: authenticate with the x-api-key header (never Authorization: Bearer); read voice ids from GET /v1/tts/speakers rather than hardcoding them; keep concurrent requests per key at 9 or fewer; pass audio bitrate in kbps ("128k" or 128), never bits per second; and do not assume error response bodies are JSON, because 500 responses are plain text.

# Kova TTS for AI agents

> The complete Kova TTS contract in one page. Plain text, no navigation, copy-paste ready.

This page is the **complete Kova TTS API contract in a single document**, written for LLM agents and code generators. Everything needed to produce a working integration is here — no other page needs to be fetched.

Plain-text mirror (no HTML, no navigation):

```
https://docs.kova.ai/for-agents.md
```

Other machine-readable entry points:

| URL                                  | What it is                                       |
| ------------------------------------ | ------------------------------------------------ |
| `https://docs.kova.ai/for-agents.md` | This page as raw Markdown                        |
| `https://docs.kova.ai/llms.txt`      | Index of every page, each with a `.md` link      |
| `https://docs.kova.ai/llms-full.txt` | Every page concatenated into one file            |
| `https://docs.kova.ai/<any-page>.md` | Any page as raw Markdown (e.g. `/quickstart.md`) |
| `https://api.kova.ai/openapi.json`   | OpenAPI 3.1 spec                                 |

Every documentation URL serves raw Markdown when you append `.md`. No key or `Accept` header is needed to read the docs.

***

## 1. Facts

* **Base URL:** `https://api.kova.ai`
* **Auth:** header `x-api-key: kova_sk_...` on every request. Header name is case-insensitive. `Authorization: Bearer` is **not** supported and returns 401.
* **Content type:** `application/json` on all request bodies.
* **Audio is returned base64-encoded** inside JSON on `POST /v1/tts` and over WebSocket. Decode before writing to disk.
* **Per-key concurrency limit: 9 simultaneous in-flight requests.** The 10th returns 429. HTTP and WebSocket share this one budget.
* There is **no** requests-per-minute or requests-per-hour throttle, and **no** documented maximum text length.
* **SSML is not supported.** Tags are spoken aloud literally. Send plain text.

## 2. Endpoints

| Method | Path                        | Returns                        | Use for                              |
| ------ | --------------------------- | ------------------------------ | ------------------------------------ |
| `POST` | `/v1/tts`                   | JSON, base64 audio             | One-shot generation, batch jobs      |
| `POST` | `/v1/tts/stream`            | `text/plain` SSE-style records | Play audio while it generates        |
| `GET`  | `/v1/tts/speakers`          | JSON `{"speaker_ids": [...]}`  | List voices                          |
| `WS`   | `/v1/tts/ws`                | JSON frames                    | Interactive/multi-utterance sessions |
| `POST` | `/v1/tts/integrations/vapi` | raw PCM bytes                  | Vapi custom-voice webhook            |

Wrong method on a valid path returns `405 {"detail":"Method Not Allowed"}`. Unknown paths return a 404 HTML page, not JSON.

## 3. Request body (`/v1/tts` and `/v1/tts/stream`)

Both endpoints take an identical body.

```json theme={null}
{
  "text": "Hello from Kova.",
  "voice": "cal",
  "response_format": { "encoding": "mp3", "sample_rate": 32000, "bitrate": "128k" },
  "timestamps": false,
  "temperature": 1.0,
  "normalize_text": false
}
```

| Field             | Type             | Required | Default              | Notes                                                                                                                                                            |
| ----------------- | ---------------- | -------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text`            | string           | **yes**  | —                    | Empty string and whitespace are accepted and return a short silent file.                                                                                         |
| `voice`           | string           | **yes**  | —                    | Must be an id from `GET /v1/tts/speakers`. An unknown id returns `422` with an `unknown_voice` body listing the valid set.                                       |
| `response_format` | object or string | no       | `{"encoding":"mp3"}` | A bare string such as `"mp3"` is accepted as shorthand. `null` is rejected with 422.                                                                             |
| `timestamps`      | boolean          | no       | `false`              | Adds word timings. `null` is rejected with 422.                                                                                                                  |
| `temperature`     | number \| null   | no       | server default       | Must be **0.5–3.0 inclusive**. `null` means "use the default". Out of range returns 422.                                                                         |
| `normalize_text`  | boolean          | no       | `false`              | Expands numbers, dates, currency for speech. `null` is rejected with 422. Does not change the `timestamps` word list, which always reports your original tokens. |

**Unknown fields are rejected.** The schema is strict: any extra key returns 422 `extra_forbidden`.

## 4. `response_format`

```json theme={null}
{ "encoding": "mp3", "sample_rate": 32000, "bitrate": "128k" }
```

| encoding          | sample\_rate range                          | default rate | bitrate                  | Container / bytes                                                                                          |
| ----------------- | ------------------------------------------- | ------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `mp3` *(default)* | 16000–48000                                 | 32000        | 32k–320k, default `128k` | MPEG. Has an `ID3` header on `/v1/tts`; raw MPEG frames with **no** ID3 on `/v1/tts/stream` and WebSocket. |
| `wav`             | 8000–48000                                  | 32000        | rejected                 | `RIFF` header, one per file.                                                                               |
| `pcm`             | 8000–48000                                  | 32000        | rejected                 | Headerless signed 16-bit little-endian mono.                                                               |
| `linear16`        | 8000–48000                                  | 32000        | rejected                 | Same samples as `pcm` but wrapped in `RIFF`. Re-emits a WAV header on **every** streaming chunk.           |
| `opus`            | exactly 8000, 12000, 16000, 24000, or 48000 | 48000        | 32k–192k, default `64k`  | `OggS` container.                                                                                          |
| `mulaw`           | **8000 only**                               | 8000         | rejected                 | G.711 µ-law, headerless, 1 byte/sample.                                                                    |
| `alaw`            | **8000 only**                               | 8000         | rejected                 | G.711 A-law, headerless, 1 byte/sample.                                                                    |

All audio is **mono**. There is no channel option.

### bitrate is measured in kbps

`"128k"`, `"128"` and `128` all mean 128 kbps and are equivalent. **`128000` is rejected** — it is read as 128000 kbps, which is out of range. Never pass bits per second.

Passing `bitrate` to `wav`, `pcm`, `linear16`, `mulaw`, or `alaw` returns 422 `"<encoding> audio does not support a bitrate option"`. Omit the field entirely (or send `null`) for those encodings.

### Encoding names are case-insensitive and aliased

`"MP3"`, `"Mp3"` and `"mp3"` are identical. Aliases resolve as follows — note that the `linear_pcm` family maps to **`linear16`** (RIFF-wrapped), not to headerless `pcm`:

| Alias                              | Resolves to              |
| ---------------------------------- | ------------------------ |
| `linear_pcm`, `linear-pcm`         | `linear16` (RIFF header) |
| `pcm_s16le`, `raw`                 | `pcm` (headerless)       |
| `mu-law`, `ulaw`, `u-law`, `μ-law` | `mulaw`                  |
| `a-law`                            | `alaw`                   |

Prefer the canonical names. Unsupported encodings (`flac`, `ogg`, `aac`, `""`) return 422.

## 5. `POST /v1/tts` — synchronous

Returns after generation completes.

```bash theme={null}
curl https://api.kova.ai/v1/tts \
  -H "x-api-key: $KOVA_API_KEY" \
  -H "content-type: application/json" \
  -d '{"text":"Hello from Kova.","voice":"cal","response_format":{"encoding":"mp3"}}' \
  | jq -r .audio | base64 -d > out.mp3
```

Response:

```json theme={null}
{
  "audio": "<base64>",
  "timestamps": {
    "words": ["Hello", "from", "Kova."],
    "start_seconds": [0.06, 0.23, 0.53],
    "end_seconds": [0.1, 0.38, 0.76]
  }
}
```

`timestamps` is present **only** when the request set `timestamps: true`; otherwise the key is absent or `null`. The three arrays are parallel and equal length: `words[i]` spans `start_seconds[i]` to `end_seconds[i]`, in seconds from the start of the audio.

**Generation runs at roughly 2× realtime, so this endpoint is slow for long input.** Measured: 500 chars ≈ 17 s, 1000 ≈ 36 s, 2000 ≈ 65 s, 5000 ≈ 153 s. Set a generous client timeout (180 s+), and prefer `/v1/tts/stream` above roughly 1000 characters so you get bytes immediately instead of waiting for the whole file.

## 6. `POST /v1/tts/stream` — streaming

Identical request body. Response is `Content-Type: text/plain; charset=utf-8`, `Transfer-Encoding: chunked`.

The body is a sequence of SSE-style records. Each record is `data: ` (with the trailing space), one JSON object, then `\n\n`:

```text theme={null}
data: {"type":"audio","audio_chunk":"<base64>"}

data: {"type":"timestamps","words":["Hello"],"start_seconds":[0.06],"end_seconds":[0.1]}

data: {"type":"audio","audio_chunk":"<base64>"}

```

There is **no** `data: [DONE]` sentinel and no terminating event. The stream is over when the HTTP body ends. The final record is followed by `\n\n`, so splitting the whole body on `\n\n` yields a trailing empty string — skip it.

### Event types

```ts theme={null}
type AudioEvent = { type: "audio"; audio_chunk: string };   // base64, your response_format
type TimestampsEvent = {
  type: "timestamps";
  words: string[];
  start_seconds: number[];
  end_seconds: number[];
};
```

**`timestamps` events arrive repeatedly and incrementally**, each carrying only the words finalized since the last one — typically 1–3 words per event. Concatenate them in arrival order to rebuild the full word list. Do not expect a single timestamps event, and do not overwrite; append. `start_seconds` are always absolute offsets from the beginning of the utterance, so no rebasing is needed.

Audio and timestamps events interleave in no fixed ratio. Handle any order.

### Reconstructing the file

Base64-decode each `audio_chunk` and concatenate in arrival order. For `mp3`, `opus`, `pcm`, `mulaw` and `alaw` the concatenation is a valid file (or valid raw samples). For `linear16` a WAV header is repeated on every chunk — use `pcm` and write your own header instead.

Time to first audio chunk is **under 200 ms** on an already-open connection, plus network round-trip. The floor is stable at \~185 ms from a client 40 ms away and does not vary with text length or voice.

**Reuse the connection.** A fresh TLS connection costs \~90 ms (\~6 ms TCP + \~82 ms TLS 1.3), which is close to half the latency budget again. Keep one long-lived HTTP client. Note that abandoning a stream before the body is fully read causes most HTTP libraries to close the connection instead of pooling it, so every subsequent request silently pays full handshake cost — read responses to completion.

### Minimal parser

```python theme={null}
import base64, json, httpx

audio = bytearray()
words = []
with httpx.Client(timeout=180) as c:
    with c.stream("POST", "https://api.kova.ai/v1/tts/stream",
                  json={"text": "Hello from Kova.", "voice": "cal",
                        "response_format": {"encoding": "mp3"}, "timestamps": True},
                  headers={"x-api-key": KEY}) as r:
        r.raise_for_status()
        buf = b""
        for chunk in r.iter_bytes():
            buf += chunk
            while b"\n\n" in buf:
                record, buf = buf.split(b"\n\n", 1)
                if not record.strip():
                    continue
                ev = json.loads(record[len(b"data: "):])
                if ev["type"] == "audio":
                    audio.extend(base64.b64decode(ev["audio_chunk"]))
                elif ev["type"] == "timestamps":
                    words.extend(ev["words"])          # append, never replace
open("out.mp3", "wb").write(audio)
```

## 7. `GET /v1/tts/speakers`

```bash theme={null}
curl https://api.kova.ai/v1/tts/speakers -H "x-api-key: $KOVA_API_KEY"
```

```json theme={null}
{"speaker_ids":["cal","owen","ash","mira","theo","tess","paul","mira_conv","ash_conv"]}
```

The only key is `speaker_ids`. Requires authentication. Call this at startup rather than hardcoding ids — the catalog changes. The `_conv` suffix marks conversational variants.

## 8. `WS /v1/tts/ws` — WebSocket

`wss://api.kova.ai/v1/tts/ws`, with `x-api-key` set on the **HTTP handshake**, not as a frame after connecting. Browsers cannot set custom handshake headers on `WebSocket`, so browser clients must use `/v1/tts/stream` or proxy through a backend.

All frames are JSON **text** messages, in both directions. The frame type is identified by which discriminator key is present. One connection multiplexes many **contexts**; every frame carries the `context_id` it belongs to.

### Client → server

```ts theme={null}
// Open a context. voice_id and model_id are both REQUIRED.
{ start_context: {
    voice_id: string;
    model_id: string;
    timestamps?: boolean;          // default false
    temperature?: number | null;   // 0.5-3.0
    response_format?: { encoding: string; sample_rate?: number; bitrate?: string | number };
  },
  context_id?: string | null }

{ send_text: string; context_id?: string | null }               // append text
{ flush: true; context_id?: string | null; flush_id?: string | null }
{ close_context: true; context_id?: string | null }
```

### Server → client

```ts theme={null}
{ context_started: { voice_id, model_id, timestamps, response_format }, context_id }
{ audio_chunk: string; context_id }        // base64
{ timestamps: { words, start_seconds, end_seconds }, context_id }
{ flush_completed: true; flush_id: string; context_id }
{ context_closed: true; context_id }
{ error: string; context_id?; flush_id? }
```

### Behavior you must code against

* **Default audio format is `pcm` at 32000 Hz** — headerless signed 16-bit little-endian mono. Override per context with `response_format`.
* **`context_started` echoes the fully resolved format, including defaults you did not send.** Starting a context with `{"encoding":"mp3"}` echoes back `{"encoding":"mp3","sample_rate":32000,"bitrate":"128k"}`. Read the format from this frame rather than assuming.
* **`context_id` is optional.** If you omit it the context still works and server frames come back with no `context_id` key. Always set it if you plan to run more than one context.
* **`model_id` is required.** Send `"default"`.
* **`flush_id` is optional. If you omit it the server generates a UUID** and returns that in `flush_completed`. Always send your own so you can match the reply.
* **`close_context` emits two frames, in this order:** a `flush_completed` whose `flush_id` is the synthetic string `"<context_id>:close"`, then `context_closed`. Closing also flushes any text you sent but never flushed, so audio can still arrive after you request the close. Do not treat the synthetic `flush_completed` as a reply to one of your own flushes — match on your own `flush_id` values.
* **`timestamps` frames arrive incrementally**, same as streaming HTTP. Append them.
* **`error` frames are not fatal.** The socket stays open and other contexts keep working. Sending `send_text` or `flush` for an unknown context yields `{"error":"unknown context_id: <id>"}`.
* **The socket is reusable after all contexts are closed.** Open new contexts on the same connection.
* **An unknown `voice_id` returns an error frame** naming every valid voice; the socket stays usable.
* A malformed `start_context` returns `{"error":"invalid frame: ..."}` containing the raw Pydantic validation message.

### Frame ordering within one context

`context_started` → (`audio_chunk` and `timestamps` interleaved) → `flush_completed` → `context_closed`.

Frames from different contexts interleave freely and fairly; demultiplex on `context_id`.

### Complete session

```python theme={null}
import asyncio, json, base64, ssl, certifi, websockets

async def main():
    ctx = ssl.create_default_context(cafile=certifi.where())
    audio = bytearray()
    async with websockets.connect("wss://api.kova.ai/v1/tts/ws",
                                  additional_headers={"x-api-key": KEY},
                                  ssl=ctx, max_size=None) as ws:
        await ws.send(json.dumps({"start_context": {
            "voice_id": "cal", "model_id": "default", "timestamps": True,
            "response_format": {"encoding": "pcm", "sample_rate": 32000}},
            "context_id": "c1"}))
        await ws.send(json.dumps({"send_text": "Welcome to Kova. ", "context_id": "c1"}))
        await ws.send(json.dumps({"send_text": "Second sentence.", "context_id": "c1"}))
        await ws.send(json.dumps({"flush": True, "context_id": "c1", "flush_id": "f1"}))

        async for raw in ws:
            f = json.loads(raw)
            if "audio_chunk" in f:
                audio.extend(base64.b64decode(f["audio_chunk"]))
            elif "flush_completed" in f and f.get("flush_id") == "f1":
                break                      # match YOUR flush_id, not any flush_completed
        await ws.send(json.dumps({"close_context": True, "context_id": "c1"}))

    import wave                            # pcm is headerless; add a WAV header yourself
    with wave.open("out.wav", "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(32000)
        w.writeframes(bytes(audio))

asyncio.run(main())
```

## 9. Errors

| Status | Body                                                             | Meaning                                                                                      |
| ------ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| 401    | `{"error":"Invalid or missing API key"}`                         | Header absent, or does not start with `kova_sk_`. Also returned for `Authorization: Bearer`. |
| 401    | `{"error":"INVALID_API_KEY"}`                                    | Well-formed `kova_sk_` key that does not exist or was revoked.                               |
| 402    | `{"error":"INSUFFICIENT_CREDITS"}`                               | Account out of credit. Resolves as soon as credit is added; the key stays valid.             |
| 422    | `{"detail":[{"type":..., "loc":[...], "msg":..., "input":...}]}` | Body validation failed. `detail` may hold several errors.                                    |
| 429    | `{"error":"Rate limit exceeded","reason":"concurrency"}`         | 9 requests already in flight for this key.                                                   |
| 405    | `{"detail":"Method Not Allowed"}`                                | Wrong HTTP method.                                                                           |
| 500    | `Internal Server Error` (**plain text**, not JSON)               | Genuine server fault. Transient; retry with backoff.                                         |

### Unknown voice returns a different 422 shape

A bad `voice` returns `422`, but **without** the `detail` array that every other validation error uses:

```json theme={null}
{
  "error": "unknown_voice",
  "message": "Unknown voice 'nope'. Valid voices: ash, ash_conv, cal, mira, mira_conv, owen, paul, tess, theo",
  "voice": "nope",
  "valid_voices": ["ash", "ash_conv", "cal", "mira", "mira_conv", "owen", "paul", "tess", "theo"]
}
```

Branch on which key is present — code that assumes `detail` exists on every 422 will raise here. Returned by `POST /v1/tts`, `POST /v1/tts/stream`, and `POST /v1/tts/integrations/vapi`.

Notes for error handling code:

* **Do not assume error bodies are JSON.** 500 responses are `text/plain`. Guard your `.json()` parse.
* **`x-request-id` is returned on every response, including 500s.** Log it.
* **429 carries no `Retry-After` header.** Back off on your own schedule: start at 200 ms, exponential with jitter, cap around 5 s.
* A 422 on `response_format` reports `loc` as `["body","response_format","encoding"]` and a `msg` beginning `"Value error, "`.

### Common 422 messages

```text theme={null}
Value error, Unsupported audio encoding: flac
Value error, mp3 audio sample_rate must be between 16000 and 48000 Hz
Value error, opus audio sample_rate must be one of 8000, 12000, 16000, 24000, or 48000 Hz
Value error, mulaw audio requires an 8000 Hz sample rate
Value error, mp3 audio bitrate must be between 32kbps and 320kbps
Value error, opus audio bitrate must be between 32kbps and 192kbps
Value error, wav audio does not support a bitrate option
Value error, Invalid mp3 audio bitrate: abc
Input should be greater than or equal to 0.5          (temperature)
Input should be less than or equal to 3               (temperature)
Extra inputs are not permitted                        (unknown field)
```

## 10. Concurrency

One budget of **9 concurrent in-flight requests per API key**, shared by HTTP and WebSocket. An open WebSocket that is generating occupies one slot no matter how many utterances pass through it. The 10th concurrent request gets 429 immediately — nothing queues server-side.

Hold a client-side semaphore of 9 (or fewer). For more throughput, use additional API keys; each key has its own budget.

## 11. Integrations

### Vapi — `POST /v1/tts/integrations/vapi?voice=<speaker_id>`

Configure the assistant's voice as Vapi's `custom-voice` provider pointing at this URL, with `x-api-key` in `server.headers`. The `voice` query parameter is **required**; omitting it returns 422.

Vapi posts `{"message":{"type":"voice-request","text":"...","sampleRate":24000}}`. The endpoint returns **raw headerless 16-bit little-endian mono PCM** as `application/octet-stream` — not JSON, not base64.

`sampleRate` accepts **any value from 8000 to 48000 Hz**, not just Vapi's standard rates; outside that range returns 422. Omitting `sampleRate` is accepted and uses a default. Only `message.type == "voice-request"` synthesizes — other message types are acknowledged with an empty 200.

### Ultravox — generic external TTS

Point `externalVoice.generic` at `https://api.kova.ai/v1/tts` with `body.text` set to the literal `"{text}"` placeholder, `response_format.encoding` set to `"pcm"`, and `jsonAudioFieldPath: "audio"`. Keep `responseSampleRate` equal to `response_format.sample_rate`. Kova responds with `Content-Type: application/json`; the `responseMimeType: "application/jsonl"` value is Ultravox's parser selector, not Kova's content type.

## 12. Behavior worth knowing

* **Output is non-deterministic.** The same text, voice and format produce different audio and different byte counts on every call, even at a fixed `temperature`. Do not hash or diff output to test for equality; assert on duration or successful decode instead.
* **Empty or whitespace-only `text` succeeds**, returning a short silent file (\~621 bytes of mp3). Validate on your side if that is not what you want.
* **No SSML, and no markup handling of any kind.** `<speak>`, `<break/>` and HTML entities like `&amp;` are all spoken as literal words. Strip markup before sending.
* **All text is accepted**: emoji, CJK, Arabic, Cyrillic, accented Latin, URLs, tabs, newlines and control characters never produce an error.
* **`timestamps` word arrays contain your original tokens**, punctuation attached (`"Kova."`, `"$19.99."`). `normalize_text` changes pronunciation but does not change these tokens.
* **An unknown `voice` returns a `422` whose body lists every valid voice** in `valid_voices`. You can recover the live catalog from the error itself, without a second call.

## 13. Checklist for a correct integration

1. Read `voice` ids from `GET /v1/tts/speakers` at startup; never hardcode.
2. Send `x-api-key`, never `Authorization: Bearer`.
3. Keep in-flight requests at 9 or fewer per key.
4. Set client timeouts to 180 s+ on `/v1/tts`, or use `/v1/tts/stream` for text over \~1000 chars.
5. Guard error parsing — 500 bodies are plain text, and the `unknown_voice` 422 has no `detail` array.
6. On 429, back off with jitter; there is no `Retry-After`.
7. Base64-decode `audio` / `audio_chunk` before writing bytes.
8. Append `timestamps` events; never replace.
9. Over WebSocket, match `flush_completed` on your own `flush_id`, and ignore the synthetic `"<ctx>:close"` one.
10. Use `pcm` rather than `linear16` when streaming, and write one WAV header yourself.
11. Pass `bitrate` in kbps (`"128k"` or `128`), never bits per second.
12. Do not assert byte-equality between runs; output is non-deterministic.
13. Keep one long-lived HTTP client and read every response to completion — a per-request connection adds \~90 ms of TLS setup to a \~185 ms budget.
