Live dictation runs speech to text and chat corrections over one WebSocket per session, without a job per request.
The stream is experimental and off unless the owner switches it on (503 dictation_off). Only keys of the
albastov.com owner account carry the dictation scope. The API stores no audio, no transcript and no chat content.
Flow
POST /v1/dictation/sessionswith an API key. The response holdssession_id,ticketandendpoints.- Open the
urlof therelayendpoint as a WebSocket. Send{"op":"auth","ticket":"<ticket>"}as the first frame within 5 seconds. - Send requests and audio. Replies arrive in any order; each reply carries the request's
id. - After a disconnect, call
POST /v1/dictation/sessions/{id}/ticketand open the socket again with the new ticket. Resend the requests that had no final reply.
Session
A session belongs to the account of the key that opened the session. client names the app and its version, at most
64 printable characters.
curl
curl https://api.albastov.com/v1/dictation/sessions \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"client": "iridium-live 2.4"}'{
"session_id": "ds_k3m2c7x4q5n6p2r7s3t4u5v6",
"ticket": "eyJhbGciOiJFZERTQSIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoiLi4uIn0.c2lnbmF0dXJl",
"expires_at": 1793567400000,
"endpoints": [
{"kind": "relay", "url": "wss://api.albastov.com/v1/dictation/stream?session=ds_k3m2c7x4q5n6p2r7s3t4u5v6"}
]
}expires_at is the ticket's expiry in Unix milliseconds. Open each endpoint's url exactly as returned, in list
order; a later version adds endpoints of other kinds to the same list.
Ticket
The ticket is a compact JWS signed with Ed25519: base64url(header).base64url(claims).base64url(signature).
GET /v1/dictation/keys returns the public keys that verify tickets.
| Part | Content |
|---|---|
| Header | {"alg":"EdDSA","kid":"<key id>","typ":"JWT"} |
session_id | The session the ticket opens |
key_id | The id of the API key that opened the session |
exp | Expiry in Unix seconds, 5 minutes after issue |
iat, account, device, jti | Issue time, account id, stream target, unique ticket id |
- A ticket opens one socket at a time, and only before
exp. An open socket outlives its ticket. - A second socket with a ticket that holds an open socket fails with
ticket_in_use. - A socket opened with a newer ticket of the same session closes the older socket with
4409 replaced. - A ticket older than the ticket of the open socket fails with
ticket_stale. - The API checks the key that opened the session when a socket opens and every 30 seconds after. A revoked key, or a
key without the
dictationscope, closes the socket with4401 invalid_keyor4403 scope_missing.
Stream
GET /v1/dictation/stream?session=<session_id> upgrades to a WebSocket. Never put the ticket in the URL.
| Authentication | Refusal |
|---|---|
First frame {"op":"auth","ticket":"..."} within 5 seconds | Close frame with code 4000 + status and the error code as reason |
Header Authorization: Bearer <ticket> or X-Dictation-Ticket: <ticket> on the upgrade | HTTP status and JSON error body; no WebSocket |
After authentication, every frame passes unchanged in both directions, in order per direction. The API adds no frame of its own.
import json
import os
import urllib.request
from websockets.sync.client import connect # pip install websockets
req = urllib.request.Request(
"https://api.albastov.com/v1/dictation/sessions",
data=json.dumps({"client": "example"}).encode(),
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as res:
session = json.load(res)
with connect(session["endpoints"][0]["url"]) as ws:
ws.send(json.dumps({"op": "auth", "ticket": session["ticket"]}))
ws.send(json.dumps({"op": "transcribe", "id": 1, "final": True}))
ws.send(open("phrase.pcm", "rb").read()) # 16 kHz mono PCM16 little-endian, 1 MiB at most
print(json.loads(ws.recv()))Messages
A client text frame is one JSON object; audio is a binary frame. id is a whole number or a string of 1 to 64
characters, unique among the requests in flight. Every request receives exactly one final reply: text, done or
error.
| Client sends | Reply |
|---|---|
{"op":"transcribe","id":n,"final":bool}, then one binary frame of 16 kHz mono PCM16 little-endian audio | {"op":"text","id":n,"final":bool,"text":"...","model":"parakeet","model_state":"loaded","ms":{...}} |
{"op":"chat","id":n,"body":{...}} with an OpenAI chat body, model and messages included | {"op":"delta","id":n,"content":"..."} per token chunk, then {"op":"done","id":n,"text","finish_reason","model","usage","ms":{...}} |
{"op":"cancel","id":n} | {"op":"error","id":n,"code":"cancelled"} for the cancelled request |
{"op":"ping","ts"?:n} | {"op":"pong","ts"?:n} |
ms holds received (Unix milliseconds when the request was read) and total (milliseconds from received to the
reply), plus queued and inference for transcribe, and queued and ttft for chat.
Final phrases run before captions. A new caption request ("final": false) replaces the session's pending caption,
and the replaced request receives error with code replaced. Send a final at each pause in speech.
Error replies
{"op":"error","id":n,"code":"...","message":"...","ms":{...}} ends one request; the socket stays open.
code | Meaning |
|---|---|
bad_request | The frame is not JSON, or id, final or body is missing or invalid |
unknown_op | op is not transcribe, chat, cancel or ping |
duplicate_id | A request with the same id is in flight |
unexpected_audio | A binary frame without a transcribe before it |
bad_audio | Empty audio, or an odd number of bytes |
too_large | Audio longer than 120 seconds |
replaced | A newer caption request took the caption's place |
cancelled | The client cancelled the request |
asr_unavailable, asr_failed | Speech to text is offline, or failed on the request |
model_not_loaded | The chat model in body.model is not loaded |
chat_failed | The chat request was refused, or its stream broke |
Limits
| Limit | Value |
|---|---|
| Frame size | 1 MiB (32 seconds of audio); a larger frame closes the socket with 1009 |
| Frames queued for speech to text | 1,024 frames or 4 MiB per socket, 8 MiB across all sockets; past a limit the socket closes with 1008 flood |
| Frames waiting for the client | 1,024 frames or 4 MiB, and 10 seconds per write; past a limit the socket closes with 1008 slow_reader |
| Open sockets | 4 per account; a fifth closes with 4429 too_many_streams |
| Socket lifetime | 12 hours; then the socket closes with 1001 max_lifetime |
| Upgrades | 60 a minute per client address; past the limit 429 rate_limited |
| Keepalive | A ping every 20 seconds; no pong within 20 seconds closes the socket with 1001 keepalive_timeout |
An idle WebSocket closes after 100 seconds without traffic: send a frame at least every 90 seconds, or answer pings.
{"op":"ping"} is an end-to-end check and receives {"op":"pong"}.
Close codes
| Code | Reason | Cause |
|---|---|---|
1000 | Normal close by the client or the server | |
1001 | dictation turned off | Dictation was switched off; open sockets close |
1001 | keepalive_timeout | The client missed a ping |
1001 | max_lifetime | The socket was open for 12 hours; reconnect with a new ticket |
1008 | flood, slow_reader | A frame limit above |
1009 | A frame over 1 MiB | |
1011 | edge_error, stream_error | The stream broke on the server; reconnect with a new ticket |
1012 | device disconnected | Speech to text disconnected; reconnect with a new ticket |
1013 | device_busy | Speech to text did not take a frame within 5 seconds; reconnect |
4000 + status | The error code | A refusal with the HTTP status of the same refusal on the upgrade, such as 4401 ticket_expired, 4403 scope_missing, 4408 auth_timeout, 4409 ticket_in_use, 4429 too_many_streams or 4504 device_timeout |
4409 | replaced | A newer socket of the same session opened |
Every refusal code: Dictation reference.
Records
Sessions, sockets (frame and byte counts) and one row per request (operation, outcome, model, audio length,
character and token counts, and the ms timings) are kept for 90 days. Audio, transcripts and chat content are never
stored.
Reference
- Dictation reference: every route, parameter and error.