/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.
| Name | Type | Description |
|---|---|---|
client | string | The 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.
{
"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"}
]
}| Field | Value |
|---|---|
session_id | ds_ and 24 characters |
ticket | A signed ticket that opens the stream, valid 5 minutes |
expires_at | The ticket's expiry in Unix milliseconds |
endpoints | The ways to reach the stream, in order: kind and url; relay is the only kind today |
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | The body is not a JSON object, or client is over 64 characters or not printable |
| 401 | api_key_required | No API key |
| 401 | invalid_key | An unknown or revoked key |
| 403 | scope_missing | The key lacks the dictation scope |
| 403 | account_cannot | A key of an account other than the owner account |
| 503 | dictation_off | Dictation is switched off |
| 503 | device_offline, device_unsupported | Speech to text is offline |
| 503 | ticket_key_unavailable | Tickets 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
| Name | Type | Description |
|---|---|---|
id required | string | The 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
| Status | Code | Meaning |
|---|---|---|
| 401 | api_key_required | No API key |
| 401 | invalid_key | An unknown or revoked key |
| 403 | scope_missing | The key lacks the dictation scope |
| 403 | account_cannot | A key of an account other than the owner account |
| 404 | session_not_found | No session with the id, or a session of another account |
| 503 | dictation_off | Dictation is switched off |
| 503 | device_offline, device_unsupported | Speech to text is offline |
| 503 | ticket_key_unavailable | Tickets 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
| Name | Type | Description |
|---|---|---|
session required | string | Query parameter: the session id ds_… |
Headers
| Name | Description |
|---|---|
Authorization | Request. Bearer <ticket>: authenticates on the upgrade, so a refusal is an HTTP answer |
X-Dictation-Ticket | Request. <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.
{"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.
| Status | Code | Meaning |
|---|---|---|
| 400 | session_required | session is missing or malformed |
| 400 | auth_required | The first frame is not {"op":"auth"} |
| 401 | ticket_required | No ticket |
| 401 | ticket_invalid | A malformed ticket, or a ticket with a wrong signature |
| 401 | ticket_expired | The ticket's exp has passed |
| 401 | invalid_key | The key that opened the session is revoked |
| 403 | scope_missing | The key that opened the session lost the dictation scope |
| 403 | ticket_wrong_session | The ticket belongs to another session |
| 408 | auth_timeout | No auth frame within 5 seconds; close code 4408 only |
| 409 | ticket_in_use | The ticket holds an open socket |
| 409 | ticket_stale | A newer ticket of the session holds the open socket |
| 426 | upgrade_required | A plain GET without a WebSocket upgrade |
| 429 | rate_limited | The client address made over 60 upgrades in a minute |
| 429 | too_many_streams | The account holds 4 open sockets |
| 503 | dictation_off | Dictation is switched off |
| 503 | device_offline, device_unsupported, device_busy | Speech to text is offline or holds its maximum of streams |
| 503 | auth_backlog | Too many sockets wait for their auth frame; try again |
| 504 | device_timeout | Speech 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
| Name | Description |
|---|---|
Cache-Control | Response. public, max-age=60 |
Request
curl
curl https://api.albastov.com/v1/dictation/keysPython
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
{
"alg": "EdDSA",
"kid": "Xq2mVb7NcL0pRt4s",
"ticket_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
"keys": [
{"kid": "Xq2mVb7NcL0pRt4s", "alg": "EdDSA", "public_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
"active": true, "created_at": 1793567100000, "retired_at": null}
]
}| Field | Value |
|---|---|
alg | EdDSA |
kid | The id of the active key, as in a ticket's header |
ticket_key | The active key: the raw 32-byte Ed25519 public key, base64 |
keys | Every 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
| Status | Code | Meaning |
|---|---|---|
| 429 | rate_limited | The client address made over 60 calls in a minute |
| 503 | ticket_key_unavailable | Tickets cannot be signed now |