# Live transcription

> Stream microphone or tab audio to transcript1 over a WebSocket and get subtitle lines, with optional real-time translation, back as people speak.

Canonical: https://transcript1.com/docs/live
Last updated: 2026-11-03

Live transcription turns an audio stream into subtitle lines while people are still speaking. Your client sends raw PCM audio over a WebSocket, and transcript1 sends back lines that start as a partial guess and become final. Add `translate_to` and every line carries its translation too. When the session ends, the result is saved as a normal transcription.

There are no separate live plans: live transcription uses the same minute balance as everything else. See [Billing](https://transcript1.com/docs/live#billing) for how connected time is counted.

## When to use live

| Use live when | Use [a normal transcription](https://transcript1.com/docs/getting-started) when |
|---|---|
| The audio is still being produced: a call, a talk, a stream, a video you are watching. | You have a link or a finished file. |
| You need subtitles within a second or two of the speech. | You want the best accuracy and can wait for the result. |
| You want real-time translation next to each line. | You want a translation of a finished transcript, or several languages. |

Live takes audio only, as raw PCM. It does not accept video links, uploads, WebM, Opus or MP3. Capture the audio in your client and convert it to PCM first. [Capture audio in a browser](https://transcript1.com/docs/live#capture-audio-in-a-browser) shows how.

## Quickstart with the SDK

Install SDK 1.5.0 or newer: `npm i @transcript1/sdk@latest`. `t1.live.start` creates the session, connects and resolves once the session has started, so you can send audio right away.

```javascript
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1(); // reads TRANSCRIPT1_API_KEY

const live = await t1.live.start({ language: 'en', translateTo: 'es', sampleRate: 16000 });

live.on('line', line => {
  const mark = line.final ? 'final  ' : 'partial';
  console.log(mark, line.text, '→', line.translation?.text ?? '');
});
live.on('warning', warning => console.warn(warning.code));

// speech.pcm: 16 kHz, mono, signed 16-bit little-endian, no header
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
  live.send(chunk);
  await sleep(100); // 3,200 bytes are 100 ms of audio: send at real-time speed
}

const result = await live.end();
console.log(result.reason, result.transcriptionId, result.minutesCharged);
```

Lines update in place. Replace a line you already have when one with the same `id` arrives, or read all of them from `live.lines`. `end()` finishes the session, waits for the last lines and saves the transcript. See [Node.js SDK](https://transcript1.com/docs/sdk#live-transcription) for every method and event.

## Quickstart with a raw WebSocket

Without the SDK there are two steps: create a session over HTTPS, then open the WebSocket with the single-use token that came back.

### Create a session

```bash
curl -X POST https://api.transcript1.com/v1/live/sessions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"language": "en", "translate_to": "es", "sample_rate": 16000}'
```

The API answers `201`:

```json
{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "waiting",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "websocket_url": "wss://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD/stream",
  "token": "Zk3x...",
  "token_expires_at": "2026-11-02T12:01:00Z",
  "rate": 1
}
```

`transcription_id` is `null` when `save` is `false`. The token is single-use and expires after 60 seconds, so connect right away. `rate` is the number of balance minutes charged per started minute of this session; see [Billing](https://transcript1.com/docs/live#billing).

The request body takes these fields. All are optional, and the scope is `transcriptions:write`:

| Field | Default | Rule |
|---|---|---|
| `language` | `auto` | `auto`, or a two-letter code that live transcription supports. |
| `translate_to` | none | A two-letter code. One target language per session. See [Translate live](https://transcript1.com/docs/live#translate-live). |
| `diarize` | `false` | Label speakers. Some setups cannot label speakers, for example together with `language: "auto"`, and answer `422`. Check [capabilities](https://transcript1.com/docs/live#capabilities). |
| `audio_format` | `pcm_s16le` | The only value. The field exists so formats can be added later. |
| `sample_rate` | `16000` | An integer from 8000 to 48000. Mono only. |
| `save` | `true` | `false` keeps nothing after the session ends. See [Saved transcripts](https://transcript1.com/docs/live#saved-transcripts). |

### Connect and stream

Open the WebSocket with the token as the second subprotocol. This works in browsers and in Node 22+ without custom headers. Wait for `session.started` before you send audio: binary frames sent earlier are dropped, and you get an `audio_dropped` warning.

```javascript
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';

const session = await fetch('https://api.transcript1.com/v1/live/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TRANSCRIPT1_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ language: 'en', translate_to: 'es', sample_rate: 16000 }),
}).then(response => response.json());

const ws = new WebSocket(session.websocket_url, ['t1-live', session.token]);
const lines = new Map();

ws.addEventListener('message', async message => {
  const event = JSON.parse(message.data);

  if (event.type === 'session.started') {
    for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
      ws.send(chunk);
      await sleep(100);
    }
    ws.send(JSON.stringify({ type: 'end' }));
  } else if (event.type === 'line') {
    lines.set(event.line.id, event.line); // the full current state: replace by id
    console.log(event.line.final, event.line.text, event.line.translation?.text);
  } else if (event.type === 'session.ended') {
    console.log('ended:', event.reason, event.minutes_charged);
  }
});

ws.addEventListener('close', event => console.log('closed', event.code));
```

On Node 20 there is no built-in `WebSocket`: use the `ws` package, which has the same `new WebSocket(url, protocols)` form.

The server answers a valid token with the `t1-live` subprotocol and nothing else. A bad token never fails the handshake: the socket opens, you get an `error` event and the socket closes with a 4xxx code, so a browser shows the real reason instead of a generic 1006. See [Close codes](https://transcript1.com/docs/live#close-codes).

## Capture audio in a browser

Live audio must be 16-bit signed little-endian mono PCM at the `sample_rate` of the session. Browsers give you floating-point samples, so convert them in an `AudioWorklet`. This snippet captures the microphone as 16 kHz PCM in 100 ms chunks and sends it with the browser SDK entry. `getAccessToken` returns an OAuth access token; never put an API key in a browser. See [Security](https://transcript1.com/docs/live#security).

```javascript
import { createLiveClient } from '@transcript1/sdk/live';

const workletSource = `
class Pcm16Processor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.buffer = new Int16Array(1600); // 100 ms at 16 kHz
    this.filled = 0;
  }

  process(inputs) {
    const channel = inputs[0][0];
    if (!channel) return true;
    for (let i = 0; i < channel.length; i++) {
      const sample = Math.max(-1, Math.min(1, channel[i]));
      this.buffer[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
      if (this.filled === this.buffer.length) {
        this.port.postMessage(this.buffer.slice());
        this.filled = 0;
      }
    }
    return true;
  }
}
registerProcessor('pcm16', Pcm16Processor);
`;

const client = createLiveClient({ getAccessToken: () => myApp.getOAuthAccessToken() });

const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
const context = new AudioContext({ sampleRate: 16000 });
const moduleUrl = URL.createObjectURL(new Blob([workletSource], { type: 'text/javascript' }));
await context.audioWorklet.addModule(moduleUrl);

// the context's real rate: a browser that cannot do 16 kHz picks its own
const live = await client.start({ language: 'auto', sampleRate: context.sampleRate });
live.on('line', renderLine);

const microphone = context.createMediaStreamSource(stream);
const capture = new AudioWorkletNode(context, 'pcm16');
capture.port.onmessage = event => live.send(event.data); // an Int16Array
const mute = context.createGain();
mute.gain.value = 0;
microphone.connect(capture).connect(mute).connect(context.destination);

async function stop() {
  stream.getTracks().forEach(track => track.stop());
  await context.close();
  return live.end();
}
```

The worklet is written for 16 kHz. If the context runs at another rate, the chunk is no longer 100 ms, but the PCM stays correct because `sampleRate` matches the context. To capture the sound of a tab or a video instead of the microphone, create the `MediaStream` with `getDisplayMedia` or from a media element's `captureStream()` and connect it the same way.

## Translate live

Add `translate_to` to the session and transcript1 translates every line as it is spoken.

- One target language per session. Add more languages to the saved transcript afterwards with the [translations endpoint](https://transcript1.com/docs/live#saved-transcripts).
- Every `line` has an original (`text`, `final`) and a `translation` object (`language`, `text`, `final`). `translation` is `null` when `translate_to` is not set.
- The original and the translation become final independently: a line can have a final original while its translation is still changing. Draw a line as final once both are.
- A line that is already in the target language uses its own text as the translation, and it is final when the line closes.
- A line closes when the speaker pauses, the speaker changes, a pause between words is longer than 0.9 seconds, the line reaches two subtitle rows (84 characters) or 15 seconds, or you send `finalize`. With translation on, a line can take about 3 seconds longer to close while its translation arrives. In that case the translation can stay open (`final: false`) and keep growing after the original is final.
- Translation is free. It uses no minutes.

Which target languages and language pairs are available depends on the live speech provider. Read [capabilities](https://transcript1.com/docs/live#capabilities) instead of hardcoding rules. An unsupported target returns `422 unsupported_translation`. For example, a provider can require an explicit `language` instead of `auto`, or allow only pairs that include English.

## WebSocket protocol

### Connecting

```javascript
new WebSocket(websocket_url, ['t1-live', token]);
```

The token travels as a WebSocket subprotocol. It never appears in a URL or an access log. Send no `Authorization` header: the token is the credential. The server pings every 30 seconds; browsers and the `ws` package answer automatically. A socket that sends no pong for 60 seconds is dropped.

A valid connection to a session that already has a socket replaces the old one. See [Resume and takeover](https://transcript1.com/docs/live#resume-and-takeover).

### Client messages

| Message | Meaning |
|---|---|
| Binary frame | Audio: PCM s16le mono at the session's `sample_rate`. Each frame has an even length and is at most 256 KB. About 100 ms per frame is a good size. |
| `{"type":"finalize"}` | Close the current line now, for example when the video is paused. |
| `{"type":"end"}` | Finish the session and save it. The server answers `session.ended` and closes the socket. |
| Anything else | A non-fatal `error` event with the code `invalid_message`. |

An odd-length or oversized frame is a protocol violation and closes the socket with `4008`.

Start sending audio after `session.started`. A client that sends audio faster than real time is billed for the audio time instead of the connected time (see [Billing](https://transcript1.com/docs/live#billing)), so send at the speed the audio is produced.

### Server events

Every event is a JSON text frame with a `type`.

| Event | Sent when |
|---|---|
| `session.started` | The connection was accepted. Send audio from now on. |
| `line` | A subtitle line was created or changed. |
| `usage` | After every charge. |
| `warning` | Something needs attention but the session continues. |
| `error` | A non-fatal problem, or the reason for an immediate close. |
| `reconnect` | The server is about to restart. Resume. |
| `session.ended` | The session is over and saved. The last event before a normal close. |

`session.started` says whether this is a resume and how much audio the session has already processed:

```json
{
  "type": "session.started",
  "session_id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "resumed": false,
  "offset_ms": 0
}
```

`line` carries the full current state of one line, so replace the line you have with the same `id`. See [The line object](https://transcript1.com/docs/live#the-line-object).

```json
{
  "type": "line",
  "line": {
    "id": 12,
    "start_ms": 53120,
    "end_ms": 56040,
    "speaker": null,
    "language": "en",
    "text": "Welcome back to the stream",
    "final": true,
    "translation": { "language": "es", "text": "Bienvenidos de nuevo", "final": false }
  }
}
```

`usage` is sent after each charge, with the connected time, the audio time, the balance minutes charged so far and the minutes you have left:

```json
{
  "type": "usage",
  "connected_ms": 61240,
  "audio_ms": 60100,
  "minutes_charged": 2,
  "minutes_remaining": 898
}
```

`warning` has a `code`:

| `code` | Meaning |
|---|---|
| `balance_low` | The next charge would fail. The session ends with `balance_exhausted` when it comes due. Not sent while [auto top-up](https://transcript1.com/docs/billing-and-limits#auto-top-up) is on. |
| `audio_dropped` | Audio arrived before the session was ready, or too much audio arrived while the speech provider connected. The oldest audio was dropped. |
| `idle_timeout_soon` | No audio for 4 minutes. The session ends after 5 minutes without audio. Send audio to keep it. |

```json
{ "type": "warning", "code": "idle_timeout_soon" }
```

`error` has a `code` and a `message`. These codes can arrive on a live socket:

| `code` | Fatal | Meaning |
|---|---|---|
| `invalid_message` | No | A text frame that is not `finalize` or `end`. |
| `invalid_token` | Yes | The token is invalid, expired or already used. The socket closes with `4001`. |
| `not_found` | Yes | The session does not exist. The socket closes with `4004`. |
| `session_ended` | Yes | The session has already ended. The socket closes with `4004`. |

```json
{ "type": "error", "code": "invalid_message", "message": "Expected {\"type\":\"finalize\"} or {\"type\":\"end\"}" }
```

`reconnect` comes before the server closes the socket with `1012`, for example during a deploy. Fetch a new token and resume.

```json
{ "type": "reconnect", "reason": "server_restart" }
```

`session.ended` is the last event of every session. The numbers are the final totals:

```json
{
  "type": "session.ended",
  "reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "minutes_charged": 3,
  "connected_ms": 142500,
  "audio_ms": 141900
}
```

### The line object

| Field | Meaning |
|---|---|
| `id` | A 0-based number that grows by one for each line. It is the segment index in the saved transcript. |
| `start_ms`, `end_ms` | Start and end in session audio time. This time carries on across reconnects. |
| `speaker` | The speaker label when `diarize` is on, otherwise `null`. Labels are the same as in normal transcripts. |
| `language` | The detected or given language of the line, or `null` when it is not known yet. |
| `text` | The original text so far. |
| `final` | `true` once the original text will no longer change. |
| `translation` | `null` without `translate_to`, otherwise `language`, `text` and `final`. |

Show lines with `final: false` in a lighter style: their text can still change. The text of a line with `final: true` never changes again, and a final line is never removed.

The SDK uses `camelCase` for line fields: `startMs`, `endMs`.

### End reasons

`session.ended` and `GET /v1/live/sessions/:id` give an `end_reason`:

| Reason | Meaning |
|---|---|
| `client_ended` | You sent `end`. |
| `balance_exhausted` | The next charge failed. The session ends on a minute boundary and is saved. |
| `idle_timeout` | No audio for 5 minutes. |
| `max_duration` | The session was connected for 300 minutes. Start a new one. |
| `disconnected` | The client was gone for more than 2 minutes without resuming. |
| `upstream_error` | The speech provider failed. |
| `internal_error` | Something went wrong on our side. |
| `not_connected` | The session was never connected within 5 minutes. It is never charged and is visible only through `GET`. |

### Close codes

| Code | Meaning |
|---|---|
| `1000` | Normal close. It always follows `session.ended`. |
| `1011` | Unexpected server error. The session is recovered by the server: resume with a new token. |
| `1012` | Server restart. It follows `reconnect`: resume with a new token. |
| `4001` | The token is invalid, expired or already used. |
| `4004` | The session was not found or has already ended. |
| `4008` | Protocol violation: an odd-length or oversized binary frame. |
| `4010` | Replaced by a newer connection to the same session. |

While the API restarts, a new WebSocket request can be answered with HTTP `503`. Retry shortly.

## Resume and takeover

A session survives a dropped network, an API deploy and a crash. For 2 minutes after a drop it waits for you; after that it ends as `disconnected` and is saved.

1. Call `POST /v1/live/sessions/:id/token`. The response is `token`, `token_expires_at` and `websocket_url`, the same fields as when you created the session. It fails with `409 session_ended` once the session has ended.
2. Open a new WebSocket with that token.
3. `session.started` arrives with `resumed: true`. `offset_ms` is the amount of audio the session has already processed: continue with the audio from that point. Line ids continue.

Lines that were already final are kept. The in-progress tail at the moment of the drop is lost, so replay a few seconds of audio from `offset_ms` if you buffer it. Reconnect gaps are not billed.

Takeover is the same call. If the server still thinks a socket is open, a new valid connection replaces it, and the old socket is closed with `4010` after its lines are saved. A resume is never blocked by the limit on simultaneous sessions.

The SDK does all of this for you: on a drop or a `reconnect` event it fetches a token and reconnects with a backoff of 0.5 to 8 seconds for up to 2 minutes, and replays the last 10 seconds of audio sent during the gap.

## Billing

Live transcription draws from the same minute balance as batch transcriptions.

- **Connected time is billed**, from the moment the socket is accepted until it closes. Silence and a paused video count. The time between a drop and a resume does not.
- **Per started minute, minimum 1.** The first minute is charged when the first connection is accepted, and each following minute when it starts.
- **Faster than real time is billed as audio time.** If the audio you send is longer than the connected time, the audio time is billed instead.
- **The rate is read from the API.** One started minute costs `rate` balance minutes, rounded up. The rate is usually 1 and can have decimals. Read the live rate from `rates.live` in `GET /v1/account`, from `rate` in `GET /v1/live/capabilities` and from `rate` in the session you create. The rate of a session stays fixed once it is created.
- **Translation is free.**
- **Never connected means not charged.** A session that is not connected within 5 minutes ends as `not_connected`.
- **Failures are refunded.** If a session ends with `upstream_error` or `internal_error` before it produced a single line, every charge is returned. An empty session caused by silence is charged and saved empty.
- **When the balance runs out** and [auto top-up](https://transcript1.com/docs/billing-and-limits#auto-top-up) is on, transcript1 charges your saved card while the session keeps streaming, and the session continues. If auto top-up is off or paused, or the charge fails, the session finalizes, saves and ends with `balance_exhausted` on a minute boundary. The `balance_low` warning comes first, unless auto top-up is on.

Creating a session returns `402 insufficient_minutes` when your usable balance is lower than one started minute at the current rate. If auto top-up is on, transcript1 tries to top up first. See [Billing and limits](https://transcript1.com/docs/billing-and-limits#live-transcription).

## Limits

| | Free | Monthly | Annual |
|---|---|---|---|
| Simultaneous live sessions | 1 | 2 | 3 |

- A session counts while it is `waiting`, `connected` or `disconnected` (within its 2-minute window). Over the limit, `POST /v1/live/sessions` returns `429 too_many_live_sessions` with `Retry-After: 30`.
- Live sessions do not count toward your active job limit.
- A session lasts at most 300 minutes of connected time. It then ends with `max_duration`.
- A session with no audio for 5 minutes ends with `idle_timeout`; you get `idle_timeout_soon` after 4 minutes. A paused stream still bills connected time and counts as idle, so end the session if the pause is long.
- transcript1 limits all live sessions together. When they are all in use, `POST /v1/live/sessions` returns `503 live_capacity`, which is retryable. Wait for `Retry-After` (30 seconds) and try again. This is checked only when a session is created, never in the middle of one.
- Creating sessions and fetching tokens count toward your requests per minute.
- Audio is raw PCM only: `pcm_s16le`, mono, 8000 to 48000 Hz.

## Capabilities

`GET /v1/live/capabilities` describes what live transcription supports right now, so clients never hardcode provider rules. The scope is `transcriptions:read`.

```json
{
  "available": true,
  "auto_language": true,
  "languages": ["en", "es"],
  "translation": {
    "targets": ["es", "de"],
    "pairs": "any",
    "requires_language": false
  },
  "diarize": true,
  "audio_formats": ["pcm_s16le"],
  "sample_rate": { "min": 8000, "max": 48000 },
  "max_session_minutes": 300,
  "rate": 1
}
```

The lists are shortened here. Read them from the API.

- `translation.pairs` is `"any"` (any detected language into any target), or a list of objects with `from` and `to`.
- `translation.requires_language` is `true` when translation needs an explicit `language` instead of `auto`.
- When live transcription is not configured, the response is `available: false` with only `rate`, and creating a session returns `503 live_unavailable`.

With the SDK: `await t1.live.capabilities()`.

## Read a session

`GET /v1/live/sessions/:id` returns the state of a session. The scope is `transcriptions:read`.

```bash
curl https://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```json
{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "ended",
  "end_reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "options": {
    "language": "en",
    "translate_to": "es",
    "diarize": false,
    "audio_format": "pcm_s16le",
    "sample_rate": 16000,
    "save": true
  },
  "connected_ms": 142500,
  "audio_ms": 141900,
  "minutes_charged": 3,
  "rate": 1,
  "created_at": "2026-11-02T11:58:12Z",
  "ended_at": "2026-11-02T12:00:35Z"
}
```

`status` is `waiting`, `connected`, `disconnected` or `ended`. `end_reason` is `null` until the session has ended. With the SDK: `await t1.live.get(id)`.

## Errors

Request errors use the usual [error envelope](https://transcript1.com/docs/errors). These are the ones that `POST /v1/live/sessions` and its siblings return:

| HTTP status | `code` | Retryable | When |
|---|---|---|---|
| 422 | `invalid_request` | No | A field is missing or invalid, for example a `sample_rate` outside 8000 to 48000. |
| 422 | `unsupported_language` | No | `language` is not supported for live transcription. |
| 422 | `unsupported_translation` | No | The target language, or the language pair, is not supported. |
| 402 | `insufficient_minutes` | No | Your usable balance is below one started minute at the current rate. |
| 403 | `email_not_verified` | No | Verify your email address first. |
| 429 | `too_many_live_sessions` | Yes | Your plan's simultaneous live sessions are in use. Wait for `Retry-After`. |
| 503 | `live_capacity` | Yes | All live capacity is in use. Wait for `Retry-After` (30 seconds). |
| 503 | `live_unavailable` | No | Live transcription is not available right now. |
| 409 | `session_ended` | No | `POST /v1/live/sessions/:id/token` for a session that has ended. |

Problems on the socket arrive as `error` events and [close codes](https://transcript1.com/docs/live#close-codes).

## Saved transcripts

With `save: true` (the default), the session is saved as a normal transcription when it ends. The audio is never stored.

- `transcription_id` is returned when you create the session. Its status is `live` while the session runs and `completed` after it ends. A session that never connected leaves a `canceled` transcription.
- The transcription has `source: { "platform": "live", "session_id": "lv_…" }`. There is no link or file. The dashboard lists it as a live session.
- Each line becomes one segment, with the same index as the line `id`. Read the transcript as JSON, text, SRT or VTT like any other transcription: see the [API reference](https://transcript1.com/docs/api-reference).
- The live translation is saved with it. Read it with the `translation` parameter, as described in [Translation](https://transcript1.com/docs/translation#read-the-translation).
- Add more translation languages later with `POST /v1/transcriptions/:id/translations` once the transcription is `completed`. See [Translation](https://transcript1.com/docs/translation#translate-an-existing-transcript).

With `save: false`, nothing is kept after the session ends: there is no transcription and `transcription_id` is `null`. You still get every line over the socket and the usual charge applies.

## Security

- The token you get when you create a session is single-use and expires after 60 seconds. It is sent as a WebSocket subprotocol, not in the URL, so it stays out of access logs. A leaked token is useless once used or expired.
- Use an API key only from a server you control. Never put an API key into a web page or an extension.
- In a browser or an extension, sign users in with OAuth and pass the OAuth access token to `getAccessToken` of `createLiveClient`. The token needs the `transcriptions:write` scope to start sessions. OAuth access tokens expire after 1 hour, and every resume asks `getAccessToken` for a fresh one.
- Your server can also create the session with its API key and hand only the `websocket_url` and `token` to the browser. The client then connects directly. It cannot resume without a new token from you.
