STT for Humans
Real-time speech-to-text for human conversations with krisp-realtime-stt-humans-v1: diarization, PII redaction and custom vocabulary.
Beta: Speech-to-Text is in beta. Models, messages and limits may change before general availability.
This page describes the WebSocket protocol for Krisp streaming speech-to-text (STT) with the krisp-realtime-stt-humans-v1 model, built for transcribing human conversations. It supports speaker diarization and PII redaction.
Endpoint
- WebSocket URL:
wss://streaming.krisp.ai/stt - Auth: temporary session token. See Authentication.
Use the session token as the API key when opening the WebSocket. Do not send the master token on the WebSocket.
Authorization: api-key <session-token>If your client cannot set headers, pass the same value as the authorization query parameter. See the API overview.
Connection and handshake flow
1) Client initiates connection + sends handshake message
After opening the WebSocket connection with the session token, the client must send an initial JSON handshake that defines the session.
Handshake message (client → server, JSON):
{
"model": "krisp-realtime-stt-humans-v1",
"vocabulary": ["word1", "word2"],
"sample_rate": 16000,
"speaker_diarization": false,
"pii_redaction": false,
"channels": 1,
"audio_format": "pcm_s16le",
"metadata": {}
}| Field | Type | Description |
|---|---|---|
model | string | krisp-realtime-stt-humans-v1. |
vocabulary | string[] | Optional list of custom vocabulary terms. |
sample_rate | number | Audio sample rate in Hz. Must be 16000. |
speaker_diarization | boolean | Enable speaker diarization. Default: false. See Diarization. |
pii_redaction | boolean | Enable PII redaction. Default: false. |
channels | number | Number of audio channels. Use 1. |
audio_format | string | pcm_s16le (PCM signed 16-bit little-endian, default) or opus. |
metadata | object | Optional object echoed in the ready response. Maximum 4096 bytes when JSON-encoded. |
2) Server acknowledges readiness (or returns an error)
Once the server validates the handshake and is ready to receive audio, it responds with a ready message.
Ready response (server → client, JSON):
{
"type": "ready",
"request_id": "9f2c...",
"metadata": {}
}request_id: unique identifier for the streaming session. Include it if you contact Krisp support.metadata: echo of themetadataobject from the handshake.
Do not send audio before this message. If no handshake is received within 15 seconds, the server closes the session.
Error response
If the server cannot accept the session, it responds with an error message and closes the connection. Besides the errors below, the common errors apply.
Error response (server → client, JSON):
{
"type": "error",
"request_id": "9f2c...",
"error": {
"code": "invalid_api_key",
"status_code": 401,
"message": "Invalid Authorization header.",
"details": {
"hint": "Check authorization header."
}
}
}error.code | status_code | Description |
|---|---|---|
missing_authorization_header | 401 | Authorization header is missing |
invalid_api_key | 401 | Authorization header is malformed |
unauthorized | 403 | Session token is not valid or has expired |
invalid_client_hello | 400 | Handshake is missing or invalid |
client_hello_timeout | 408 | Handshake was not received in time |
chunk_too_large | 413 | A message exceeded 65,536 bytes |
internal_server_error | 500 | Unexpected server error |
Audio streaming
3) Client streams audio frames (raw bytes)
After receiving type: "ready", the client sends raw audio bytes as binary WebSocket messages.
- Message type: binary (raw bytes)
- Encoding: according to
audio_formatin the handshake:pcm_s16le(PCM signed 16-bit little-endian) oropus - Sample rate: 16,000 Hz
- Channels: 1 (mono)
- Recommended chunk duration: 80 ms, or multiples of 80 ms
- Maximum message size: 65,536 bytes
Stream audio in realtime. Do not wrap audio in JSON.
Transcription messages
4) Server sends transcription results (JSON)
As the server processes audio, it returns transcription messages as JSON. Messages may be partial or final.
Example transcript response (server → client, JSON):
{
"type": "transcript",
"metadata": {},
"transcript": {
"words": [
{
"text": " weather",
"start": 12480,
"end": 12880,
"language": "en",
"language_confidence": 0.9921
}
],
"isFinal": true
}
}textkeeps its leading space, so joiningtextin arrival order gives the transcript.startandendbound the word, in milliseconds from the start of the stream.languageandlanguage_confidenceare present when the server runs language ID.metadatais always empty.- A final never changes and is never repeated.
5) Diarization
Add "speaker_diarization": true to the handshake:
{
"model": "krisp-realtime-stt-humans-v1",
"speaker_diarization": true
}Diarization frames arrive between transcript frames:
{
"type": "diarization",
"committed": [{ "start": 463400, "end": 469400, "speaker": 5 }],
"provisional": [
{ "start": 471500, "end": 498500, "speaker": 5 },
{ "start": 499100, "end": 502300, "speaker": 2 }
],
"n_speakers": 6
}| Field | Meaning |
|---|---|
committed | Segments that became final since the previous frame. Append them; they never change and are never sent again. |
provisional | The current guess for everything after the last committed segment. Each frame's array replaces the previous one. |
start, end | Integer ms of stream time, on the same timeline as a word's start / end. |
speaker | Integer ID, stable for the whole session; not comparable across sessions. |
n_speakers | Speakers in committed plus current provisional segments; can go down if a provisional speaker disappears. |
last | true on the session's final diarization frame only. |
A frame is sent only when something changed.
The current speaker timeline is committed followed by the latest provisional. The two never overlap.
A word belongs to the segment where start <= word.start < end:
- Segment edges are snapped to word starts, so every word falls in exactly one segment.
- Punctuation and word pieces stay with their word.
- A word under a committed segment keeps its speaker for good. A word under a provisional segment can still change speaker.
Timing
| What | Typical |
|---|---|
| First (provisional) speaker label for a word | 2–4 s after speech |
| Committed label | Segment end + 26–33 s |
Final frame after end_of_audio | 0.3–0.5 s |
Show provisional labels straight away and repaint them as frames arrive. Don't wait for committed labels in a live UI: commits exist to give storage and analytics a guarantee that labels won't change.
Session end
After end_of_audio, frames arrive in this order:
- the remaining transcript frames;
- the final diarization frame with
"last": true, after which every segment is committed; end.
The session is over at end, not at last. Closing the socket without end_of_audio loses the final diarization state.
6) Stream end
To end the stream, the client sends one text frame after its last audio message:
{"type": "end_of_audio"}The server then finishes the session and closes the socket itself. The client shouldn't close it; it should read frames until end arrives.
After end_of_audio, the last frame the server sends is end, and it closes the socket right after:
{
"type": "end",
"language": {
"overall_language": "en",
"overall_confidence": 0.982,
"current_language": "en",
"current_confidence": 0.991,
"per_language": {
"de": 0.003,
"en": 0.982,
"es": 0.004,
"fr": 0.005,
"ru": 0.003,
"hy-AM": 0.003
}
}
}The last frame of a session. language is present when the server runs language ID:
overall_*averages the whole session.current_*covers the last 0.5 s.per_languageholds each language's mean probability.
Example (Python)
Streams a 16 kHz, mono, 16-bit PCM WAV file at realtime pace with diarization on, and prints every message from the server. Requires pip install "websockets>=14".
import asyncio
import json
import sys
import wave
import websockets
SESSION_TOKEN = "YOUR_SESSION_TOKEN" # see the Authentication page
URL = "wss://streaming.krisp.ai/stt"
CHUNK_MS = 480 # a multiple of 80 ms
async def main(path: str) -> None:
headers = {"Authorization": f"api-key {SESSION_TOKEN}"}
async with websockets.connect(URL, additional_headers=headers) as ws:
await ws.send(json.dumps({
"model": "krisp-realtime-stt-humans-v1",
"sample_rate": 16000,
"channels": 1,
"audio_format": "pcm_s16le",
"speaker_diarization": True,
}))
hello = json.loads(await ws.recv())
if hello["type"] != "ready":
raise RuntimeError(hello)
async def send_audio() -> None:
with wave.open(path, "rb") as wav:
frames = 16000 * CHUNK_MS // 1000
while chunk := wav.readframes(frames):
await ws.send(chunk)
await asyncio.sleep(CHUNK_MS / 1000)
await ws.send(json.dumps({"type": "end_of_audio"}))
sender = asyncio.create_task(send_audio())
async for message in ws:
msg = json.loads(message)
print(msg)
if msg.get("type") == "end":
break
await sender
asyncio.run(main(sys.argv[1]))Changelog
- 2026-10-01: Page created.
Updated 3 days ago

