API Reference
Stream audio to Dictate over a WebSocket and get recognized text back as it is spoken. No polling, no SDK required — plain WebSocket and JSON.
New here? The free plan includes 30 minutes of transcription to try it with.
Audio never leaves your infrastructure
Dictate relays your audio for the duration of a live session and does not persist it. See the privacy overview for exactly what is stored and for how long.
1. Quick start
Four calls. Authenticate, mint a short-lived WebSocket token, open the socket, stream audio.
Authenticate
Exchange your credentials for an access token. Send it as a bearer token on every call below.
curl -s -X POST https://dictate.top/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"YOUR_PASSWORD"}'
Request a WebSocket token
WebSocket tokens are short-lived and bound to your remaining quota. Mint one per session.
curl -s -X POST https://dictate.top/api/v1/ws-token \
-H 'Authorization: Bearer $ACCESS_TOKEN'
Open the socket
Build the URL from the port and path the server returned, then wait for the ready message.
wss://<host>:<port>/ws?token=<ws_token>
Stream audio and read text
Send 16 kHz mono s16le PCM in 100-200 ms frames. Each binary frame back carries the transcript so far — replace your display, do not append.
# send: socket.send(pcm_frame)
# receive: parse_frame(data).text
2. Authentication
Authenticate with the access token returned by login or registration and send it as a bearer token on every request:
Authorization: Bearer <access_token>
Get an access token
POST /api/v1/auth/login
Exchange credentials for a token.
{
"email": "you@example.com",
"password": "YOUR_PASSWORD"
}
POST /api/v1/auth/register
Create an account and receive a token in one call.
{
"email": "you@example.com",
"username": "your name",
"password": "YOUR_PASSWORD",
"confirm_password": "YOUR_PASSWORD",
"agree_terms": true
}
POST /api/v1/auth/refresh
Exchange the refresh token when the access token expires.
API keys are issued but not yet accepted
Keys created through POST /api/v1/api-keys/
currently return 401 when used as a bearer
token — the gateway accepts session tokens only. Use your access token until
key authentication ships.
This page documents the behaviour you can rely on today. We will note the change here when key authentication is live.
3. Request a WebSocket token
WebSocket connections do not carry headers in every client, so the socket is authenticated with a dedicated short-lived token. Request one per session.
POST /api/v1/ws-token
curl -s -X POST https://dictate.top/api/v1/ws-token \
-H 'Authorization: Bearer $ACCESS_TOKEN'
Response
{
"token": "<ws_token>",
"port": 7932,
"path": "/ws",
"expires_in": 300,
"sample_rate": 16000,
"remaining_ms": 1800000
}
port and path
are returned by the server rather than hardcoded, so build the URL from them:
ws://<host>:<port><path>?token=<token>.
The token is valid for 300 seconds and is
tied to your user and remaining quota — mint a fresh one per session.
4. Stream audio
Audio format
Send 16 kHz, mono, signed 16-bit little-endian PCM — 32,000 bytes per second. Send frames of roughly 100–200 ms (3,200–6,400 bytes) as they become available rather than waiting for a complete recording.
| Sample rate | 16000 Hz (required) |
| Channels | 1 (mono) |
| Sample format | signed 16-bit little-endian (s16le) |
| Byte rate | 32,000 bytes per second |
| Recommended frame | 100-200 ms (3,200-6,400 bytes) |
| Container | none — send raw PCM bytes |
What you send
Raw PCM bytes — no header, no wrapper. The server meters billing from the byte count, so frames must be exactly the audio and nothing else.
What you receive
The server opens with a JSON control message, then streams binary frames
containing the recognized text. Each frame is
header (4 B) + sequence (4 B) + size (4 B) + gzip JSON,
big-endian integers.
Frame header layout
byte 0 version (high nibble) | header size in words (low nibble)
byte 1 message type (high nibble) | flags (low nibble)
byte 2 serialization (high nibble) | compression (low nibble)
byte 3 reservedRecognized text arrives as an accumulated string: each frame repeats the full transcript so far rather than only the new words. Replace your displayed text with each update; do not concatenate.
5. Complete examples
Each example streams speech.raw (16 kHz mono
s16le) and prints each update.
Node.js
DownloadStreams a raw PCM file and prints every update. Requires the ws package.
#!/usr/bin/env node
/**
* Dictate streaming speech-to-text — Node.js example.
*
* Authenticates, requests a WebSocket token, streams 16 kHz mono s16le PCM and
* prints every text update.
*
* Usage:
* npm install ws
* DICTATE_EMAIL=you@example.com DICTATE_PASSWORD=... \
* node transcribe.js speech.raw
*
* `speech.raw` is 16 kHz, mono, signed 16-bit little-endian PCM. Convert with:
* ffmpeg -i input.wav -ar 16000 -ac 1 -f s16le -acodec pcm_s16le speech.raw
*/
'use strict';
const fs = require('fs');
const { parseFrame, chunkPcm } = require('./protocol');
const WebSocket = require('ws');
const BASE_URL = process.env.DICTATE_BASE_URL || 'http://localhost:7932';
const FRAME_MS = 200;
async function api(path, { method = 'GET', body, token } = {}) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
throw new Error(`${method} ${path} -> HTTP ${res.status}: ${await res.text()}`);
}
return res.json();
}
async function main() {
const file = process.argv[2];
if (!file) {
console.error('usage: node transcribe.js <speech.raw>');
process.exit(1);
}
// ---- 1. authenticate -------------------------------------------------
const session = await api('/api/v1/auth/login', {
method: 'POST',
body: { email: process.env.DICTATE_EMAIL, password: process.env.DICTATE_PASSWORD },
});
const token = session.access_token;
// ---- 2. mint a WebSocket token --------------------------------------
const grant = await api('/api/v1/ws-token', { method: 'POST', token });
const url = `ws://${hostOf(grant)}${grant.path}?token=${grant.token}`;
console.log(`remaining quota: ${grant.remaining_ms} ms`);
// ---- 3. stream audio -------------------------------------------------
const pcm = fs.readFileSync(file);
const frames = chunkPcm(pcm, FRAME_MS);
const socket = new WebSocket(url);
socket.on('open', () => {
for (const frame of frames) socket.send(frame);
console.log(`sent ${frames.length} frames`);
// Closing is how the server learns the session ended; it then flushes the
// final utterance. Do not keep the socket open waiting for one.
setTimeout(() => socket.close(), 2000);
});
socket.on('message', (data) => {
const frame = parseFrame(data);
if (frame.isError) {
console.error('error frame:', frame.errorCode, frame.errorMessage);
return;
}
// Each frame carries the whole transcript so far — replace, never append.
if (frame.text) console.log(frame.text);
if (frame.isFinal) {
console.log('\n--- final ---');
socket.close();
}
});
socket.on('close', (code) => {
console.log(`closed (${code})`);
process.exit(0);
});
socket.on('error', (err) => {
console.error('socket error:', err.message);
process.exit(1);
});
}
/** The token response tells us the port to dial; fall back to the API host. */
function hostOf(grant) {
const url = new URL(BASE_URL);
return `${url.hostname}:${grant.port || url.port || 7932}`;
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
Python
DownloadSame flow with httpx and websockets, including a self-contained frame parser.
#!/usr/bin/env python3
"""
Dictate streaming speech-to-text - Python example.
Authenticates, requests a WebSocket token, streams 16 kHz mono s16le PCM and
prints every text update.
Usage:
pip install httpx websockets
DICTATE_EMAIL=you@example.com DICTATE_PASSWORD=... \\
python transcribe.py speech.raw
``speech.raw`` is 16 kHz, mono, signed 16-bit little-endian PCM. Convert with:
ffmpeg -i input.wav -ar 16000 -ac 1 -f s16le -acodec pcm_s16le speech.raw
"""
import asyncio
import gzip
import json
import os
import struct
import sys
import httpx
import websockets
BASE_URL = os.environ.get("DICTATE_BASE_URL", "http://localhost:7932")
FRAME_MS = 200
BYTES_PER_MS = 16000 * 2 // 1000 # 16 kHz * 16 bit * mono = 32 bytes/ms
def parse_frame(data: bytes) -> dict:
"""Decode one binary frame: header(4) + sequence(4) + size(4) + gzip JSON."""
if len(data) < 8:
return {}
header_size = (data[0] & 0x0F) * 4 or 4
message_type = (data[1] >> 4) & 0x0F
flags = data[1] & 0x0F
compression = data[2] & 0x0F
offset = header_size
if flags in (0b0001, 0b0011): # a sequence number is present
offset += 4
size = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
payload = data[offset : offset + size]
if compression == 0b0001:
try:
payload = gzip.decompress(payload)
except OSError:
pass
try:
body = json.loads(payload)
except (ValueError, TypeError):
body = {}
text = ""
result = body.get("result")
if isinstance(result, list):
text = "".join(
(item.get("payload_msg") or {}).get("text", "") for item in result
)
elif isinstance(result, dict):
text = result.get("text", "") or body.get("text", "")
else:
text = body.get("text", "")
return {
"message_type": message_type,
"is_final": flags in (0b0010, 0b0011),
"text": text,
}
async def main() -> None:
if len(sys.argv) < 2:
print("usage: python transcribe.py <speech.raw>", file=sys.stderr)
raise SystemExit(1)
async with httpx.AsyncClient(base_url=BASE_URL, timeout=30) as http:
login = await http.post(
"/api/v1/auth/login",
json={
"email": os.environ.get("DICTATE_EMAIL"),
"password": os.environ.get("DICTATE_PASSWORD"),
},
)
login.raise_for_status()
token = login.json()["access_token"]
auth = {"Authorization": f"Bearer {token}"}
grant_resp = await http.post("/api/v1/ws-token", headers=auth)
grant_resp.raise_for_status()
grant = grant_resp.json()
print(f"remaining quota: {grant['remaining_ms']} ms")
host = BASE_URL.split("://", 1)[1].split(":")[0]
port = grant.get("port") or 7932
url = f"ws://{host}:{port}{grant['path']}?token={grant['token']}"
with open(sys.argv[1], "rb") as handle:
pcm = handle.read()
step = BYTES_PER_MS * FRAME_MS
frames = [pcm[i : i + step] for i in range(0, len(pcm), step)]
async with websockets.connect(url, max_size=16 * 1024 * 1024) as socket:
for frame in frames:
await socket.send(frame)
print(f"sent {len(frames)} frames")
# Closing is how the server learns the session ended; it then flushes the
# final utterance. Do not keep the socket open waiting for one.
async def closer():
await asyncio.sleep(2)
await socket.close()
asyncio.create_task(closer())
try:
async for message in socket:
if isinstance(message, str):
continue # JSON control message
frame = parse_frame(message)
if frame.get("text"):
# Each frame carries the whole transcript so far.
print(frame["text"])
if frame.get("is_final"):
print("\n--- final ---")
break
except websockets.ConnectionClosed:
pass
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nstopped")
6. Errors
The server reports failures two ways: a JSON control message while the socket is still open, and a WebSocket close code when it is not.
| Code | Meaning | Recoverable |
|---|---|---|
| 4001 | Missing token in the query string | Mint a new token and reconnect |
| 4002 | Token invalid, wrong type, or expired | Mint a new token and reconnect |
| 4003 | Account disabled or not permitted | Terminal — contact support |
| 4004 | No upstream capacity available | Retry after a short backoff |
| 4005 | Quota exhausted | Upgrade or wait for the reset |
| 4006 | Recognition service error | Retry the session |
| 4007 | Subscription not active | Terminal — reactivate the subscription |
| 4008 | Internal server error | Retry with backoff |
HTTP 401 — missing or invalid access token
HTTP 403 — subscription inactive or quota exhausted
HTTP 429 — rate limited; back off before retrying
7. Quota and usage
Quota is measured in milliseconds of audio transcribed. Check what remains before a long session:
GET /api/v1/quota
{
"remaining_ms": 1740000,
"total_ms": 1800000,
"used_ms": 60000,
"bonus_ms": 0,
"reset_date": "2026-11-01"
}
Everything at once
GET /api/v1/usage/stats returns quota, characters
transcribed and estimated typing time saved in a single response, so a dashboard never
shows a quota figure and a usage figure sampled seconds apart.
{
"quota": {
"total_ms": 36000000,
"used_ms": 29400,
"remaining_ms": 35970600,
"used_percent": 0.1,
"total_hours": 10.0,
"used_hours": 0.008,
"remaining_hours": 9.99
},
"transcription": {
"session_count": 1,
"completed_count": 1,
"total_audio_ms": 29400,
"transcribed_chars": 373,
"transcribed_words": 62,
"chars_per_minute_of_speech": 761.2
},
"time_saved": {
"saved_minutes": 1.5,
"saved_hours": 0.02,
"typing_chars_per_minute": 250,
"basis": "Characters transcribed divided by an assumed sustained typing speed of 250 characters per minute. An estimate, not a measurement."
}
}
time_saved is an estimate, not a measurement:
characters transcribed divided by an assumed sustained typing speed of
typing_chars_per_minute. Dictate never times your
typing, so the assumption is echoed back in the response rather than buried here.
The WebSocket token response already carries remaining_ms,
and the server closes the connection the moment the allowance runs out
(4005). Draining stops at the exact frame that
exhausted the balance, so partial audio is still transcribed.
Usage history
| GET /api/v1/usage/ | Usage records |
| GET /api/v1/usage/sessions | Transcription sessions |
| GET /api/v1/subscriptions/current | Active plan |
8. Practical notes
Close the socket when recording stops
There is no explicit end-of-stream marker. The server treats the socket closing as the end of the session and flushes the last utterance. Keep the socket open and the final words never arrive.
The final frame is what completes the transcript
Because of the above, the last utterance is emitted only after the close. Give the server a moment to finish rather than tearing the socket down abruptly.
Replace, never concatenate
Every result frame contains the full transcript so far. Appending frames together duplicates the text many times over.
Billing is metered from your bytes
Quota is deducted from the length of each frame you send. Send only PCM audio — a WAV header or any wrapper is billed as though it were audio.
Ready to try it?
The free plan includes 30 minutes of transcription — enough to run the examples above end to end.