Skip to main content

STT WebSocket

Real-time Speech-to-Text transcription via WebSocket streaming with support for 39 languages (including code-switched Indic+English) and telephony integration. Powered by 60db STT v01 (a non-hallucinating, multi-backend speech recognition stack).

Endpoint

Authentication

Query parameter authentication: Examples:
The WebSocket connection checks workspace wallet balance before starting a session. If the workspace has insufficient credits, the connection is closed with a 1008 status code and an INSUFFICIENT_CREDITS error.

Connection Details

The same port accepts plain HTTP GET requests and responds 200 ok — safe for load-balancer health checks.

Session Lifecycle

Two-phase finals (context-gated LLM refinement). When you supply a context object on start, every utterance produces two transcription events sharing a sentence_id:
  1. First emitis_final: true, speech_final: false — fast dict-corrected text. Use for low-latency UI paint and barge-in.
  2. Canonicalis_final: true, speech_final: true — definitive LLM-refined answer. Always arrives.
When context is omitted, every utterance produces a single transcription event with is_final: true, speech_final: true (no first emit). Simple consumers can gate exclusively on speech_final: true regardless of whether refinement is on.
Do not send the start message until connection_established is received. The backend proxy attaches its client-message listener only after authenticating and opening the upstream connection. Messages sent earlier will be silently dropped. Likewise, do not send audio messages until session_started is received — the upstream returns unknown message type: audio if it arrives before the session is ready.

Client → Server Messages

start — Begin session

Sent once after connection is established. Must be sent before any audio.
Parameters:
Never send languages: "auto" or languages: ["auto"]. The auto-detect entry in GET /stt/languages is a convenience for the REST /stt form-upload flow only. On WebSocket, the server only accepts real ISO codes and uses null as the auto-detect signal. Sending "auto" returns language 'auto' is not in the v1 supported. The 60db WebSocket proxy (/ws/stt) strips the string "auto" from incoming start and config messages as a safety net, but your client should send null directly.

audio — JSON audio chunk (browser mode)

Fields:

Binary frame — raw μ-law audio (telephony mode)

Send a raw WebSocket binary frame with μ-law bytes, no JSON wrapper. The server auto-detects this as telephony mode on the first binary frame.

config — Change language mid-session

Both languages and continuous_mode are optional; include only fields you want to change. Send "languages": null to revert to auto-detect. The proxy applies the same sanitising here as on start: the literal string "auto" is stripped from languages, and a config.utterance_end_ms below 1000 ms is clamped up to 1000 ms.

stop — End session

Server processes any remaining audio buffer, sends session_stopped, then closes.

test — Ping / latency check

Server echoes test_response with the same timestamp for round-trip measurement.

Server → Client Messages

connecting — Authentication in progress

connection_established — Authentication successful

On STT these fields are top-level and the frame is identified by type. Branch on msg.type === "connection_established" — not on msg.connection_established, which is the TTS frame’s shape (/ws/tts nests the same fields under a connection_established key). A client written against the TTS shape will silently never start its STT session.
Fields:
string
Service name: "stt"
integer
Your user ID
number
Available credits
string
Workspace name

connected — Proxy wired to upstream STT server

Sent by the backend proxy after it has opened the upstream connection and attached its client-message listener. After this point, client messages are no longer dropped.
You will normally see two connected frames, and they carry different payloads. The proxy emits its own handshake frame first (session_id plus a small server_info), then forwards the upstream server’s capability frame (the one below, with features and timing). Both are informational — treat connection_established as “safe to send start” and session_started as “safe to send audio”, and make any connected handler idempotent so it does not start audio capture twice.
server_info on the proxy frame is diagnostic; do not branch on its contents.

session_startedstart message processed, audio is now accepted

Sent by the upstream STT server after a start message is received and validated. It is safe to begin sending audio frames immediately after this event.
Fields worth branching on:

speech_started — VAD detected voice activity

Use this for barge-in: interrupt TTS playback when this arrives. Fired after 2 consecutive VAD-positive chunks (~64ms of confirmed speech).

transcription — Transcription result

All results (interim and final) share the same transcription type — differentiate with flags. Final result (is_final=true, speech_final=true):
Empty speech_final signal (text="", is_final=true, speech_final=true): Sent when audio was detected but transcription was rejected (silence, hallucination, low confidence, wrong language). Client should reset its state on this message and not treat it as an error.
Interim result (is_final=false, speech_final=false) — only sent when interim_results_frequency is set:
Use interims only for barge-in word-count checks. Never send interim text to the LLM — a final with is_final=true, speech_final=true will follow. Response Fields:
string
Transcribed text. Empty string = speech-end-no-result signal.
number
0.0–1.0. Telephony typically 0.35–0.75; browser 0.55–0.95.
string
Detected language code e.g. "en".
string
Uppercase language code (e.g. "EN") in the WS shape. Note: REST /stt returns the full English name ("English") here — WS preserves the legacy uppercase-code shape for client compatibility.
boolean
true = end of speech reached. May still be followed by a canonical upgrade if LLM refinement is active.
boolean
true = canonical answer, will not be revised. When LLM refinement is on, one is_final: true, speech_final: false event is followed by one is_final: true, speech_final: true. When refinement is off, every final is speech_final: true. See Canonical-answer semantics.
boolean
true for interim results only.
integer
Monotonically increasing counter per session.
number
Duration (seconds) of the audio segment transcribed.
number
Seconds from processing start to result ready (excludes queue time).
array
Word-level timestamps [{word, start, end, confidence, boosted?, original?}]. Note: the field name is confidence, not probability (60db STT convention — different from legacy Whisper docs). Present on finals; empty on interims.When the keyword/context-terms boost replaced a word, the entry includes boosted: true and original (the pre-boost word):
The segment-level text is already rebuilt from boosted words upstream — no client-side stitching required. Recommended UI: subtle underline on boosted: true words, with original shown on hover.
array
List of [{speaker, start, end}] diarization turns when config.diarize=true. Omitted or null otherwise. Raw speaker IDs look like SPEAKER_00, SPEAKER_01; clients typically re-label these as “Speaker 1”, “Speaker 2” in order of first appearance.
string
Marker for utterances that should be skipped by the consumer (no useful text, never billed). Omitted on ordinary finals.
boolean
Present on canonical emits when refinement was attempted. true = text is the LLM-refined version. false = the LLM was skipped or failed and the first-emit text was promoted unchanged — the event still arrives, so consumers waiting on speech_final: true never hang.
string
Why llm_applied is false. Upstream values include gate_closed and error:* (timeout, HTTP error). The 60db proxy adds dropped_too_many_words when it rolled a refinement back — see the guardrail note below. Diagnostic; safe to ignore in display logic.
number
Round-trip time to the LLM endpoint, in ms. Present when the refinement call actually ran — including runs the proxy later rolled back, so SLA dashboards keep the timing.
boolean
Added by the 60db proxy when the upstream rejected an utterance as a suspected hallucination (processing_mode: "hallucination_rejected") but usable text was available from the interim or first emit. The proxy substitutes that text rather than delivering an empty final. Route on it, but flag it for review — accuracy is not guaranteed. Absent on ordinary events.
string
Why the event is tentative. Currently only hallucination_rejected.
number
Audio signal-to-noise ratio (dB) for this utterance, when measured. Optional. Surface as a “good / fair / poor” badge: >= 15 good, 0–15 fair, < 0 poor.

Canonical-answer semantics: speech_final

is_final and speech_final are NOT identical when LLM refinement is active — they split into two distinct meanings: The same sentence_id is echoed across both phases so clients can reconcile. Canonical event example (after LLM refinement):
Guarantees:
  • Exactly one canonical event per utterance. When refinement is on, you get two transcription events per utterance (first emit + canonical). When refinement is off, you get one (speech_final: true). Never zero, never three.
  • Same sentence_id across both phases. Reconcile on that key.
  • The canonical always arrives. Consumers waiting on speech_final: true never hang.
  • sentence_id ordering is preserved per session, but canonicals are NOT guaranteed to arrive in sentence_id order when LLM is on — two utterances finalizing close in time may complete refinement out of order. Key on sentence_id, not arrival order.
  • words[] corresponds to the original ASR output on both phases — the LLM does not realign tokens. Use words[] for word-level timing, text for display.
Recommended client patterns: Simplest — don’t care about the first-emit optimization:
With fast first-emit (UX-aware):
For voicebot NLU routing: feed the first-emit text (speech_final: false) to NLU immediately for fast intent dispatch — don’t wait for canonical. If your NLU benefits from proper-noun accuracy (name-spelling slots, drug-name lookup), run a second-pass call on the canonical (speech_final: true) text and reconcile on sentence_id.
Word-preservation guardrail (60db proxy). Refinement is allowed to polish, not to delete. If the canonical text keeps less than 40% of the first emit’s words, the proxy restores the first-emit text and re-flags the event as llm_applied: false with llm_reason: "dropped_too_many_words" (llm_latency_ms is kept). The same rule is applied to legacy refined events, which are dropped outright when they fail it, so the transcription you already rendered stands. You never receive a canonical that silently lost most of what was said.
Legacy refined event. Earlier builds emitted a separate refined event ~400 ms after the final instead of a second transcription. The 60db /ws/stt proxy transparently handles both shapes — if you’re still seeing refined events in the wire trace, upstream workers haven’t been restarted onto the two-phase build yet. New client code should target the two-phase flow only; refined is accepted but deprecated.

language_changed — After config message changes language

mode_changed — After config message changes continuous_mode

session_stopped — After stop is processed

billing_summary fields: What gets billed: only canonical finals (is_final: true and speech_final: true) that carry a duration. First emits (speech_final: false) are previews and are never charged, so two-phase refinement costs the same as a single-phase session. Utterances marked speech_end_no_result, speech_end_too_short, hallucination_rejected or low_snr_dropped are dropped upstream and never charged either — silence, noise and rejected audio cost nothing.

error — Processing error

Concurrency-limit error frame

When a user has reached their per-user STT session cap (counted across REST + WS combined), the server sends an error frame and closes with code 1008:
Existing sessions are unaffected; the cap releases when an in-flight session ends. Do not auto-reconnect on 1008 — the limit only frees when an in-flight session completes. The frontend should distinguish concurrency-limit closes from auth/network failures so the UI message is correct.

connection_closed — Upstream dropped the session

Sent when the upstream STT service closes its side. Billing is finalised at this point and the client socket is closed straight after, so treat it as terminal and reconnect if you still have audio to send.

test_response — Reply to test ping

Complete Example

    Audio Requirements

    Supported Languages

    39 transcription languages total (25 European, 13 Indic with Hinglish code-switching, and Arabic MSA). Fetch the full catalog from GET /stt/languages. Code-switching (Indic + English): hi+en, bn+en, mr+en, pa+en, gu+en, or+en, as+en, ne+en, te+en, kn+en, ta+en, ml+en — collapses to the fast path when both languages share the same pipeline. Not supported (explicit rejection): ur, ja, ko, zh, th, vi, id, tl, sw, tr, fa, he. These return an unsupported_language error — there is no silent aliasing. Arabic dialect tags (ar-eg, ar-lv, ar-gu, ar-ma) return dialect_not_supported — pass ar for best-effort MSA transcription of dialectal audio.

    Limitations and Best Practices

    Handshake ordering (most common mistake) Do not send start in ws.onopen. Wait for the proxy’s connection_established message first — it marks the point at which the proxy has attached its client-message listener. Likewise, wait for session_started before sending audio frames, otherwise the upstream server returns unknown message type: audio. utterance_end_ms floor The minimum is 1000 ms, and it is enforced twice: the upstream server rejects a start below it, and the 60db proxy clamps sub-1000 values up to 1000 ms before forwarding so an older client is silently corrected instead of disconnected. Shorter silences fragment utterances and make the non-hallucinating backends drop short segments. If you need faster turn-taking, drive barge-in from speech_started and interim results — not from this value. Language count Max 5 languages per session. Cross-backend multi-language (e.g. ["en","ar"]) runs per-utterance LID which adds ~20–50 ms per utterance. Same-backend multi-language (e.g. ["en","hi"]) collapses to a single backend’s fast path with zero LID overhead — use this whenever possible. Telephony confidence 8 kHz μ-law → 16 kHz resampling reduces backend confidence by ~0.10–0.15 compared to native wideband input. Use a client-side threshold of 0.35 for telephony vs 0.55 for browser. Buffer limits
    • Pre-speech ring buffer: 1.0 s (captures first word before VAD fires)
    • Minimum coalesce before VAD: 160 ms (μ-law 1280 bytes, linear sample_rate × 2 × 160 / 1000 bytes)
    • Maximum utterance duration: 30 s (anything longer force-finalizes)
    VAD
    • Speech start: Silero probability > STT_VAD_THRESHOLD (default 0.5, server-configurable)
    • Silence → utterance end: utterance_end_ms of consecutive sub-threshold audio
    • No separate continuation threshold
    Non-hallucinating architecture The 60db STT backend uses non-hallucinating architectures that do not output no_speech_prob — the CTC/RNN-T topology emits blank tokens on non-speech. Hallucination guard is a word-rate sanity check (> 5 words/second → rejected). Diarization diarize=true requires HF_TOKEN on the server plus gated model approval for pyannote/speaker-diarization-3.1. Without both, the request silently falls back to a deterministic mock. Check session_started.diarize to confirm the request was accepted.

    Pricing

    • Rate: $0.00000833 per second
    • Minimum: $0.01 per session
    • Billing: Per second of audio processed

    Error Codes

    error_code sits alongside error on the frame — for example {"type":"error","error":"Insufficient credits to start STT session","error_code":"INSUFFICIENT_CREDITS","details":{"required":0.01,"available":0}}. One exception: when the wallet runs out mid-session, the deduction failure is reported as {"type":"error","error":{"code":"INSUFFICIENT_CREDITS","message":"…"}} — a nested object — before the socket closes with 1008. Handle both shapes if you parse credit errors.

    Testing

    Then send: