# Cartesia STT reference

> CartesiaStreamingClient WebSocket contract: wss://api.cartesia.ai/stt/turns/websocket, model ink-2, encoding pcm_s16le, sample_rate 16000, cartesia_version 2026-03-01, server events, client close message, pending audio buffer, and STTFailure classification table with notchMessage strings.

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `InkIt/CartesiaStreamingClient.swift`
- `InkIt/AudioCaptureService.swift`
- `InkIt/CartesiaKeyValidator.swift`
- `InkItTests/STTFailureRoutingTests.swift`
- `InkIt/AppCoordinator.swift`

---

---
title: "Cartesia STT reference"
description: "CartesiaStreamingClient WebSocket contract: wss://api.cartesia.ai/stt/turns/websocket, model ink-2, encoding pcm_s16le, sample_rate 16000, cartesia_version 2026-03-01, server events, client close message, pending audio buffer, and STTFailure classification table with notchMessage strings."
---

`CartesiaStreamingClient` opens a streaming WebSocket to Cartesia Ink-2 STT on each dictation take. `AppCoordinator` constructs the client with the Keychain-stored Cartesia API key, calls `connect()` at hotkey press, pipes 16 kHz mono PCM from `AudioCaptureService` via `sendAudio(_:)`, and calls `finalizeAndClose()` on release. Transcript updates flow to the notch HUD through `onTranscriptUpdate`; classified failures surface through `onError` as `STTFailure` with `notchMessage` copy.

## WebSocket endpoint

:::endpoint WSS /stt/turns/websocket
Streaming speech-to-text over WebSocket. InkIt targets the Cartesia turns API at `wss://api.cartesia.ai/stt/turns/websocket`.
:::

<ParamField body="model" type="string" required>
Fixed to `ink-2`.
</ParamField>

<ParamField body="encoding" type="string" required>
Fixed to `pcm_s16le` — signed 16-bit little-endian PCM.
</ParamField>

<ParamField body="sample_rate" type="integer" required>
Fixed to `16000`.
</ParamField>

<ParamField body="cartesia_version" type="string" required>
Fixed to `2026-03-01`. Also sent as the `Cartesia-Version` request header.
</ParamField>

### Request headers

| Header | Value |
| --- | --- |
| `X-API-Key` | Cartesia API key from Keychain (`cartesiaAPIKey`) |
| `Cartesia-Version` | `2026-03-01` |

<RequestExample>

```http
GET wss://api.cartesia.ai/stt/turns/websocket?model=ink-2&encoding=pcm_s16le&sample_rate=16000&cartesia_version=2026-03-01 HTTP/1.1
X-API-Key: sk_car_…
Cartesia-Version: 2026-03-01
```

</RequestExample>

<Note>
A rejected WebSocket upgrade (invalid key, out of credits, rate limited) is delivered through `URLSessionWebSocketDelegate.urlSession(_:task:didCompleteWithError:)`, not the receive loop. The HTTP status on `task.response` drives `STTFailure` classification.
</Note>

## Audio payload

InkIt does not send the microphone's native format directly. `AudioCaptureService` taps the input device, and `AudioPCMConverter` resamples to mono `pcm_s16le` at 16 kHz before each chunk reaches the WebSocket.

| Property | Value |
| --- | --- |
| Channels | 1 (mono) |
| Encoding | `pcm_s16le` |
| Sample rate | 16 000 Hz |
| Frame format | Raw binary WebSocket frames |
| Tap buffer size | 1024 frames (device-native rate) |

Each converted chunk is sent as a `.data` frame via `sendAudio(_:)`. Send failures classify through `STTFailure.classify(transportError:response:)`.

## Server events

Cartesia emits JSON text frames. `CartesiaStreamingClient.handleMessage(_:)` parses the `type` field and updates local transcript state.

| Event | InkIt handling |
| --- | --- |
| `connected` | Marks session ready, flushes `pendingAudio`, sends deferred close if hotkey already released |
| `turn.start` | Ignored (no state change) |
| `turn.update` | Replaces `currentTurn` with cumulative in-progress transcript; fires `onTranscriptUpdate` |
| `turn.eager_end` | Same as `turn.update` — cumulative transcript for the in-progress turn |
| `turn.resume` | Ignored — user continued a previously eager-ended turn |
| `turn.end` | Appends final turn text to `completedTurns`, clears `currentTurn`; if `awaitingClose`, completes session |
| `error` | Classifies via `status_code` and `error_code`, routes through `reportFailureOrCollapse` |

<ResponseExample>

```json
{"type":"turn.update","transcript":"hello wor"}
```

</ResponseExample>

<ResponseExample>

```json
{"type":"turn.end","transcript":"hello world"}
```

</ResponseExample>

<ResponseExample>

```json
{"type":"error","status_code":402,"error_code":"quota_exceeded","message":"…"}
```

</ResponseExample>

<Info>
Cartesia emits already-final words per turn. InkIt does not expose partial word-level tokens. The live HUD transcript is `completedTurns` joined with the latest `currentTurn`, space-separated and trimmed.
</Info>

## Client close message

When the user releases the hotkey, `AppCoordinator.stopDictation()` stops audio capture and calls `finalizeAndClose()`. Per the Cartesia STT protocol, the client sends a JSON close frame:

<RequestExample>

```json
{"type":"close"}
```

</RequestExample>

After close is requested:

1. The server processes all buffered audio and emits a final `turn.end` carrying the last word.
2. `awaitingClose` is set so the client completes on that `turn.end` rather than racing the socket close.
3. A fallback timer fires if the server never finishes: **3.0 s** when transcript content exists, **2.0 s** when it does not (silent or too-short press).

If `finalizeAndClose()` runs before the `connected` event, `pendingClose` is set and the close frame is deferred until `handleConnected()` flushes buffered audio first. Audio must be queued before the close frame.

```mermaid
sequenceDiagram
    participant AC as AppCoordinator
    participant CSC as CartesiaStreamingClient
    participant ACS as AudioCaptureService
    participant S as Cartesia STT

    AC->>CSC: connect()
    AC->>ACS: start(onChunk:)
    ACS-->>CSC: sendAudio(pcm chunks)
    Note over CSC: pendingAudio until connected
    S-->>CSC: {"type":"connected"}
    CSC->>S: flush pendingAudio
    S-->>CSC: turn.update / turn.end
    CSC-->>AC: onTranscriptUpdate
    AC->>ACS: stop()
    AC->>CSC: finalizeAndClose()
    CSC->>S: {"type":"close"}
    S-->>CSC: final turn.end
    CSC-->>AC: onClosed(transcript)
```

## Pending audio buffer

URLSession can queue early `send()` calls, but Cartesia may discard binary frames received before the session is fully initialized. InkIt buffers audio client-side until `connected` arrives.

| State | Behavior |
| --- | --- |
| `isConnected == false` | Chunks append to `pendingAudio` |
| `connected` event | `handleConnected()` sends all buffered chunks in order, then sets `isConnected = true` |
| `finalizeAndClose()` before `connected` | Sets `pendingClose`; close frame sent after flush |
| After `connected` | `sendAudio` sends directly on the WebSocket task |

Concurrent `sendAudio` callers are gated by `stateLock` so post-handshake sends cannot race ahead of the flush.

## Session completion paths

`finishClose(reason:reportClosed:)` is atomic — only one completion path fires `onClosed`. `CloseReason` values are recorded in `SessionMetrics` (stored in UserDefaults as `CartesiaCloseMetrics`).

| CloseReason | When |
| --- | --- |
| `finalTurnReceived` | Final `turn.end` after `{"type":"close"}` (happy path) |
| `serverClosed` | Server closed socket before post-close `turn.end` |
| `graceTimerExpired` | Fallback timer fired before server finished |
| `serverError` | Server sent `{"type":"error"}` |
| `silentNoAudio` | Unexplained ending or post-close 500 with no transcript content |
| `receiveFailed` | Receive loop or upgrade failure |
| `externalCancel` | `cancel()` from audio start failure or `setError` |

On error paths, `finishClose` is called with `reportClosed: false` so `onError` does not race with an empty `onClosed` that would reset coordinator state to `.idle` and wipe the notch notice.

## STTFailure classification

`STTFailure` classifies Cartesia `error` events (`status_code` + `error_code`) and transport-level `URLError` / HTTP upgrade failures. It drives the notch island message and, for two cases, persistent Home service-issue flags.

### Classification table

| `STTFailure` | `error_code` signals | HTTP `status_code` signals | Transport `URLError` | `notchMessage` |
| --- | --- | --- | --- | --- |
| `offline` | — | — | `.notConnectedToInternet`, `.networkConnectionLost`, `.cannotConnectToHost`, `.cannotFindHost`, `.dnsLookupFailed`, `.dataNotAllowed` | No internet |
| `serverError` | — | ≥ 500 | `.timedOut` | Server error |
| `rateLimited` | `concurrency_limited` | 429 | — | Too many requests |
| `outOfCredits` | `quota_exceeded`, `plan_upgrade_required` | 402 | — | Out of credits |
| `invalidKey` | — | 401, 403 | — | Invalid API key |
| `unknown` | anything else | unclassified (e.g. 400) | other codes; POSIX `ENOTCONN`/`EPIPE`/`ECONNRESET` | Couldn't transcribe |

### Failure routing (`reportFailureOrCollapse`)

Only **named** failures surface an error notch. `.unknown` never shows an error — it delivers whatever transcript exists or collapses silently.

| Condition | Outcome |
| --- | --- |
| Named failure (`!= .unknown`) | `onError(failure)` → notch shows `notchMessage`; session ends without `onClosed` |
| `.unknown` with transcript content | `onClosed(transcript)` — graceful goodbye |
| `.unknown` with no content | `onClosed("")` → coordinator returns to `.idle`, no error |
| `.serverError` after close requested, no content | Silent collapse (rapid tap-and-release; server returns 500 on ~zero-audio close) |
| `.serverError` mid-hold, no content | Surfaces `Server error` — user may still be speaking |

`STTFailureRoutingTests` locks these contracts: benign POSIX disconnects must classify as `.unknown`, named transport errors stay named, and post-close 500 on silent taps must not alarm the user.

## Coordinator integration

`AppCoordinator` wires the client callbacks during `startDictation()`:

| Callback | Coordinator action |
| --- | --- |
| `onTranscriptUpdate` | Updates `liveTranscript` for notch HUD (suppressed during onboarding trial) |
| `onError` | `handleSTTFailure` → `setError(failure.notchMessage)` |
| `onClosed` | Trims transcript; empty → `.idle`; non-empty → polish → paste or History |

`handleSTTFailure` persists fixable flags:

| Failure | Settings flag |
| --- | --- |
| `invalidKey` | `cartesiaKeyInvalid = true` |
| `outOfCredits` | `cartesiaOutOfCredits = true` |

A successful `onClosed` with non-empty text clears both flags. Errors show immediately mid-hold (via `setError`) and self-clear 1.5 s after hotkey release.

<Warning>
`CartesiaKeyValidator` probes `GET https://api.cartesia.ai/voices?limit=1` over HTTP to distinguish invalid keys from offline machines. That advisory check is separate from the live WebSocket handshake, where a rejected key and an offline host can look similar without the HTTP probe.
</Warning>

## Related pages

<CardGroup>
<Card title="Dictation pipeline" href="/dictation-pipeline">
End-to-end flow from hotkey through audio capture, STT, polish, paste, and history.
</Card>
<Card title="Configure Cartesia API key" href="/configure-cartesia-api-key">
Obtain and store the API key that authenticates the WebSocket.
</Card>
<Card title="STT troubleshooting" href="/stt-troubleshooting">
Diagnose transcription failures, notch surfacing, and regression contracts.
</Card>
<Card title="Input device selection" href="/input-device-selection">
Microphone pinning and the audio-ready HUD cue before PCM reaches the socket.
</Card>
</CardGroup>
