> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tiro.ooo/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket protocol

> Reference for the realtime transcription WebSocket: connecting, audio frame rules, message fields, ending and reconnection, error reasons, and close codes.

Enterprise The Realtime Transcription API is available to Enterprise plan customers on request. See [Overview](https://docs.tiro.ooo/en/developers/realtime-transcription/overview.md) for the requirements.

This page covers the rules for the WebSocket connection you open to `wsUrl` after you create a session. The REST APIs to create, get, list, and end sessions are in the [API reference](https://docs.tiro.ooo/api-reference/realtime-transcription/create-realtime-transcription.md).

## How do sessions and connections differ?

A session is the transcription unit you create over REST. A connection is one WebSocket attached to that session. A session has only two states, `OPEN` and `ENDED`. While the session is `OPEN`, a connection can drop and attach again.

```mermaid theme={"system"}
stateDiagram-v2
    [*] --> OPEN: POST /v1/external/realtime-transcriptions
    state OPEN {
        state "No connection" as Idle
        state "Connected" as Connected
        [*] --> Idle
        Idle --> Connected: Connect to wsUrl, session.started
        Connected --> Idle: Connection drops (1001, 1006, 1011, 1012, 4029, 4500, 4503, and others)
        Connected --> Connected: New connection replaces the old one (old one gets 4409)
    }
    OPEN --> ENDED: session.end, REST end, timeout
    ENDED --> [*]
```

* Once a session is `ENDED`, you cannot reopen it. To keep transcribing, create a new session.
* When a connection drops, the session stays `OPEN`. Reconnect with the same `wsUrl` and `streamToken`.
* You receive the reason the session ended in `endReason`. The values are listed under [`session.ended`](#session-ended).

## Connecting

Connect to the `wsUrl` from the `POST /v1/external/realtime-transcriptions` response with the `Authorization: Bearer <streamToken>` header.

* Use `wsUrl` exactly as returned. It currently looks like `wss://<host>/v1/external/realtime-transcriptions/{id}/stream`, but the host and path can change.
* Tiro accepts the token only in the `Authorization` header. You cannot connect with a query string, a cookie, or an API key. For that reason, the browser WebSocket API, which cannot set headers, cannot connect.
* Tiro does not check the Origin header.
* A `streamToken` works only for its session, and you use the same token to reconnect while the session is `OPEN`. You receive it only in the session creation response, and no API issues it again. If you lose the token, end the session and create a new one.

Tiro accepts the connection first. If something is wrong, it sends an `error` message and then closes the connection. Tiro rejects with an HTTP status only in these three cases.

| HTTP | Condition |
| - | - |
| `400` | The request is not a WebSocket upgrade |
| `401` | The `Authorization` header has no Bearer token |
| `404` | The path is not a realtime transcription stream path |

After accepting, Tiro checks the token, the session status, the API key that issued the token, and the workspace policy. If the policy is turned off or the API key is deleted during a session, Tiro keeps the open connection and rejects the next connection.

## Audio frames

Send audio as binary frames and control messages as text frames. Each text frame carries one UTF-8 JSON object.

| Item | Value |
| - | - |
| Format | 16,000 Hz, mono, signed 16-bit little-endian PCM. Raw data with no header |
| Data rate | 32,000 bytes per second |
| Frame size | Up to 6,400 bytes (200 ms). 3,200 bytes (100 ms) recommended. No minimum |
| Audio lead | You can send audio up to 60 seconds ahead of real time |

You can send audio before you receive `session.started`. Tiro keeps audio that arrives before transcription is ready and processes it in order.

| Violation | Result |
| - | - |
| Frame larger than 6,400 bytes | `error` (`audio_frame_too_large`), then close `1009` |
| Frame with an odd number of bytes | `error` (`invalid_audio_frame`), then close `1007` |
| Audio lead above 60 seconds | `error` (`audio_rate_exceeded`), then close `4029` |
| Text frame that is not valid JSON | `error` (`invalid_message`, `fatal: false`). The connection stays open |
| Unknown `type` | `error` (`unknown_message_type`, `fatal: false`). The connection stays open |
| Audio sent after `session.end` | Ignored with no error |

### How is the audio lead calculated?

The audio lead is how far the audio you sent is ahead of the real elapsed time. Tiro measures it for each connection and closes the connection when it exceeds 60 seconds.

* Idle time does not build up credit. If you send nothing for a minute and then send two minutes of audio at once, you still exceed 60 seconds.
* A new connection inherits the lead of the previous one. Reconnecting does not reset the limit.
* Above 60 seconds, `retryAfterMs` in `error` (`audio_rate_exceeded`) holds the overflow. Wait at least that long, reconnect, and send at real-time pace.

## Messages

Every text message has a `type` field. Lengths and offsets are integer milliseconds with names that end in `Ms`. Times are ISO-8601 UTC strings with names that end in `At`. Locales use the [Supported locales](https://docs.tiro.ooo/en/developers/fundamentals/supported-locales.md) format, such as `ko_KR`.

| Direction | `type` | When |
| - | - | - |
| Client to Tiro | Binary frame | Audio |
| Client to Tiro | [`session.end`](#session-end) | To end the session |
| Tiro to client | [`session.started`](#session-started) | Once, as the first message of each connection |
| Tiro to client | [`transcript`](#transcript) | Transcription result |
| Tiro to client | [`translation`](#translation) | Translation result |
| Tiro to client | [`session.ended`](#session-ended) | When the session ends. This is the last message of the session |
| Tiro to client | [`error`](#error) | An error. With `fatal: true`, Tiro closes the connection right after |

**Ordering.** Messages for the same `segmentId` (`transcript`) and the same `translationId` (`translation`) arrive in the order Tiro sent them. Zero or more `isFinal: false` messages come first, then one `isFinal: true`. Tiro does not guarantee order between different segments, or between `transcript` and `translation`.

**Compatibility.** Tiro can add message types and fields. It does not remove fields or change their names, types, or meanings. Ignore, or apply a default to, any unknown `type`, field, `reason`, `endReason`, or `status` value.

### `session.end`

The client sends this to end the session. You can send it more than once. Tiro ignores every copy after the first. For what happens next, see [Ending a session](#ending).

```json theme={"system"}
{ "type": "session.end" }
```

### `session.started`

The first message of each connection. It usually arrives in about 1 second, and rarely takes tens of seconds.

```json theme={"system"}
{ "type": "session.started", "id": "Rt7Yq2PbX9cDk", "resumed": false, "audioOffsetMs": 0 }
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Session ID. Same as `id` in the REST response |
| `resumed` | boolean | `false` on the session's first connection, `true` on a reconnection |
| `audioOffsetMs` | integer | The audio length Tiro has finished processing. It is also where the first audio you send on this connection lands on the session timeline. It is `0` on the first connection. For how to use it, see [Audio from a disconnect](#resume-audio) |

### `transcript`

A transcription result.

```json theme={"system"}
{
  "type": "transcript",
  "segmentId": "Ab3kZ9qLm2XyW-1-17",
  "isFinal": true,
  "text": "오늘 회의는 일정 점검부터 하겠습니다.",
  "locale": "ko_KR"
}
```

| Field | Type | Description |
| - | - | - |
| `segmentId` | string | ID of the result unit (segment). An opaque string, so do not parse it or sort by it |
| `isFinal` | boolean | `false` for a result that can still change, `true` for a final result |
| `text` | string | The full current text of this segment, not a delta to append. Can be an empty string |
| `locale` | string \| null | If the session set `transcriptionLocale`, always that value. In an auto-detect session, the language detected for this segment. `null` if none was detected or it is outside the supported list |

* When an `isFinal: false` message arrives for a `segmentId`, replace that segment's whole text.
* `isFinal: true` arrives exactly once per segment. After it, no more messages arrive for that segment.
* A segment can arrive as `isFinal: true` only, with no `isFinal: false` before it.
* Tiro decides segment size and does not guarantee it. Segments currently break where an utterance ends.

### `translation`

A translation result. It arrives only when the session set `translationLocale`.

```json theme={"system"}
{
  "type": "translation",
  "translationId": "t3-9",
  "isFinal": true,
  "locale": "en_US",
  "text": "Today's meeting will start with a schedule review.",
  "sourceSegmentId": null
}
```

| Field | Type | Description |
| - | - | - |
| `translationId` | string | ID of the translation piece. An opaque string |
| `isFinal` | boolean | `false` for a piece that can still change, `true` for a final piece |
| `locale` | string | The session's `translationLocale`. Never `null` |
| `text` | string | The full current text of this piece. Never an empty string |
| `sourceSegmentId` | string \| null | Reserved for a hint about the source position. Always `null` for now |

* The replacement rule matches `transcript`. An `isFinal: false` message replaces the whole previous text for the same `translationId`, and `isFinal: true` arrives only once.
* Translation pieces do not pair 1:1 with source segments. One piece can span several segments, and one segment can split across several pieces.
* Join the `text` of final pieces in arrival order, with no separator, to get the translation. Pieces can break mid-word, so do not add spaces between them. A final piece that contains only whitespace is a word break, so join it as is.
* A translation can arrive after its source is final, or before the source's `isFinal: false` message.
* In an auto-detect session, Tiro does not translate a segment whose `transcript.locale` equals `translationLocale`. In a session with a set language, speech in the target language can also go untranslated.

### `session.ended`

Tells you that the session ended. This is the last message of the session. Tiro closes the connection with close `1000` after it. When you receive it, do not reconnect.

```json theme={"system"}
{
  "type": "session.ended",
  "id": "Rt7Yq2PbX9cDk",
  "endReason": "CLIENT_REQUESTED",
  "audioDurationMs": 3605120,
  "endedAt": "2026-11-05T10:01:30Z"
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Session ID |
| `endReason` | string | Why the session ended. See the table below |
| `audioDurationMs` | integer | Total audio length Tiro received during the session |
| `endedAt` | string | When the session ended |
| `incomplete` | boolean | Present as `true` only when Tiro could not send all remaining results within 30 seconds, so some final results may be missing. Absent when results are complete |

| `endReason` | Condition |
| - | - |
| `CLIENT_REQUESTED` | You sent `session.end` or ended the session over REST |
| `IDLE_TIMEOUT` | No audio arrived for 15 minutes |
| `MAX_DURATION` | 3 hours passed since the first connection |
| `INTERNAL_ERROR` | Tiro determined that it cannot continue the session. Currently this happens when three connections in a row close with `stt_unavailable` (`4503`) within 5 minutes |

A session that ends while no connection is open has nowhere to send this message. In that case, check the status with `GET /v1/external/realtime-transcriptions/{id}`. Connecting to an ended session returns `error` (`session_ended`).

### `error`

Reports an error.

```json theme={"system"}
{
  "type": "error",
  "reason": "audio_frame_too_large",
  "message": "Audio frame exceeds 6400 bytes",
  "retryable": false,
  "fatal": true,
  "maxFrameBytes": 6400,
  "receivedBytes": 32000
}
```

| Field | Type | Description |
| - | - | - |
| `reason` | string | Identifier to branch on. See the [WebSocket errors](#ws-errors) table |
| `message` | string | Human-readable English text. It can change, so do not branch on it |
| `fatal` | boolean | `true` means Tiro closes the connection right after this message |
| `retryable` | boolean | Whether you may reconnect after the connection closes following this message. Always `false` when `fatal` is `false` |
| `retryAfterMs` | integer | Only on `audio_rate_exceeded`. How long to wait before reconnecting |
| `maxFrameBytes`, `receivedBytes` | integer | Only on `audio_frame_too_large`. The allowed size and the received size |

## Ending a session

Send `session.end` to receive every last result.

**1. Send session.end**

After the last audio frame, send `{"type": "session.end"}`. Tiro ignores audio sent after it.

**2. Receive the remaining results**

Tiro sends all remaining `transcript` and `translation` messages with `isFinal: true`. Segments and translation pieces that are not final yet are sent as final with their last text.

**3. Receive session.ended**

Tiro sends `session.ended` and closes the connection with close `1000`.

Keep the connection open until you receive `session.ended`. Sending the remaining results takes up to 30 seconds, including translations. If it does not finish within 30 seconds, Tiro sends `session.ended` with the results sent so far and adds `incomplete: true`.

When a session ends over REST or by a timeout while a connection is open, it ends in the same order. In those cases, audio that reached Tiro but has not started transcription yet may be left out of the results.

If you only close the WebSocket connection, the session stays `OPEN` and holds one of the workspace's concurrent session slots. If a connection simply drops, you also do not receive the results that were not final at that moment.

## Timeouts

| Item | Value | Result |
| - | - | - |
| No audio | 15 minutes, counted from session creation or the last audio frame, with or without a connection | The session ends with `IDLE_TIMEOUT` |
| Maximum session length | 3 hours, counted from the first connection, including time spent disconnected | The session ends with `MAX_DURATION` |
| Ping | Tiro sends a WebSocket ping every 20 seconds. With no pong for 60 seconds, Tiro closes the connection with close `1001` | The session stays `OPEN` |

* Text messages do not reset the no-audio timer.
* While muted, you can keep the connection open and stop sending. If you send audio again within 15 minutes, the session continues.
* Tiro can end a session later than the timeout: up to about 1 minute late with a connection, and up to about 3 minutes late with no connection.

## Reconnecting

While a session is `OPEN`, you can reconnect with the same `wsUrl` and `streamToken` after a disconnect. Decide whether to reconnect based on the last message you received, not the close code.

```mermaid theme={"system"}
flowchart TD
    A[Connection closed] --> B{Received session.ended?}
    B -- Yes --> X[Session over. Do not reconnect]
    B -- No --> C{"Did the error that closed the connection have retryable: false?"}
    C -- Yes --> Y[Do not reconnect]
    C -- No --> D{Close 1012?}
    D -- Yes --> E[Reconnect right away]
    D -- No --> F[Reconnect after backoff]
```

1. If you received `session.ended`, the session is over. Do not reconnect.
2. If the `error` that closed the connection (`fatal: true`) had `retryable: false`, do not reconnect. Ignore earlier non-fatal errors (`fatal: false`, such as `invalid_message`) in this decision.
3. In every other case, reconnect. That covers an `error` with `retryable: true` and a close with no message. Close `1012` is a server restart, so reconnect right away. For everything else, use backoff.

Start the backoff at 1 second, double it up to 30 seconds, and add ±20% jitter. If `retryAfterMs` is present, wait at least that long. While the session is `OPEN`, there is no limit on reconnection attempts.

A successful reconnection returns `session.started` with `resumed: true`. If the session already ended, you receive `error` (`session_ended`, `retryable: false`).

### How do I resend audio from a disconnect?

Tiro never asks for audio again. Audio that was not sent is not part of the session, so the client must buffer it and send it again.

```mermaid theme={"system"}
sequenceDiagram
    participant C as Your client
    participant W as Tiro WebSocket

    C->>W: PCM audio
    W--xC: Connection drops (no message or retryable error)
    Note over C: Buffer up to the last 60 seconds of audio
    C->>W: Connect to the same wsUrl (same streamToken)
    W-->>C: session.started {resumed: true, audioOffsetMs}
    C->>W: Buffered audio after audioOffsetMs
    C->>W: Then live audio
    W-->>C: transcript, translation
```

* While disconnected, keep up to the most recent 60 seconds of audio. Drop anything older.
* After reconnecting, read `audioOffsetMs` from `session.started`. It is the audio length Tiro finished processing, so it can be shorter than what the previous connection actually sent. If a new connection replaces the previous one before Tiro notices that the previous one dropped, the value can be `0`.
* First send the buffered audio from the `audioOffsetMs` point of the session timeline onward, then continue with live audio. This stays within the 60-second audio lead.
* Tiro does not check whether resent audio duplicates audio it already received.
* Close `1012` arrives with no message, so some frames sent just before it may not have reached Tiro. In that case too, resend the audio after `audioOffsetMs`.
* Discard segments and translation pieces that were still `isFinal: false` at the disconnect. Tiro does not send them again on the new connection.

### What happens if I connect to the same session twice?

A session has one connection at a time. When a new connection arrives with the same token, Tiro uses the new connection and closes the old one.

* The old connection receives `error` (`connection_replaced`, `retryable: false`) and closes with close `4409`. Do not reconnect from the old connection's client.
* The new connection receives `session.started` with `resumed: true` and continues the session.

Tiro works this way so that the new connection works right away even when a mobile network switch drops the old connection before Tiro notices.

## WebSocket errors

New `reason` values can appear. For an unknown `reason`, follow its `fatal` and `retryable` values.

| `reason` | `fatal` | `retryable` | close | Situation and action |
| - | - | - | - | - |
| `unauthorized` | true | false | `4001` | The token is wrong, the path belongs to another session, or the API key that issued the token was deleted or disabled. Create a new session |
| `realtime_api_not_enabled` | true | false | `4003` | The Realtime Transcription API is turned off for the workspace |
| `session_ended` | true | false | `4004` | The session already ended. Create a new session |
| `connection_replaced` | true | false | `4409` | A new connection joined the same session. Only this connection ends. The session continues on the new connection |
| `audio_frame_too_large` | true | false | `1009` | A frame exceeded 6,400 bytes. Split frames smaller |
| `invalid_audio_frame` | true | false | `1007` | A frame had an odd number of bytes. Cut frames on 16-bit sample boundaries |
| `audio_rate_exceeded` | true | true | `4029` | Audio lead exceeded 60 seconds. Wait at least `retryAfterMs`, reconnect, and send at real-time pace |
| `stt_unavailable` | true | true | `4503` | Tiro could not reconnect to the transcription engine after three retries at 1, 2, and 4 second intervals. Reconnect with backoff. If three connections in a row close with this error within 5 minutes, the session ends with `INTERNAL_ERROR` and the next connection gets `error` (`session_ended`) |
| `internal_error` | true | true | `4500` | A server error on this connection. The session stays `OPEN`. Reconnect with backoff |
| `invalid_message` | false | false | none | A text frame was not valid JSON. The connection stays open |
| `unknown_message_type` | false | false | none | You sent an unknown `type`. The connection stays open |

## Close codes

Use this table when you read logs. Decide on reconnection with the [Reconnecting](#reconnect) rules and not the close code. New close codes can appear.

| Code | Meaning | Preceding message |
| - | - | - |
| `1000` | Session ended | `session.ended` |
| `1001` | Server shutdown (deploy or restart), or no pong for 60 seconds. Reconnect with backoff | None |
| `1006` | Abnormal closure with no close frame | None |
| `1007` | Malformed audio frame | `error` (`invalid_audio_frame`) |
| `1009` | Audio frame larger than 6,400 bytes | `error` (`audio_frame_too_large`) |
| `1011` | Tiro could not send a message on this connection. This can happen when the client does not read messages in time. Reconnect with backoff | None |
| `1012` | Server restart. Reconnect right away | None |
| `4001` | Authentication failed | `error` (`unauthorized`) |
| `4003` | Realtime Transcription API turned off | `error` (`realtime_api_not_enabled`) |
| `4004` | Session already ended | `error` (`session_ended`) |
| `4029` | Audio lead above 60 seconds | `error` (`audio_rate_exceeded`) |
| `4409` | Replaced by another connection | `error` (`connection_replaced`) |
| `4500` | Internal server error on this connection | `error` (`internal_error`) |
| `4503` | Transcription engine unavailable | `error` (`stt_unavailable`) |

## REST errors

The session REST APIs return errors in the shared format from [Error handling](https://docs.tiro.ooo/en/developers/fundamentals/error-handling.md). Branch on `detail.reason`. For a response with no `detail.reason`, branch on the HTTP status. Errors for each endpoint are in the [API reference](https://docs.tiro.ooo/api-reference/realtime-transcription/create-realtime-transcription.md).

| HTTP | `detail.reason` | Situation and action |
| - | - | - |
| 400 | `invalid_request` | A value in the session creation request breaks a rule. It also occurs when `translationLocale` is `AUTO` or equals the transcription language. Check `detail.fields` |
| 400 | `unsupported_language` | The locale is not supported. Tiro also rejects regions outside the list, such as `en-GB`. Check `detail.field` and `detail.value` |
| 400 | none | `status` in the list request is not `OPEN` or `ENDED` |
| 401 | none | The API key is missing or invalid |
| 403 | `insufficient_scope` | The key lacks the required scope. OAuth access tokens also receive this response |
| 403 | `workspace_bound_key_required` | The key is an account API key not bound to a workspace, or an organization API key. Use a key bound to the workspace |
| 403 | `workspace_access_denied` | `workspaceGuid` differs from the key's workspace, or the account API key owner is not a member of the workspace |
| 403 | `realtime_api_not_enabled` | Only on session creation. The Realtime Transcription API is turned off for the workspace, so contact Tiro |
| 404 | none | The key's workspace was deleted |
| 404 | `realtime_transcription_not_found` | The session does not exist or does not belong to the key's workspace |
| 409 | `concurrent_transcription_limit` | The workspace has 10 `OPEN` sessions. Find unused sessions with the list API, end them, and try again |
| 429 | none | The key exceeded 600 calls per 60 seconds. Wait for `Retry-After` |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.