Speech to Text API
REST and WebSocket reference for speech to text with kugel-ear-1: upload a recording or stream live audio, custom vocabulary, word alternatives, errors and close codes.
kugel-ear-1 transcribes speech in nine European languages, in the language
you name or the one it detects among those you list. Upload a complete recording over REST, or
stream live PCM16 audio over WebSocket and receive revisable partial
hypotheses plus an authoritative final result.
All requests need an API key whose permissions allow Speech to text. This page is the wire protocol; what the model does and how to choose between upload and streaming are in the Speech to text guide, and the SDK calls are in the SDK reference.
Upload a complete recording
/v1/audio/transcriptionsTwo request shapes are accepted: a JSON body with base64 audio, or a
multipart/form-data upload in the OpenAI Whisper API shape.
Request fields
audio_b64Base64-encoded audio (JSON body only). Mutually exclusive with the
multipart file field.
fileThe audio file (multipart upload only).
modelkugel-ear-1Optional. Omit it to select kugel-ear-1. An explicit value must equal
kugel-ear-1 exactly; any other value is rejected before the file is read or
usage is recorded.
languageautoOptional. A two-letter code such as "fr" transcribes in that language; a
list such as ["de", "fr"] detects the language among those codes, expecting
the first; "auto" or omitted detects German or English. In a multipart upload, repeat the
language field for a list. An unsupported code answers 422. See
Supported languages.
promptMultipart only. Accepted for OpenAI Whisper API compatibility.
boosted_phrasesYour vocabulary for this request: names, brands and domain terms the
recognizer should spell the way you write them. A JSON array in the JSON
body; one repeated boosted_phrases form field per phrase in a multipart
upload. See Custom vocabulary.
dictionary_idsOptional. The Dictionaries whose words this request
uses as vocabulary. Omit it to use every active dictionary of the project;
send [] to use none. One repeated dictionary_ids form field per ID in a
multipart upload, and a single empty dictionary_ids field for none. See
Your dictionaries.
project_idOptional. The project whose dictionaries apply. Omitted, a project API key uses its own project.
sample_rate16000JSON body only, 8000 to 48000. The endpoint reads the real sample rate out of the uploaded container, so this field only matters for raw PCM without a container header.
response_formatjsonOnly json is currently accepted. srt, vtt, verbose JSON and SSE
upload streaming are rejected explicitly rather than silently downgraded.
Response fields
| Field | Type | Description |
|---|---|---|
text | string | OpenAI-compatible alias of transcript. |
transcript | string | The transcribed text. |
language | string | The two-letter code of the language kugel-ear-1 transcribed, such as "de" or "en": the one you named, or the one it detected. |
duration_s | number | Duration of the accepted audio, in seconds. |
model | string | Always "kugel-ear-1". |
model_revision | string | null | The immutable revision of the model build that produced the result. Pin qualification or debugging notes to this, not to model. |
word_timestamps | array | Empty for kugel-ear-1. See Not promised. |
word_alternatives | array | Ordered spelling candidates for words the recognizer may have written differently from what the caller meant, with probabilities that sum to 1 per word. Present inline on HTTP; on WebSocket it arrives as a separate deferred event. See Using word alternatives. |
hints | object | {"entity_in_progress": bool}. |
processing_ms | number | null | Server processing time for the request. |
Additional fields may appear; ignore unknown keys.
curl https://api.kugelaudio.com/v1/audio/transcriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audio_b64": "'"$(base64 -i speech.wav)"'"
}'curl https://api.kugelaudio.com/v1/audio/transcriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@speech.wav" \
-F "model=kugel-ear-1" \
-F "response_format=json"{
"text": "I'd like to book a flight to Berlin.",
"transcript": "I'd like to book a flight to Berlin.",
"language": "en",
"duration_s": 3.42,
"model": "kugel-ear-1",
"model_revision": "...",
"word_timestamps": [],
"word_alternatives": []
}Using word alternatives
Each entry names one word of transcript by its position (raw_word_index
counts maximal runs of letters or digits, so follow-up is two words) and
lists complete spellings for it, most likely first, the written spelling always
included:
{
"transcript": "Hi, my name is Catherine Reid.",
"word_alternatives": [
{
"raw_word_index": 4,
"word": "Catherine",
"alternatives": [
{"spelling": "Catherine", "probability": 0.55},
{"spelling": "Katherine", "probability": 0.30},
{"spelling": "Kathryn", "probability": 0.15}
]
},
{
"raw_word_index": 5,
"word": "Reid",
"alternatives": [
{"spelling": "Reid", "probability": 0.50},
{"spelling": "Reed", "probability": 0.35},
{"spelling": "Read", "probability": 0.15}
]
}
]
}Candidates come from the audio (the recognizer heard something close to
another word, typical on a noisy line) and from other spellings of name-like
words that sound the same, which the audio can never settle. A word with no
entry was written with no plausible alternative; an empty list means none were
produced for the whole utterance. Alternatives never hold back or replace the
transcript: if they cannot be computed, HTTP returns the transcript with an
empty list and a WebSocket turn gets no alternatives frame, so do not block
the next turn waiting for one.
The list is deliberately conservative: an entry is a genuine spelling question, not every low-probability word. Common function and boilerplate words and mail-provider/domain tokens are never reported.
When the transcript feeds a language model that fills in forms, pass the alternatives along with the text so the agent can ask one targeted question instead of making the caller spell everything:
Transcript: Hi, my name is Catherine Reid.
Uncertain spellings: Catherine (or Katherine, Kathryn); Reid (or Reed, Read)Custom vocabulary
Give the recognizer the names, brands and domain terms of your calls and it
biases its decoding toward them, so a name it would otherwise write by ear
comes out in your spelling. The words come from two places: your
project's Dictionaries, applied without sending anything,
and the boosted_phrases you send with a request.
curl https://api.kugelaudio.com/v1/audio/transcriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audio_b64": "'"$(base64 -i call.wav)"'",
"boosted_phrases": ["Kratzenstein", "KugelAudio", "Mayrhofer"]
}'curl https://api.kugelaudio.com/v1/audio/transcriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@call.wav" \
-F "boosted_phrases=Kratzenstein" \
-F "boosted_phrases=KugelAudio" \
-F "boosted_phrases=Mayrhofer"{"type": "config", "sample_rate": 16000, "boosted_phrases": ["Kratzenstein", "KugelAudio", "Mayrhofer"]}On a stream the list belongs to the connection: it applies to every turn
until you send another config frame. The partials and a long turn's fast
final can still miss a listed spelling, because they come first; the turn's
revision (the alternatives frame with revises_final: true) is decoded with
the list and carries it. A short turn's final already does.
A short list helps most. When your boosted_phrases are at most 20 phrases
that fit a small text budget, they are also given to the recognizer as
context, which matters most for one-word answers and names that sound alike.
Your dictionaries' words do not count toward these 20: they bias the
recognizer however many there are. A voice bot gets the strongest effect by
sending, with each question, the answers it expects to that question: send a
new config frame with that question's boosted_phrases. A longer list still
biases the recognizer, only without the context.
Write each phrase exactly as it should appear in the transcript. A phrase is a hint, not a replacement rule: phrases bias the recognizer toward the listed words, so it still writes what it hears and prefers your spelling where the audio fits it. The pull works on near misses too, so a word that only sounds like a listed one can come out as the listed one. List what speakers in this audio may actually say, not everything you have: the closer the list is to the call, the more it helps and the less it costs.
| Limit | Value |
|---|---|
| Phrases per request | 5,000 distinct phrases, after trimming and dropping repeats (case-insensitive) |
| Length of one phrase | 64 characters |
| Characters | No <, > or control characters |
Phrases do not shorten the maximum audio length: an upload or streamed turn with phrases may be as long as one without (see Audio limits).
The limits count your boosted_phrases and your dictionaries' words together.
A list over a limit is rejected, never shortened. An upload answers 422. A
stream answers an error frame that names the limit and keeps the session as it
was, so send a corrected config frame on the same socket:
{"error": "boosted_phrases carries 5001 distinct phrases; the maximum is 5000", "error_code": "VALIDATION_ERROR", "code": 422}Your dictionaries
The Dictionaries you keep for text to speech also
teach the recognizer: the word of every entry is used as a phrase, after your
request's boosted_phrases. Its pronunciation (replacement or ipa) plays
no part in transcription.
Which dictionaries apply follows the same rules as for text to speech:
| You send | Words used |
|---|---|
| nothing | Every active dictionary of the project. A dictionary with a language applies when the request's language names it, or when language is omitted or "auto". |
dictionary_ids: [12, 31] | Exactly these dictionaries, active or not, whatever their language. An ID outside the project answers 400. |
dictionary_ids: [] | None. |
The project is the one your API key belongs to, or the project_id you send.
On a stream, send project_id and dictionary_ids on the config frame;
a config frame without them uses the project's active dictionaries.
Dictionary words are phrases: they count against the phrase limits above,
and like your boosted_phrases they do not shorten the maximum audio length.
When your dictionaries do not fit beside your boosted_phrases, the request
is refused with a message that says so; choose fewer with dictionary_ids:
{"error": "boosted_phrases and the 4980 words of your project's dictionaries do not fit the phrase limits (boosted_phrases carries 5030 distinct phrases; the maximum is 5000); choose dictionaries with dictionary_ids, or send dictionary_ids: [] to apply none", "error_code": "VALIDATION_ERROR", "code": 422}Errors
Errors return the standard JSON error body; see Error Codes.
| Status | error_code | Body / trigger |
|---|---|---|
400 | VALIDATION_ERROR | Multipart model form field with an unsupported value: {"error": "Unsupported ASR model; use kugel-ear-1", "error_code": "VALIDATION_ERROR", "code": 400} |
400 | VALIDATION_ERROR | response_format other than json: Unsupported response format |
400 | VALIDATION_ERROR | dictionary_ids names a dictionary outside the project, or is sent without a project: Unknown dictionary_ids for this project: [99]. See Your dictionaries. |
401 | UNAUTHORIZED | Missing or invalid API key. Checked before the model field, so a bad key plus a bad model still returns 401. |
402 | INSUFFICIENT_CREDITS | The organization's balance is spent. |
403 | UNAUTHORIZED | Transcription is not enabled for this organization. |
403 | UNAUTHORIZED | project_id names a project you are not a member of while you select dictionary_ids. |
403 | PERMISSION_DENIED | The API key's permissions do not allow Speech to text. |
413 | VALIDATION_ERROR | The recording is longer than 300 seconds: {"error": "Audio exceeds the maximum duration for one transcription request.", "error_code": "VALIDATION_ERROR", "code": 413}. Split it into shorter segments. See Audio limits. |
422 | VALIDATION_ERROR | JSON body with an unsupported model: {"error": "Invalid transcription request", "error_code": "VALIDATION_ERROR", "code": 422} |
422 | VALIDATION_ERROR | boosted_phrases, alone or with your dictionaries' words, over a limit; the message names the limit. |
4xx | VALIDATION_ERROR | The audio could not be transcribed (for example, bytes that cannot be decoded): {"error": "ASR request failed", "error_code": "VALIDATION_ERROR", "code": <status>}, usually 422. |
429 | RATE_LIMITED | Rate limit reached. Honour Retry-After when present. |
502 | MODEL_UNAVAILABLE | Transcription failed on our side: ASR backend unavailable. Retry. |
503 | MODEL_UNAVAILABLE | Speech to text is unavailable right now, or not set up for your organization yet. Contact support if it persists. |
Rejection for an unsupported model happens after authentication and before any transcription or usage recording, so a rejected request is never billed.
Stream live audio
/v1/audio/transcriptions/streamConnect with your API key, send a config frame, then base64 PCM16 audio
chunks. The server ends each turn when the speaker finishes and keeps the
connection open for the next one; end_of_speech ends the last turn and the
connection:
wss://api.kugelaudio.com/v1/audio/transcriptions/stream?api_key=YOUR_API_KEYThe X-API-Key and Authorization: Bearer headers also work. See
Authentication.
Client frames
{"type": "config", "sample_rate": 16000, "model": "kugel-ear-1", "turn_detection": "kugelturn", "boosted_phrases": ["Kratzenstein"]}
{"type": "audio_chunk", "audio_b64": "..."}
{"type": "end_of_speech"}turn_detection is optional and decides who ends a turn. Stream the audio as
it is, pauses included; you never send a voice-activity signal.
| Value | The turn ends |
|---|---|
"kugelturn" (default) | When the speaker finished, as KugelTurn, our end-of-turn model, hears it from 200 ms of pause; after 3.5 seconds of pause at the latest (3 seconds with "conservative"). |
"vad" | After 1 second of pause. |
"manual" | Only when you send end_of_speech. |
turn_detection_level is optional and only works with "kugelturn". It sets
how readily the model ends a turn; the 200 ms start is the same for both levels,
and the fallback is 3.5 seconds with "responsive" and 3 seconds with
"conservative".
| Value | Behaviour |
|---|---|
"responsive" (default) | Ends turns soonest. |
"conservative" | Holds longer through a pause mid-thought, so fewer turns are cut off early. |
A number being dictated in groups, such as a phone number ("Meine
Telefonnummer lautet 0 1 … 6332 2 … 896"), does not end its turn before
2 seconds of pause with "responsive" (2.5 seconds with "conservative"), so
the next group joins the same turn. A turn counts as dictation when it ends on a
number and either has several number groups, names what is dictated
(Telefonnummer, IBAN, PLZ, phone number, …), or follows a turn that itself ended
on a number. A plain answer such as "Ich bin 25" ends as quickly as any other
turn.
{"type": "config", "sample_rate": 16000, "turn_detection": "kugelturn", "turn_detection_level": "conservative"}A level with "vad" or "manual" is answered with config_rejected.
language is optional and takes the same values as on an upload; see
Supported languages.
boosted_phrases, dictionary_ids and project_id are optional; see
Custom vocabulary.
model on the config frame is optional. Omit it, or send "kugel-ear-1".
Any other value is rejected before any audio is processed:
{"error": "Unsupported ASR model; use kugel-ear-1", "error_code": "VALIDATION_ERROR", "code": 400}followed by WebSocket close code 1003 (reason: the same message).
Server frames
- Partial:
{"type": "partial", "partial_text": "...", "is_final": false}.partial_textis the complete rolling hypothesis; replace your previous value rather than appending. Partials never carrymodelormodel_revision; that absence is how you tell a revisable guess from an attributable result. Partials arrive roughly every 4 seconds of accepted audio, not wall-clock time; a turn shorter than 4 seconds produces no partial at all, only the final frame. Each partial carriesaudio_end_s: the seconds of accepted audio, from the start of the turn, its text was decoded from. - Final:
{"type": "partial", "partial_text": "...", "is_final": true, "model": "kugel-ear-1", "model_revision": "...", "turn_end_reason": "client_end_of_speech", "word_alternatives": []}. The only frame that carriesmodelandmodel_revision, and the authoritative result for the turn. The turn's language arrives on its revision (below), not here; see Supported languages. It also carriesdecision_to_final_ms: the server time in milliseconds from the input that ended the turn (yourend_of_speech, or the audio that crossed the end-of-turn decision) to this frame being sent. For a short turn (a one-word answer, a date, a short phrase) the final already carries the second pass's transcript, so it can arrive slightly later than a long turn's; the revision that follows repeats it. - Alternatives:
{"type": "alternatives", "partial_text": "...", "model": "kugel-ear-1", "model_revision": "...", "word_alternatives": [...]}. Arrives after the final, is neveris_final, and repeats the final's text so it can be handled independently. An empty list means none were produced. If scoring fails the frame is not sent, so treat it as optional. See Using word alternatives. When it carries"revises_final": true, itspartial_textis a more accurate transcript of the whole turn than the final's: the turn was transcribed again after the final was sent, typically within a second. If that second pass has not finished within 5 seconds the frame is not sent. Replace the final's text with it if you can update a result after the fact; itsword_alternativesindex this frame's text, not the final's. It also carrieslanguage, the code of the language the turn was transcribed in.
turn_end_reason on the final frame is one of exactly four values:
| Value | Meaning |
|---|---|
client_end_of_speech | You sent end_of_speech. |
model_end_of_turn | The server detected that the speaker finished. |
silence_timeout | The server ended the turn after its pause limit: 3.5 seconds with "kugelturn" (3 seconds at the "conservative" level), 1 second with "vad". |
max_duration | The turn reached the maximum turn length (300 seconds, with or without phrases). The server ended it with the audio received so far; the connection stays open and your next audio_chunk starts a new turn. |
Treat any other value as a protocol error rather than guessing at its meaning.
Refused upgrades
A connection refused before the upgrade completes answers with an ordinary
HTTP status and the standard error body, not a WebSocket close code. The
websockets Python client raises InvalidStatus and exposes the response on
InvalidStatus.response; browsers surface the status on the failed handshake.
| Status | error_code | Meaning |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid credential. |
402 | INSUFFICIENT_CREDITS | The organization's balance is spent. |
403 | UNAUTHORIZED | Authenticated, but kugel-ear-1 has not been released to this organization. |
403 | PERMISSION_DENIED | The API key's permissions do not allow Speech to text. |
429 | RATE_LIMITED | Rate limit or open-connection cap reached. Honour Retry-After when present. |
503 | MODEL_UNAVAILABLE | Speech to text is unavailable right now, or not set up for your organization yet. |
Distinguishing 429 from 401 matters: a client that reads a refused upgrade
as an auth failure will rotate a perfectly good API key instead of backing off.
Close codes
These apply once the socket is open.
| Code | Meaning |
|---|---|
1000 | Normal close. |
1003 | The client asked for a model other than kugel-ear-1, or sent a binary WebSocket frame instead of a JSON audio_chunk. |
1011 | Transcription failed mid-stream. |
1012 | The server is restarting for an update. The authoritative final frame for the audio you already sent arrives before this close; reconnect to continue. |
1013 | The server is restarting and accepted the connection only to close it. Reconnect. |
4003 | The organization's balance ran out mid-stream (after an INSUFFICIENT_CREDITS error frame). |
4xxx | Other 4000+ codes end the stream on an error; read the close reason. |
See Error Codes for the shared HTTP/WebSocket error body shape.
Supported languages
kugel-ear-1 transcribes German (de), English (en), French (fr),
Italian (it), Spanish (es), Portuguese (pt), Dutch (nl), Polish (pl)
and Romanian (ro). The Speech to text guide
explains how to choose.
The language field is the same on the JSON body, the multipart form and the
WebSocket config frame. It takes two-letter ISO 639-1 codes, exactly as the
text to speech API does:
| You send | The audio is transcribed |
|---|---|
"fr" | In French. |
["de", "fr", "it"] | In the one of these languages the model detects, expecting the first: put the language you expect most first. In a multipart upload, repeat the language field once per code. |
"auto", or no language | In German or English, whichever the model detects. No other language is detected. |
For a language other than German and English, send its code or include it in a list; without one, the audio is transcribed as German or English.
Codes are matched exactly. An unknown code such as "ja", a regional tag such
as "de-DE", a name such as "German", or an empty list is refused, never
ignored: an upload answers 422, and a stream answers an error frame with
"code": 422 and keeps the session as it was, so send a corrected config
frame on the same socket.
{"type": "config", "sample_rate": 16000, "language": ["de", "fr"]}The response's language field reports the code the audio was transcribed in:
- Upload: chosen once per request, from the whole recording, and returned on every response.
- Live stream: chosen per turn, from that turn's audio, so a
conversation that switches between the languages you allow is followed. A
turn stays in the language the stream expects (the first in your list, then
the language of the last turn) unless its audio is clearly another: a short
"Mhm" or "Äh" sounds alike in many languages, a sentence does not. The
language is reported on the turn's revision frame (
"type": "alternatives"with"revises_final": true), which arrives shortly after the final. The final frame and the partials do not carry it. A turn whose revision is not sent has no reported language, so treat the field as optional.
{"type": "alternatives", "partial_text": "Ich möchte einen Flug nach Berlin buchen.", "revises_final": true, "language": "de", "model": "kugel-ear-1", "model_revision": "...", "word_alternatives": []}The live partials are most accurate in English, German, Spanish and French; the turn's revision is transcribed in the language you chose.
Automatic end-of-turn detection (model_end_of_turn) works in every supported
language; see End of turn with KugelTurn.
Audio limits
- Containers: complete WAV/RIFX/RF64, FLAC and Ogg files, plus most other common container formats. Sample rates from 8 kHz to 48 kHz are accepted; audio is resampled internally as needed.
- Streaming input: raw PCM16 mono, base64-encoded in
audio_chunkmessages, at the rate declared inconfig(default 16000 Hz). - Maximum duration: 300 seconds, with or without
boosted_phrasesand dictionary words. A longer upload is rejected with413before it is transcribed; split it into shorter segments. On the streaming path, a turn that reaches the limit is ended by the server withturn_end_reason: "max_duration", and the audio that follows starts a new turn on the same connection. A singleaudio_chunklonger than the limit is refused with anerrorframe. - Partial cadence: partials are due roughly every 4 seconds of accepted audio; see Server frames above.
SDKs
The Python, TypeScript and Java SDKs wrap the upload as client.asr.transcribe(...)
(client.asr().transcribe(...) in Java); see the
SDK reference. In every SDK, model is an optional plain
string: leave it out and the request carries no model field, and every response
reports "model": "kugel-ear-1".
The Python and TypeScript SDKs stream live audio as client.asr.stream(...): it
sends the config frame, the audio_chunk frames and end_of_speech, and yields
every server frame parsed; see
Stream live audio in the SDK reference.
The Java SDK has no streaming client yet; use the protocol in
Stream live audio directly.