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: Beareris not supported and returns 401. - Content type:
application/jsonon all request bodies. - Audio is returned base64-encoded inside JSON on
POST /v1/ttsand 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.
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:
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 eachaudio_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
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
pcmat 32000 Hz — headerless signed 16-bit little-endian mono. Override per context withresponse_format. context_startedechoes 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_idis optional. If you omit it the context still works and server frames come back with nocontext_idkey. Always set it if you plan to run more than one context.model_idis required. Send"default".flush_idis optional. If you omit it the server generates a UUID and returns that inflush_completed. Always send your own so you can match the reply.close_contextemits two frames, in this order: aflush_completedwhoseflush_idis the synthetic string"<context_id>:close", thencontext_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 syntheticflush_completedas a reply to one of your own flushes — match on your ownflush_idvalues.timestampsframes arrive incrementally, same as streaming HTTP. Append them.errorframes are not fatal. The socket stays open and other contexts keep working. Sendingsend_textorflushfor 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_idreturns an error frame naming every valid voice; the socket stays usable. - A malformed
start_contextreturns{"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
9. Errors
Unknown voice returns a different 422 shape
A badvoice returns 422, but without the detail array that every other validation error uses:
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-idis returned on every response, including 500s. Log it.- 429 carries no
Retry-Afterheader. Back off on your own schedule: start at 200 ms, exponential with jitter, cap around 5 s. - A 422 on
response_formatreportslocas["body","response_format","encoding"]and amsgbeginning"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
PointexternalVoice.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
textsucceeds, 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&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.
timestampsword arrays contain your original tokens, punctuation attached ("Kova.","$19.99.").normalize_textchanges pronunciation but does not change these tokens.- An unknown
voicereturns a422whose body lists every valid voice invalid_voices. You can recover the live catalog from the error itself, without a second call.
13. Checklist for a correct integration
- Read
voiceids fromGET /v1/tts/speakersat startup; never hardcode. - Send
x-api-key, neverAuthorization: Bearer. - Keep in-flight requests at 9 or fewer per key.
- Set client timeouts to 180 s+ on
/v1/tts, or use/v1/tts/streamfor text over ~1000 chars. - Guard error parsing — 500 bodies are plain text, and the
unknown_voice422 has nodetailarray. - On 429, back off with jitter; there is no
Retry-After. - Base64-decode
audio/audio_chunkbefore writing bytes. - Append
timestampsevents; never replace. - Over WebSocket, match
flush_completedon your ownflush_id, and ignore the synthetic"<ctx>:close"one. - Use
pcmrather thanlinear16when streaming, and write one WAV header yourself. - Pass
bitratein kbps ("128k"or128), never bits per second. - Do not assert byte-equality between runs; output is non-deterministic.
- 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.