Skip to content

Dictation

Open a live dictation session, renew its ticket, open its WebSocket and read the ticket signing keys.

/v1/dictation opens live dictation sessions: one WebSocket per session for speech to text and chat corrections. The session routes need a key with the dictation scope, which only keys of the albastov.com owner account carry. The flow, the messages and the close codes: Live dictation.

Errors have the shape {"ok": false, "error": "dictation-off", "code": "dictation_off", "message": "..."}. Branch on code.

Open a session

POST /v1/dictation/sessions opens a session and returns its first ticket.

Parameters

A JSON body, or no body.

NameTypeDescription
clientstringThe app's name and version, at most 64 printable characters; default ""

Request

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

Python

import json
import os
import urllib.request

req = urllib.request.Request(
    "https://api.albastov.com/v1/dictation/sessions",
    data=json.dumps({"client": "iridium-live 2.4"}).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)
print(session["session_id"], session["endpoints"][0]["url"])

JavaScript

const res = await fetch("https://api.albastov.com/v1/dictation/sessions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ client: "iridium-live 2.4" }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const session = await res.json();
console.log(session.session_id, session.endpoints[0].url);

Response

201 with the session.

Response
{
  "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"}
  ]
}
FieldValue
session_idds_ and 24 characters
ticketA signed ticket that opens the stream, valid 5 minutes
expires_atThe ticket's expiry in Unix milliseconds
endpointsThe ways to reach the stream, in order: kind and url; relay is the only kind today

Errors

StatusCodeMeaning
400bad_requestThe body is not a JSON object, or client is over 64 characters or not printable
401api_key_requiredNo API key
401invalid_keyAn unknown or revoked key
403scope_missingThe key lacks the dictation scope
403account_cannotA key of an account other than the owner account
503dictation_offDictation is switched off
503device_offline, device_unsupportedSpeech to text is offline
503ticket_key_unavailableTickets cannot be signed now

Renew a ticket

POST /v1/dictation/sessions/{id}/ticket returns a new ticket for a session of the key's account, for a reconnect.

Parameters

NameTypeDescription
id requiredstringThe session id ds_…, in the path

Request

curl

curl -X POST https://api.albastov.com/v1/dictation/sessions/ds_k3m2c7x4q5n6p2r7s3t4u5v6/ticket \
  -H "Authorization: Bearer $ALBASTOV_API_KEY"

Python

import json
import os
import urllib.request

req = urllib.request.Request(
    "https://api.albastov.com/v1/dictation/sessions/ds_k3m2c7x4q5n6p2r7s3t4u5v6/ticket",
    method="POST",
    headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res:
    print(json.load(res)["ticket"])

JavaScript

const res = await fetch("https://api.albastov.com/v1/dictation/sessions/ds_k3m2c7x4q5n6p2r7s3t4u5v6/ticket", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).ticket);

Response

200 with the same body as Open a session and a new ticket for the same session.

Errors

StatusCodeMeaning
401api_key_requiredNo API key
401invalid_keyAn unknown or revoked key
403scope_missingThe key lacks the dictation scope
403account_cannotA key of an account other than the owner account
404session_not_foundNo session with the id, or a session of another account
503dictation_offDictation is switched off
503device_offline, device_unsupportedSpeech to text is offline
503ticket_key_unavailableTickets cannot be signed now

Open the stream

GET /v1/dictation/stream upgrades to the session's WebSocket. The ticket, not an API key, authenticates the socket: as the first frame {"op":"auth","ticket":"..."} within 5 seconds, or as a header on the upgrade.

Parameters

NameTypeDescription
session requiredstringQuery parameter: the session id ds_…

Headers

NameDescription
AuthorizationRequest. Bearer <ticket>: authenticates on the upgrade, so a refusal is an HTTP answer
X-Dictation-TicketRequest. <ticket>, the same as Authorization

Request

Python

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=b"{}",
    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": "ping", "ts": 1}))
    print(ws.recv())

JavaScript

const res = await fetch("https://api.albastov.com/v1/dictation/sessions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}`, "Content-Type": "application/json" },
  body: "{}",
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const session = await res.json();

const ws = new WebSocket(session.endpoints[0].url);
ws.onopen = () => {
  ws.send(JSON.stringify({ op: "auth", ticket: session.ticket }));
  ws.send(JSON.stringify({ op: "ping", ts: 1 }));
};
ws.onmessage = (e) => {
  console.log(e.data);
  ws.close();
};

Response

101 Switching Protocols, then frames in both directions: Messages.

Response
{"op": "pong", "ts": 1}

Errors

With the first-frame method, a refusal after the upgrade is a close frame with code 4000 + status and the code below as reason, such as 4401 ticket_expired. With the header method, a refusal is the HTTP status and the JSON error body.

StatusCodeMeaning
400session_requiredsession is missing or malformed
400auth_requiredThe first frame is not {"op":"auth"}
401ticket_requiredNo ticket
401ticket_invalidA malformed ticket, or a ticket with a wrong signature
401ticket_expiredThe ticket's exp has passed
401invalid_keyThe key that opened the session is revoked
403scope_missingThe key that opened the session lost the dictation scope
403ticket_wrong_sessionThe ticket belongs to another session
408auth_timeoutNo auth frame within 5 seconds; close code 4408 only
409ticket_in_useThe ticket holds an open socket
409ticket_staleA newer ticket of the session holds the open socket
426upgrade_requiredA plain GET without a WebSocket upgrade
429rate_limitedThe client address made over 60 upgrades in a minute
429too_many_streamsThe account holds 4 open sockets
503dictation_offDictation is switched off
503device_offline, device_unsupported, device_busySpeech to text is offline or holds its maximum of streams
503auth_backlogToo many sockets wait for their auth frame; try again
504device_timeoutSpeech to text did not answer the upgrade within 10 seconds

Get the ticket keys

GET /v1/dictation/keys returns the Ed25519 public keys that verify tickets. The route needs no key.

Headers

NameDescription
Cache-ControlResponse. public, max-age=60

Request

curl

curl https://api.albastov.com/v1/dictation/keys

Python

import json
import urllib.request

with urllib.request.urlopen(urllib.request.Request("https://api.albastov.com/v1/dictation/keys", headers={"User-Agent": "my-app/1.0"}), timeout=30) as res:
    keys = json.load(res)
print(keys["kid"], keys["ticket_key"])

JavaScript

const res = await fetch("https://api.albastov.com/v1/dictation/keys");
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const keys = await res.json();
console.log(keys.kid, keys.ticket_key);

Response

Response
{
  "alg": "EdDSA",
  "kid": "Xq2mVb7NcL0pRt4s",
  "ticket_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
  "keys": [
    {"kid": "Xq2mVb7NcL0pRt4s", "alg": "EdDSA", "public_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
     "active": true, "created_at": 1793567100000, "retired_at": null}
  ]
}
FieldValue
algEdDSA
kidThe id of the active key, as in a ticket's header
ticket_keyThe active key: the raw 32-byte Ed25519 public key, base64
keysEvery key that verifies tickets now: kid, alg, public_key, active, and created_at and retired_at in Unix milliseconds

After a key rotation, keys also lists the previous key for 5 minutes, the lifetime of the tickets the previous key signed.

Errors

StatusCodeMeaning
429rate_limitedThe client address made over 60 calls in a minute
503ticket_key_unavailableTickets cannot be signed now