Skip to main content
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):
Other machine-readable entry points: 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

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.
Unknown fields are rejected. The schema is strict: any extra key returns 422 extra_forbidden.

4. response_format

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: Prefer the canonical names. Unsupported encodings (flac, ogg, aac, "") return 422.

5. POST /v1/tts — synchronous

Returns after generation completes.
Response:
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:
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

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

7. GET /v1/tts/speakers

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

Server → client

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_completedcontext_closed. Frames from different contexts interleave freely and fairly; demultiplex on context_id.

Complete session

9. Errors

Unknown voice returns a different 422 shape

A bad voice returns 422, but without the detail array that every other validation error uses:
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

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.