Skip to content
KugelAudioDocs

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

POST/v1/audio/transcriptions

Two 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_b64
string
body

Base64-encoded audio (JSON body only). Mutually exclusive with the multipart file field.

file
file
body

The audio file (multipart upload only).

model
string
bodydefault: kugel-ear-1

Optional. 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.

language
string | string[]
bodydefault: auto

Optional. 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.

prompt
string
body

Multipart only. Accepted for OpenAI Whisper API compatibility.

boosted_phrases
string[]
body

Your 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_ids
integer[]
body

Optional. 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_id
integer
body

Optional. The project whose dictionaries apply. Omitted, a project API key uses its own project.

sample_rate
integer
bodydefault: 16000

JSON 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_format
string
bodydefault: json

Only json is currently accepted. srt, vtt, verbose JSON and SSE upload streaming are rejected explicitly rather than silently downgraded.

Response fields

FieldTypeDescription
textstringOpenAI-compatible alias of transcript.
transcriptstringThe transcribed text.
languagestringThe 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_snumberDuration of the accepted audio, in seconds.
modelstringAlways "kugel-ear-1".
model_revisionstring | nullThe immutable revision of the model build that produced the result. Pin qualification or debugging notes to this, not to model.
word_timestampsarrayEmpty for kugel-ear-1. See Not promised.
word_alternativesarrayOrdered 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.
hintsobject{"entity_in_progress": bool}.
processing_msnumber | nullServer processing time for the request.

Additional fields may appear; ignore unknown keys.

Examples

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)"'"
  }'

Response

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:

JSON
{
  "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:

Text
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"]
  }'

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.

LimitValue
Phrases per request5,000 distinct phrases, after trimming and dropping repeats (case-insensitive)
Length of one phrase64 characters
CharactersNo <, > 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:

JSON
{"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 sendWords used
nothingEvery 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:

JSON
{"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.

Statuserror_codeBody / trigger
400VALIDATION_ERRORMultipart model form field with an unsupported value: {"error": "Unsupported ASR model; use kugel-ear-1", "error_code": "VALIDATION_ERROR", "code": 400}
400VALIDATION_ERRORresponse_format other than json: Unsupported response format
400VALIDATION_ERRORdictionary_ids names a dictionary outside the project, or is sent without a project: Unknown dictionary_ids for this project: [99]. See Your dictionaries.
401UNAUTHORIZEDMissing or invalid API key. Checked before the model field, so a bad key plus a bad model still returns 401.
402INSUFFICIENT_CREDITSThe organization's balance is spent.
403UNAUTHORIZEDTranscription is not enabled for this organization.
403UNAUTHORIZEDproject_id names a project you are not a member of while you select dictionary_ids.
403PERMISSION_DENIEDThe API key's permissions do not allow Speech to text.
413VALIDATION_ERRORThe 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.
422VALIDATION_ERRORJSON body with an unsupported model: {"error": "Invalid transcription request", "error_code": "VALIDATION_ERROR", "code": 422}
422VALIDATION_ERRORboosted_phrases, alone or with your dictionaries' words, over a limit; the message names the limit.
4xxVALIDATION_ERRORThe audio could not be transcribed (for example, bytes that cannot be decoded): {"error": "ASR request failed", "error_code": "VALIDATION_ERROR", "code": <status>}, usually 422.
429RATE_LIMITEDRate limit reached. Honour Retry-After when present.
502MODEL_UNAVAILABLETranscription failed on our side: ASR backend unavailable. Retry.
503MODEL_UNAVAILABLESpeech 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

WSS/v1/audio/transcriptions/stream

Connect 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:

Text
wss://api.kugelaudio.com/v1/audio/transcriptions/stream?api_key=YOUR_API_KEY

The X-API-Key and Authorization: Bearer headers also work. See Authentication.

Client frames

JSON
{"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.

ValueThe 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".

ValueBehaviour
"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.

JSON
{"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:

JSON
{"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_text is the complete rolling hypothesis; replace your previous value rather than appending. Partials never carry model or model_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 carries audio_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 carries model and model_revision, and the authoritative result for the turn. The turn's language arrives on its revision (below), not here; see Supported languages. It also carries decision_to_final_ms: the server time in milliseconds from the input that ended the turn (your end_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 never is_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, its partial_text is 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; its word_alternatives index this frame's text, not the final's. It also carries language, the code of the language the turn was transcribed in.

turn_end_reason on the final frame is one of exactly four values:

ValueMeaning
client_end_of_speechYou sent end_of_speech.
model_end_of_turnThe server detected that the speaker finished.
silence_timeoutThe server ended the turn after its pause limit: 3.5 seconds with "kugelturn" (3 seconds at the "conservative" level), 1 second with "vad".
max_durationThe 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.

Statuserror_codeMeaning
401UNAUTHORIZEDMissing or invalid credential.
402INSUFFICIENT_CREDITSThe organization's balance is spent.
403UNAUTHORIZEDAuthenticated, but kugel-ear-1 has not been released to this organization.
403PERMISSION_DENIEDThe API key's permissions do not allow Speech to text.
429RATE_LIMITEDRate limit or open-connection cap reached. Honour Retry-After when present.
503MODEL_UNAVAILABLESpeech 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.

CodeMeaning
1000Normal close.
1003The client asked for a model other than kugel-ear-1, or sent a binary WebSocket frame instead of a JSON audio_chunk.
1011Transcription failed mid-stream.
1012The server is restarting for an update. The authoritative final frame for the audio you already sent arrives before this close; reconnect to continue.
1013The server is restarting and accepted the connection only to close it. Reconnect.
4003The organization's balance ran out mid-stream (after an INSUFFICIENT_CREDITS error frame).
4xxxOther 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 sendThe 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 languageIn 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.

JSON
{"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.
JSON
{"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_chunk messages, at the rate declared in config (default 16000 Hz).
  • Maximum duration: 300 seconds, with or without boosted_phrases and dictionary words. A longer upload is rejected with 413 before it is transcribed; split it into shorter segments. On the streaming path, a turn that reaches the limit is ended by the server with turn_end_reason: "max_duration", and the audio that follows starts a new turn on the same connection. A single audio_chunk longer than the limit is refused with an error frame.
  • Partial cadence: partials are due roughly every 4 seconds of accepted audio; see Server frames above.

Not promised

No word or segment timestamps and no diarization from kugel-ear-1 today. word_timestamps exists in the response shape for models that produce it; for kugel-ear-1 it is always empty, meaning "not produced," never "no speech detected."

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.