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.

1

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

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'
3

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>
4

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 reserved

Recognized 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

Download

Streams 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

Download

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