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

# Build Dictation Session Streaming Client

> Tutorial: Build an end-to-end Dictation session streaming client from login to final transcript

<Callout icon="info-circle" color="#FFE148">
  **What you will build**

  <p className="doc-tutorial-meta" aria-label="15 min, Intermediate">
    <Badge size="sm" color="gray" icon="clock">15 min</Badge> |
    <Badge size="sm" color="gray" icon="user-graduate">Intermediate</Badge>
  </p>

  * Login and create a Dictation session
  * Stream raw PCM audio over WebSocket
  * Read partial and final transcript frames
  * End the session and print the final transcript
  * Handle common API errors
</Callout>

<Tip>
  **Using an AI coding tool?**

  Copy the following prompt to add the Suki developer documentation as a skill and [MCP server](/documentation/references/mcp) to your tool for better AI-assisted coding during the integration process. For all AI options (contextual menu, `llms.txt`, and `skill.md`), refer to [AI-optimized documentation](/documentation/references/ai-optimized-documentation).
</Tip>

<Prompt description="Install the Suki developer docs as a skill to get context on Suki's developer tools, APIs, and SDKs. Add the [MCP server](/documentation/references/mcp) for documentation search." icon="gear" iconType="regular" actions={["copy", "cursor"]}>
  Install the Suki developer docs as skill to get context on Suki's developer tools, APIs, and SDKs.

  npx skills add [https://developer.suki.ai](https://developer.suki.ai)

  Then add the Suki developer docs MCP server for access to documentation search. Follow the MCP instructions at [https://developer.suki.ai/documentation/references/mcp](https://developer.suki.ai/documentation/references/mcp).
</Prompt>

In this tutorial, you build an end-to-end Dictation streaming client. You authenticate, create a transcription session, stream PCM audio over a [WebSocket](/documentation/how-to/audio-streaming/dictation-streaming) connection, read partial and final transcript frames, end the session, and print the final transcript from the end response. Examples are provided in Python, TypeScript, and Go coding languages.

By the end of this tutorial, you'll have a working client that connects to the production endpoints **`https://sdp.suki.ai`** and **`wss://sdp.suki.ai/ws/transcribe`**, and handles common [API errors](/api-reference/error-messages).

<span id="prerequisites" />

## Prerequisites

Before you begin, make sure you have:

* Completed [Partner onboarding](/documentation/get-started/partner-onboarding) and received your partner credentials.
* You have **`partner_id`** and **`partner_token`**.
* <Badge color="blue" size="sm">Optional</Badge> **`provider_id`** or **`sdp_provider_id`** if your integration uses Bearer authentication or the Single Auth Token flow.
* An audio file in **LINEAR16 PCM** format (16 kHz, mono, 16-bit little-endian). If you're using a WAV file, remove the WAV header before streaming and send only the raw PCM audio.

<Tip>
  Store your **`partner_token`** only on your backend. Never expose it in a client-side or public application.
</Tip>

<span id="architecture" />

## Architecture

```mermaid actions={false} theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#FFE148','primaryTextColor':'#111827','primaryBorderColor':'#D4A017','lineColor':'#6F5410','secondaryColor':'#FFF394','tertiaryColor':'#FFFADE','tertiaryTextColor':'#111827','tertiaryBorderColor':'#D4A017','arrowheadColor':'#6F5410','fontSize':'14px','edgeLabelBackground':'#FFFADE'}}}%%
flowchart LR
  A[Login] --> B[Create Dictation session]
  B --> C[Open WebSocket]
  C --> D[Stream PCM audio]
  D --> E[Read transcript frames]
  E --> F[End session]
  F --> G[Print final transcript]
```

<span id="project-setup" />

## Project setup

1. Create a script or web page in the language used by this tutorial (Python, Go, or TypeScript).
2. Install the required dependencies:
   <Callout icon="gear" color="#929292">
     * **Python:** `pip install requests websocket-client`

     * **Go:** `go get github.com/gorilla/websocket`

     * **TypeScript (browser):** No additional packages are required. The example uses the built-in `fetch` and `WebSocket` APIs.
   </Callout>
3. Configure your credentials:

   <Callout icon="gear" color="#929292">
     * Add your **`partner_id`**.
     * Store **`partner_token`** on your backend only. Never expose it in browser code.
     * Add **`provider_id`** or **`sdp_provider_id`** only if your integration requires them.
   </Callout>
4. Prepare an audio file in **LINEAR16 PCM** format or a WAV file. If you use a WAV file, remove the 44-byte WAV header before streaming the PCM audio.

<span id="how-websocket-authentication-works-in-this-tutorial" />

## How WebSocket authentication works in this tutorial

| Language             | How to authenticate                                                                                    |
| :------------------- | :----------------------------------------------------------------------------------------------------- |
| Python or Go         | Send `sdp_suki_token` and `transcription_session_id` as HTTP headers (`sdp_provider_id` when required) |
| TypeScript (browser) | Send `Sec-WebSocket-Protocol`: `SukiAmbientAuth,<sdp_suki_token>,<transcription_session_id>`           |

<Note>
  **Important points to remember:**

  * If login fails because the provider is new, [Register provider](/api-reference/authentication/register) once, then log in again.
  * Open **`/ws/transcribe`** only when the session is **`READY`** or **`IDLE`**.
  * Use Dictation frame types on this endpoint. Do not send ambient **`START_TIME`**, **`RU9G`**, or ambient **`AUDIO`** `data` frames.
  * Browser protocol order for Dictation is **token, then session ID**.
</Note>

<span id="tutorial-steps" />

## Tutorial steps

Work through each step in order. Read the explanation, review the code example, then continue to the next step. The full code example is available in the accordion below.

<Steps>
  <Step title="Log In">
    Call the [Login API](/api-reference/authentication/login) with your partner credentials to receive a `suki_token`. Send that token later as `sdp_suki_token`. If the provider is new, register once, then log in again.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def login(partner_id: str, partner_token: str, provider_id: Optional[str] = None) -> str:
      url = f"{BASE_URL}/api/v1/auth/login"
      payload: dict[str, Any] = {
          "partner_id": partner_id,
          "partner_token": partner_token,
      }
      if provider_id:
          payload["provider_id"] = provider_id
      r = requests.post(url, json=payload, timeout=60)
      expect_status(r, url, 200)
      return r.json()["suki_token"]

      token = login_with_register_fallback(
      PARTNER_ID, PARTNER_TOKEN, PROVIDER_ID, "Dr. John Smith", "org-123"  # Example provider name / org only
      )
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      async function login(partnerId: string, partnerToken: string, providerId?: string) {
      const url = `${BASE_URL}/api/v1/auth/login`;
      const body: Record<string, string> = {
      partner_id: partnerId,
      partner_token: partnerToken,
      };
      if (providerId) body.provider_id = providerId;
      const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      });
      await expectStatus(response, url, 200);
      const data = await response.json();
      return data.suki_token as string;
      }

      const token = await loginWithRegisterFallback(
      partnerId, partnerToken, providerId, 'Dr. John Smith', 'org-123' // Example provider name / org only
      );
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      func login(partnerID, partnerToken, providerID string) (string, error) {
      url := baseURL + "/api/v1/auth/login"
      body := map[string]string{
      	"partner_id": partnerID, "partner_token": partnerToken,
      }
      if providerID != "" {
      	body["provider_id"] = providerID
      }
      raw, _ := json.Marshal(body)
      resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
      if err != nil {
      	return "", err
      }
      defer resp.Body.Close()
      b, _ := io.ReadAll(resp.Body)
      if resp.StatusCode != http.StatusOK {
      	return "", fmt.Errorf("login failed: HTTP %d: %s", resp.StatusCode, string(b))
      }
      var out struct {
      	SukiToken string `json:"suki_token"`
      }
      if err := json.Unmarshal(b, &out); err != nil {
      	return "", err
      }
      if out.SukiToken == "" {
      	return "", fmt.Errorf("login response missing suki_token")
      }
      return out.SukiToken, nil
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Create a Dictation Session">
    Create a session with the [Create Dictation session](/api-reference/audio-transcription/create-session) API. A successful response returns `transcription_session_id` (HTTP 201).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def create_dictation_session(suki_token: str, body: Optional[dict[str, Any]] = None) -> str:
      url = f"{BASE_URL}/api/v1/transcription/session/create"
      payload = body if body is not None else {
          "audio_config": {
              "audio_encoding": "LINEAR16",
              "audio_language": "en-US",
              "sample_rate_hertz": 16000,
          }
      }
      r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
      expect_status(r, url, 201)
      return r.json()["transcription_session_id"]

      transcription_session_id = create_dictation_session(token)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      async function createDictationSession(sukiToken: string, body?: Record<string, unknown>) {
      const url = `${BASE_URL}/api/v1/transcription/session/create`;
      const payload = body ?? {
      audio_config: {
        audio_encoding: 'LINEAR16',
        audio_language: 'en-US',
        sample_rate_hertz: 16000,
      },
      };
      const response = await fetch(url, {
      method: 'POST',
      headers: restHeaders(sukiToken),
      body: JSON.stringify(payload),
      });
      await expectStatus(response, url, 201);
      const data = await response.json();
      return data.transcription_session_id as string;
      }

      const transcriptionSessionId = await createDictationSession(token);
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      func createDictationSession(sukiToken string, body map[string]any) (string, error) {
      var out createSessionResponse
      err := doJSON(http.MethodPost, baseURL+"/api/v1/transcription/session/create",
      	sukiToken, body, http.StatusCreated, &out)
      if err != nil {
      	return "", err
      }
      return out.TranscriptionSessionID, nil
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Open the WebSocket">
    Open [`wss://sdp.suki.ai/ws/transcribe`](/documentation/how-to/audio-streaming/dictation-streaming) only when the session is **`READY`** or **`IDLE`**. Authenticate with headers (Python/Go) or `Sec-WebSocket-Protocol` (browser).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      ws_headers = [
      f"sdp_suki_token: {suki_token}",
      f"transcription_session_id: {transcription_session_id}",
      ]
      if SDP_PROVIDER_ID:
      ws_headers.append(f"sdp_provider_id: {SDP_PROVIDER_ID}")
      ws = websocket.create_connection(WS_URL, header=ws_headers, timeout=60)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      const ws = new WebSocket(WS_URL, [
      `SukiAmbientAuth,${sukiToken},${transcriptionSessionId}`,
      ]);
      ws.binaryType = 'arraybuffer';
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      header := http.Header{}
      header.Set("sdp_suki_token", sukiToken)
      header.Set("transcription_session_id", transcriptionSessionID)
      if sdpProviderID != "" {
      header.Set("sdp_provider_id", sdpProviderID)
      }
      conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
      ```
    </CodeGroup>
  </Step>

  <Step title="Stream Audio">
    Send JSON **`AUDIO`** messages with base64 PCM in **`audioData`**. When finished, send **`EVENT`** / **`AUDIO_END`**. Do not send binary frames or ambient **`RU9G`**.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      for i in range(0, len(pcm), CHUNK_BYTES):
      chunk = pcm[i : i + CHUNK_BYTES]
      send_json(ws, {"type": "AUDIO", "audioData": b64(chunk)})

      send_json(ws, {"type": "EVENT", "event": "AUDIO_END"})
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      for (let i = 0; i < pcm.byteLength; i += CHUNK_BYTES) {
      const chunk = pcm.slice(i, i + CHUNK_BYTES);
      ws.send(JSON.stringify({ type: 'AUDIO', audioData: bytesToBase64(chunk) }));
      }
      ws.send(JSON.stringify({ type: 'EVENT', event: 'AUDIO_END' }));
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      for i := 0; i < len(pcm); i += chunkBytes {
      end := i + chunkBytes
      if end > len(pcm) {
      	end = len(pcm)
      }
      _ = sendJSON(conn, map[string]any{
      	"type": "AUDIO", "audioData": b64(pcm[i:end]),
      })
      }
      _ = sendJSON(conn, map[string]any{"type": "EVENT", "event": "AUDIO_END"})
      ```
    </CodeGroup>
  </Step>

  <Step title="Read Transcript Frames">
    Read inbound frames until **`EOF`**. Collect finals where **`is_final`** is true. See [Read Dictation transcript frames](/documentation/how-to/audio-streaming/dictation-streaming-transcripts).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      while True:
      raw = ws.recv()
      payload = json.loads(raw)
      transcript_obj = payload.get("transcript") or {}
      text = transcript_obj.get("transcript") if isinstance(transcript_obj, dict) else None
      if text == "EOF":
          break
      if payload.get("is_final") and text:
          finals.append(str(text))
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      ws.onmessage = (event) => {
      const payload = JSON.parse(String(event.data));
      const text = payload?.transcript?.transcript;
      if (text === 'EOF') {
      ws.close();
      return;
      }
      if (payload.is_final && text) finals.push(String(text));
      };
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      for {
      _, msg, err := conn.ReadMessage()
      if err != nil {
      	break
      }
      var frame inboundTranscript
      _ = json.Unmarshal(msg, &frame)
      text := ""
      if frame.Transcript != nil {
      	text = frame.Transcript.Transcript
      }
      if text == "EOF" {
      	break
      }
      if frame.IsFinal && text != "" {
      	finals = append(finals, text)
      }
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="End the Session and Get the Transcript">
    Close the WebSocket, then call [End Dictation session](/api-reference/audio-transcription/end-session). Use **`final_transcript`** from the response.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def end_dictation_session(suki_token: str, transcription_session_id: str) -> dict[str, Any]:
      url = f"{BASE_URL}/api/v1/transcription/session/{transcription_session_id}/end"
      r = requests.post(url, headers=rest_headers(suki_token), timeout=60)
      expect_status(r, url, 200)
      return r.json()

      end_body = end_dictation_session(token, transcription_session_id)
      print(end_body["final_transcript"])
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      async function endDictationSession(sukiToken: string, transcriptionSessionId: string) {
      const url = `${BASE_URL}/api/v1/transcription/session/${transcriptionSessionId}/end`;
      const response = await fetch(url, { method: 'POST', headers: restHeaders(sukiToken) });
      await expectStatus(response, url, 200);
      return response.json();
      }

      const endBody = await endDictationSession(token, transcriptionSessionId);
      console.log(endBody.final_transcript);
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      func endDictationSession(sukiToken, transcriptionSessionID string) (*endSessionResponse, error) {
      var out endSessionResponse
      err := doJSON(http.MethodPost,
      	fmt.Sprintf("%s/api/v1/transcription/session/%s/end", baseURL, transcriptionSessionID),
      	sukiToken, nil, http.StatusOK, &out)
      return &out, err
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Handle API Errors">
    Map REST failures using documented **`code`**, **`message`**, and **`details`**. See [API error messages](/api-reference/error-messages).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def hint_for_error(err: SukiApiError) -> str:
      if err.matches("invalid_sdp_token"):
          return "Missing, expired, or invalid sdp_suki_token. Call login again."
      if err.matches("provider_not_registered"):
          return "Provider is not registered. Call register once, then login again."
      if err.http_status == 412:
          return "Open /ws/transcribe only when the session is READY or IDLE."
      return "See /api-reference/error-messages"
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      function hintForError(err: SukiApiError): string {
      if (err.matches('invalid_sdp_token')) {
      return 'Missing, expired, or invalid sdp_suki_token. Call login again.';
      }
      if (err.matches('provider_not_registered')) {
      return 'Provider is not registered. Call register once, then login again.';
      }
      if (err.httpStatus === 412) {
      return 'Open /ws/transcribe only when the session is READY or IDLE.';
      }
      return 'See /api-reference/error-messages';
      }
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      func hintForError(err *SukiApiError) string {
      if err.Matches("invalid_sdp_token") {
      	return "Missing, expired, or invalid sdp_suki_token. Call login again."
      }
      if err.Matches("provider_not_registered") {
      	return "Provider is not registered. Call register once, then login again."
      }
      if err.HTTPStatus == 412 {
      	return "Open /ws/transcribe only when the session is READY or IDLE."
      }
      return "See /api-reference/error-messages"
      }
      ```
    </CodeGroup>
  </Step>
</Steps>

<span id="full-example" />

## Full code example

<div className="doc-tutorial-full-code" data-doc-tutorial-full-code data-active-lang="python">
  <Columns cols={3}>
    <Tile href="#full-example-python">
      <span className="doc-tutorial-tile-lang-icon" data-icon="python" aria-hidden="true" />
    </Tile>

    <Tile href="#full-example-typescript">
      <span className="doc-tutorial-tile-lang-icon" data-icon="typescript" aria-hidden="true" />
    </Tile>

    <Tile href="#full-example-go">
      <span className="doc-tutorial-tile-lang-icon" data-icon="go" aria-hidden="true" />
    </Tile>
  </Columns>

  <div id="full-example-python" className="doc-tutorial-lang-panel is-active" data-lang-panel="python">
    ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    # Dictation streaming tutorial (Python)
    # Flow: login → create session → stream PCM over WebSocket → read frames to EOF → end session
    # Install: pip install requests websocket-client

    import base64
    import json
    from typing import Any, Optional

    import requests
    import websocket

    # ---------------------------------------------------------------------------
    # Endpoints and streaming defaults
    # ---------------------------------------------------------------------------
    BASE_URL = "https://sdp.suki.ai"              # REST API host
    WS_URL = "wss://sdp.suki.ai/ws/transcribe"    # Dictation WebSocket stream
    CHUNK_BYTES = 3200                            # ~100 ms of 16 kHz mono 16-bit PCM
    WAV_HEADER_BYTES = 44                         # Standard WAV header size to strip

    # ---------------------------------------------------------------------------
    # Replace these with your partner credentials
    # ---------------------------------------------------------------------------
    PARTNER_ID = "your-partner-id"  # Example only — replace with your partner ID
    PARTNER_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."  # Example only — replace; keep on backend only
    PROVIDER_ID = "provider-123"  # Example only — optional; required for Bearer / Single Auth Token partners
    SDP_PROVIDER_ID = ""  # Optional. Leave empty unless your partnership requires sdp_provider_id


    # ---------------------------------------------------------------------------
    # Error helpers: map REST failures to the documented code / message / details shape
    # ---------------------------------------------------------------------------
    class SukiApiError(RuntimeError):
        """REST failure with the documented {code, message, details} body when present."""

        def __init__(
            self,
            http_status: int,
            url: str,
            *,
            code: Any = None,
            message: str = "",
            details: Any = None,
            raw_body: str = "",
        ) -> None:
            self.http_status = http_status
            self.url = url
            self.code = code
            self.message = message or ""
            self.details = details if details is not None else []
            self.raw_body = raw_body
            super().__init__(
                f"HTTP {http_status} {url}: code={code!r} message={self.message!r}"
            )

        def matches(self, error_id: str) -> bool:
            # Docs: match error id exactly or as a prefix (for example invalid_sdp_token: …).
            m = self.message.strip()
            return m == error_id or m.startswith(error_id + ":") or m.startswith(error_id + " ")


    def parse_suki_error_body(raw: str) -> tuple[Any, str, Any]:
        try:
            data = json.loads(raw) if raw else {}
        except json.JSONDecodeError:
            return None, (raw or "")[:500], []
        if not isinstance(data, dict):
            return None, (raw or "")[:500], []
        message = data.get("message")
        return data.get("code"), str(message) if message is not None else "", data.get("details") or []


    def raise_suki_api_error(response: requests.Response, url: str) -> None:
        raw = response.text or ""
        code, message, details = parse_suki_error_body(raw)
        raise SukiApiError(
            response.status_code,
            url,
            code=code,
            message=message,
            details=details,
            raw_body=raw,
        )


    def expect_status(response: requests.Response, url: str, want: int) -> None:
        if response.status_code != want:
            raise_suki_api_error(response, url)


    def hint_for_error(err: SukiApiError) -> str:
        if err.matches("invalid_sdp_token") or "invalid sdp token" in err.message.lower():
            return "Missing, expired, or invalid sdp_suki_token. Call login again."
        if err.matches("provider_not_registered"):
            return "Provider is not registered. Call register once, then login again."
        if err.matches("transcription_session_not_accepting") or "not accepting new speech" in err.message.lower():
            return (
                "Session is not READY/IDLE. Wait until the session accepts a new speech "
                "session, or end the parent session if you are done."
            )
        if err.http_status == 401:
            return "Unauthorized. Check partner credentials and sdp_suki_token."
        if err.http_status == 412:
            return "FailedPrecondition. Open /ws/transcribe only when the session is READY or IDLE."
        if err.http_status >= 500:
            return "Server error. Retry with backoff; see API error messages."
        return "See /api-reference/error-messages"


    def b64(data: bytes) -> str:
        return base64.b64encode(data).decode("ascii")


    def rest_headers(suki_token: str) -> dict[str, str]:
        headers = {
            "sdp_suki_token": suki_token,
            "Content-Type": "application/json",
        }
        # Include only when your partnership requires it (leave SDP_PROVIDER_ID empty otherwise).
        if SDP_PROVIDER_ID:
            headers["sdp_provider_id"] = SDP_PROVIDER_ID
        return headers


    # ---------------------------------------------------------------------------
    # Authentication
    # ---------------------------------------------------------------------------
    def login(partner_id: str, partner_token: str, provider_id: Optional[str] = None) -> str:
        """Exchange partner credentials for a Suki token (send later as sdp_suki_token)."""
        url = f"{BASE_URL}/api/v1/auth/login"
        payload: dict[str, Any] = {
            "partner_id": partner_id,
            "partner_token": partner_token,
        }
        if provider_id:
            payload["provider_id"] = provider_id
        r = requests.post(url, json=payload, timeout=60)
        expect_status(r, url, 200)
        token = r.json().get("suki_token")
        if not token:
            raise SukiApiError(200, url, message="login response missing suki_token")
        return token


    def register_provider(
        partner_id: str,
        partner_token: str,
        provider_name: str,
        provider_org_id: str,
        provider_id: Optional[str] = None,
        provider_specialty: str = "CARDIOLOGY",
    ) -> None:
        """POST /api/v1/auth/register (one-time)."""
        url = f"{BASE_URL}/api/v1/auth/register"
        payload: dict[str, Any] = {
            "partner_id": partner_id,
            "partner_token": partner_token,
            "provider_name": provider_name,
            "provider_org_id": provider_org_id,
            "provider_specialty": provider_specialty,
        }
        if provider_id:
            payload["provider_id"] = provider_id
        r = requests.post(url, json=payload, timeout=60)
        if r.status_code not in (201, 409):
            raise_suki_api_error(r, url)


    def login_with_register_fallback(
        partner_id: str,
        partner_token: str,
        provider_id: Optional[str],
        provider_name: str,
        provider_org_id: str,
    ) -> str:
        try:
            return login(partner_id, partner_token, provider_id)
        except SukiApiError as err:
            # Only register when the API indicates an unregistered provider.
            if err.matches("provider_not_registered"):
                print(hint_for_error(err))
                register_provider(
                    partner_id,
                    partner_token,
                    provider_name=provider_name,
                    provider_org_id=provider_org_id,
                    provider_id=provider_id,
                )
                return login(partner_id, partner_token, provider_id)
            print(hint_for_error(err))
            raise


    # ---------------------------------------------------------------------------
    # Dictation session setup
    # ---------------------------------------------------------------------------
    def create_dictation_session(suki_token: str, body: Optional[dict[str, Any]] = None) -> str:
        """Create a Dictation session and return transcription_session_id (HTTP 201)."""
        url = f"{BASE_URL}/api/v1/transcription/session/create"
        # Optional audio_config matches LINEAR16 / en-US / 16000 PCM you will stream.
        payload = body if body is not None else {
            "audio_config": {
                "audio_encoding": "LINEAR16",
                "audio_language": "en-US",
                "sample_rate_hertz": 16000,
            }
        }
        r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
        expect_status(r, url, 201)
        sid = r.json().get("transcription_session_id")
        if not sid:
            raise SukiApiError(201, url, message="create response missing transcription_session_id")
        return sid


    def strip_wav_header(raw: bytes) -> bytes:
        """If the file is WAV, drop the 44-byte header and keep only raw PCM bytes."""
        if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WAVE":
            return raw[WAV_HEADER_BYTES:]
        return raw


    def send_json(ws: websocket.WebSocket, obj: dict[str, Any]) -> None:
        ws.send(json.dumps(obj))


    # ---------------------------------------------------------------------------
    # WebSocket streaming (Python / Go style: auth headers, not Sec-WebSocket-Protocol)
    # ---------------------------------------------------------------------------
    def stream_pcm_file(
        suki_token: str,
        transcription_session_id: str,
        pcm_or_wav_path: str,
    ) -> list[str]:
        """
        Open /ws/transcribe, stream LINEAR16 PCM chunks as AUDIO/audioData, then AUDIO_END.
        After AUDIO_END, recv until EOF or close. Return collected is_final transcript strings.
        Auth uses HTTP headers: sdp_suki_token + transcription_session_id (+ sdp_provider_id when required).
        """
        try:
            ws = websocket.create_connection(
                WS_URL,
                header=(
                    [
                        f"sdp_suki_token: {suki_token}",
                        f"transcription_session_id: {transcription_session_id}",
                    ]
                    + ([f"sdp_provider_id: {SDP_PROVIDER_ID}"] if SDP_PROVIDER_ID else [])
                ),
                timeout=60,
            )
        except websocket.WebSocketException as err:
            raise RuntimeError(f"WebSocket connect failed: {err}") from err

        finals: list[str] = []
        try:
            with open(pcm_or_wav_path, "rb") as f:
                pcm = strip_wav_header(f.read())

            # Send ~100 ms JSON AUDIO frames (base64 PCM in audioData). Do not send binary frames.
            for i in range(0, len(pcm), CHUNK_BYTES):
                chunk = pcm[i : i + CHUNK_BYTES]
                send_json(ws, {"type": "AUDIO", "audioData": b64(chunk)})

            # Explicit end-of-audio for this speech session (not RU9G / Ambient data field).
            send_json(ws, {"type": "EVENT", "event": "AUDIO_END"})

            # Allow time for partial/final frames and EOF after AUDIO_END (connect used a short timeout).
            ws.settimeout(300)

            # Read inbound frames until transcript.transcript == "EOF" or the socket closes.
            while True:
                try:
                    raw = ws.recv()
                except websocket.WebSocketConnectionClosedException:
                    break
                except Exception as err:
                    # websocket-client may raise WebSocketTimeoutException or a socket timeout.
                    name = type(err).__name__
                    if name in ("WebSocketTimeoutException", "TimeoutError") or isinstance(
                        err, TimeoutError
                    ):
                        raise RuntimeError(
                            "Timed out waiting for Dictation EOF after AUDIO_END"
                        ) from err
                    raise
                if raw is None:
                    break
                if isinstance(raw, bytes):
                    raw = raw.decode("utf-8", errors="replace")
                try:
                    payload = json.loads(raw)
                except json.JSONDecodeError:
                    print("Non-JSON frame:", raw[:200])
                    continue

                transcript_obj = payload.get("transcript") or {}
                text = transcript_obj.get("transcript") if isinstance(transcript_obj, dict) else None
                if text == "EOF":
                    break
                if payload.get("is_final") and text:
                    finals.append(str(text))
                    print("Final:", text)
                elif text:
                    print("Partial:", text)
        except (OSError, websocket.WebSocketException) as err:
            raise RuntimeError(f"WebSocket stream failed: {err}") from err
        finally:
            try:
                ws.close()
            except Exception:
                pass
        return finals


    # ---------------------------------------------------------------------------
    # End session over REST (final_transcript is in the end response; no separate poll APIs)
    # ---------------------------------------------------------------------------
    def end_dictation_session(suki_token: str, transcription_session_id: str) -> dict[str, Any]:
        """End the Dictation session after the WebSocket stream is closed. Returns end response body."""
        url = f"{BASE_URL}/api/v1/transcription/session/{transcription_session_id}/end"
        r = requests.post(url, headers=rest_headers(suki_token), timeout=60)
        expect_status(r, url, 200)
        return r.json()


    # ---------------------------------------------------------------------------
    # Run the full flow end to end
    # ---------------------------------------------------------------------------
    if __name__ == "__main__":
        try:
            # 1) Authenticate (register once if the provider is new).
            suki_token = login_with_register_fallback(
                PARTNER_ID,
                PARTNER_TOKEN,
                PROVIDER_ID,
                provider_name="Dr. John Smith",  # Example only — replace with the provider's name
                provider_org_id="org-123",  # Example only — replace with your org ID
            )
            # 2) Create the Dictation session (optional audio_config included above).
            transcription_session_id = create_dictation_session(suki_token)
            # 3) Stream audio, collect finals from the socket, then end and print final_transcript.
            live_finals = stream_pcm_file(suki_token, transcription_session_id, "audio.wav")  # Example path — replace with your PCM/WAV file
            end_body = end_dictation_session(suki_token, transcription_session_id)
            print("Live finals:", live_finals)
            print("End response:", end_body)
            # end_body includes: transcription_session_id, status, final_transcript, duration, ended_at
        except SukiApiError as err:
            print(hint_for_error(err))
            raise
    ```
  </div>

  <div id="full-example-typescript" className="doc-tutorial-lang-panel" data-lang-panel="typescript" hidden>
    ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    // Dictation streaming tutorial (TypeScript / browser)
    // Flow: login → create session → stream PCM over WebSocket → read frames to EOF → end session
    // Browser WebSocket auth uses Sec-WebSocket-Protocol (not HTTP headers).
    // Protocol order for Dictation: SukiAmbientAuth,<sdp_suki_token>,<transcription_session_id>
    // Prefer running login / create on your backend so partner_token never ships to the client.

    // ---------------------------------------------------------------------------
    // Endpoints and streaming defaults
    // ---------------------------------------------------------------------------
    const BASE_URL = 'https://sdp.suki.ai'; // REST API host
    const WS_URL = 'wss://sdp.suki.ai/ws/transcribe'; // Dictation WebSocket stream
    const CHUNK_BYTES = 3200; // ~100 ms of 16 kHz mono 16-bit PCM
    const WAV_HEADER_BYTES = 44; // Standard WAV header size to strip

    // ---------------------------------------------------------------------------
    // Replace these with your partner credentials
    // ---------------------------------------------------------------------------
    const partnerId = 'your-partner-id'; // Example only — replace with your partner ID
    const partnerToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'; // Example only — replace; keep on backend only
    const providerId = 'provider-123'; // Example only — optional; required for Bearer / Single Auth Token partners
    const sdpProviderId = ''; // Optional. Leave empty unless your partnership requires sdp_provider_id

    // ---------------------------------------------------------------------------
    // Error helpers: map REST failures to the documented code / message / details shape
    // ---------------------------------------------------------------------------
    /** REST failure with documented { code, message, details } when present. */
    class SukiApiError extends Error {
      httpStatus: number;
      url: string;
      code: unknown;
      messageText: string;
      details: unknown;
      rawBody: string;

      constructor(
        httpStatus: number,
        url: string,
        opts: { code?: unknown; message?: string; details?: unknown; rawBody?: string } = {},
      ) {
        super(
          `HTTP ${httpStatus} ${url}: code=${String(opts.code)} message=${opts.message ?? ''}`,
        );
        this.name = 'SukiApiError';
        this.httpStatus = httpStatus;
        this.url = url;
        this.code = opts.code;
        this.messageText = opts.message ?? '';
        this.details = opts.details ?? [];
        this.rawBody = opts.rawBody ?? '';
      }

      /** Match error id exactly or as a prefix (see API error messages). */
      matches(errorId: string): boolean {
        const m = this.messageText.trim();
        return m === errorId || m.startsWith(`${errorId}:`) || m.startsWith(`${errorId} `);
      }
    }

    function parseSukiErrorBody(raw: string): {
      code?: unknown;
      message: string;
      details: unknown;
    } {
      try {
        const data = raw ? JSON.parse(raw) : {};
        if (data && typeof data === 'object' && !Array.isArray(data)) {
          return {
            code: (data as any).code,
            message: (data as any).message != null ? String((data as any).message) : '',
            details: (data as any).details ?? [],
          };
        }
      } catch {
        // non-JSON body
      }
      return { message: raw.slice(0, 500), details: [] };
    }

    async function expectStatus(
      response: Response,
      url: string,
      want: number,
    ): Promise<void> {
      if (response.status === want) return;
      const rawBody = await response.text();
      const parsed = parseSukiErrorBody(rawBody);
      throw new SukiApiError(response.status, url, {
        code: parsed.code,
        message: parsed.message,
        details: parsed.details,
        rawBody,
      });
    }

    function hintForError(err: SukiApiError): string {
      if (
        err.matches('invalid_sdp_token') ||
        err.messageText.toLowerCase().includes('invalid sdp token')
      ) {
        return 'Missing, expired, or invalid sdp_suki_token. Call login again.';
      }
      if (err.matches('provider_not_registered')) {
        return 'Provider is not registered. Call register once, then login again.';
      }
      if (
        err.matches('transcription_session_not_accepting') ||
        err.messageText.toLowerCase().includes('not accepting new speech')
      ) {
        return (
          'Session is not READY/IDLE. Wait until the session accepts a new speech ' +
          'session, or end the parent session if you are done.'
        );
      }
      if (err.httpStatus === 401) {
        return 'Unauthorized. Check partner credentials and sdp_suki_token.';
      }
      if (err.httpStatus === 412) {
        return 'FailedPrecondition. Open /ws/transcribe only when the session is READY or IDLE.';
      }
      if (err.httpStatus >= 500) {
        return 'Server error. Retry with backoff; see API error messages.';
      }
      return 'See /api-reference/error-messages';
    }

    function restHeaders(sukiToken: string): HeadersInit {
      const headers: Record<string, string> = {
        sdp_suki_token: sukiToken,
        'Content-Type': 'application/json',
      };
      // Include only when your partnership requires it (leave sdpProviderId empty otherwise).
      if (sdpProviderId) headers.sdp_provider_id = sdpProviderId;
      return headers;
    }

    // ---------------------------------------------------------------------------
    // Authentication
    // ---------------------------------------------------------------------------
    /** Exchange partner credentials for a Suki token (send later as sdp_suki_token). */
    async function login(
      partnerId: string,
      partnerToken: string,
      providerId?: string,
    ): Promise<string> {
      const url = `${BASE_URL}/api/v1/auth/login`;
      const body: Record<string, string> = {
        partner_id: partnerId,
        partner_token: partnerToken,
      };
      if (providerId) body.provider_id = providerId;

      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      await expectStatus(response, url, 200);
      const data = await response.json();
      if (!data.suki_token) {
        throw new SukiApiError(200, url, { message: 'login response missing suki_token' });
      }
      return data.suki_token as string;
    }

    /** POST /api/v1/auth/register (one-time). */
    async function registerProvider(input: {
      partnerId: string;
      partnerToken: string;
      providerName: string;
      providerOrgId: string;
      providerId?: string;
      providerSpecialty?: string;
    }): Promise<void> {
      const url = `${BASE_URL}/api/v1/auth/register`;
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          partner_id: input.partnerId,
          partner_token: input.partnerToken,
          provider_name: input.providerName,
          provider_org_id: input.providerOrgId,
          provider_id: input.providerId,
          provider_specialty: input.providerSpecialty ?? 'CARDIOLOGY',
        }),
      });
      if (response.status !== 201 && response.status !== 409) {
        await expectStatus(response, url, 201);
      }
    }

    async function loginWithRegisterFallback(
      partnerId: string,
      partnerToken: string,
      providerId: string | undefined,
      providerName: string,
      providerOrgId: string,
    ): Promise<string> {
      try {
        return await login(partnerId, partnerToken, providerId);
      } catch (err) {
        if (err instanceof SukiApiError && err.matches('provider_not_registered')) {
          console.warn(hintForError(err));
          await registerProvider({
            partnerId,
            partnerToken,
            providerName,
            providerOrgId,
            providerId,
          });
          return login(partnerId, partnerToken, providerId);
        }
        if (err instanceof SukiApiError) console.warn(hintForError(err));
        throw err;
      }
    }

    // ---------------------------------------------------------------------------
    // Dictation session setup
    // ---------------------------------------------------------------------------
    /** Create a Dictation session and return transcription_session_id (HTTP 201). */
    async function createDictationSession(
      sukiToken: string,
      body: {
        transcription_session_id?: string;
        audio_config?: {
          audio_encoding?: string;
          audio_language?: string;
          sample_rate_hertz?: number;
        };
      } = {
        audio_config: {
          audio_encoding: 'LINEAR16',
          audio_language: 'en-US',
          sample_rate_hertz: 16000,
        },
      },
    ): Promise<string> {
      const url = `${BASE_URL}/api/v1/transcription/session/create`;
      const response = await fetch(url, {
        method: 'POST',
        headers: restHeaders(sukiToken),
        body: JSON.stringify(body),
      });
      await expectStatus(response, url, 201);
      const data = await response.json();
      if (!data.transcription_session_id) {
        throw new SukiApiError(201, url, {
          message: 'create response missing transcription_session_id',
        });
      }
      return data.transcription_session_id as string;
    }

    function bytesToBase64(u8: Uint8Array): string {
      let binary = '';
      for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]!);
      return btoa(binary);
    }

    /** If the file is WAV, drop the 44-byte header and keep only raw PCM bytes. */
    function stripWavHeader(buf: Uint8Array): Uint8Array {
      if (
        buf.length >= 12 &&
        buf[0] === 0x52 &&
        buf[1] === 0x49 &&
        buf[2] === 0x46 &&
        buf[3] === 0x46 &&
        buf[8] === 0x57 &&
        buf[9] === 0x41 &&
        buf[10] === 0x56 &&
        buf[11] === 0x45
      ) {
        return buf.subarray(WAV_HEADER_BYTES);
      }
      return buf;
    }

    type TranscriptFrame = {
      transcript?: { transcript?: string; words?: unknown[] };
      is_final?: boolean;
      transcript_id?: string;
    };

    type EndSessionResponse = {
      transcription_session_id: string;
      status: string;
      final_transcript: string;
      duration: number;
      ended_at: string;
    };

    // ---------------------------------------------------------------------------
    // WebSocket streaming (browser: Sec-WebSocket-Protocol auth)
    // ---------------------------------------------------------------------------
    /**
     * Browser stream client for Dictation.
     * Authenticate with: Sec-WebSocket-Protocol = SukiAmbientAuth,<sdp_suki_token>,<transcription_session_id>
     * (token, then session ID — same order as Ambient).
     * Sends JSON AUDIO chunks with audioData, then EVENT AUDIO_END. Collects is_final frames until EOF.
     */
    function createDictationStreamClient(sukiToken: string, transcriptionSessionId: string) {
      let pcmBuffer = new Uint8Array(0);
      const pending: string[] = [];
      let closing = false;
      let sawEof = false;
      const finals: string[] = [];
      let onSocketClosed: (() => void) | undefined;
      let onEof: ((finals: string[]) => void) | undefined;
      let lastSocketError: Event | undefined;

      // Dictation protocol order: token, then transcription_session_id.
      const ws = new WebSocket(WS_URL, [
        `SukiAmbientAuth,${sukiToken},${transcriptionSessionId}`,
      ]);
      ws.binaryType = 'arraybuffer';

      function flushPending() {
        if (ws.readyState !== WebSocket.OPEN) return;
        while (pending.length > 0) ws.send(pending.shift()!);
      }

      function sendJson(obj: Record<string, unknown>) {
        pending.push(JSON.stringify(obj));
        flushPending();
      }

      function pushPcm(chunk: Uint8Array) {
        // Buffer live PCM and flush ~100 ms JSON AUDIO frames (audioData). Do not send binary frames.
        if (closing) return;
        const merged = new Uint8Array(pcmBuffer.length + chunk.length);
        merged.set(pcmBuffer, 0);
        merged.set(chunk, pcmBuffer.length);
        pcmBuffer = merged;
        while (pcmBuffer.length >= CHUNK_BYTES) {
          const frame = pcmBuffer.slice(0, CHUNK_BYTES);
          pcmBuffer = pcmBuffer.slice(CHUNK_BYTES);
          sendJson({ type: 'AUDIO', audioData: bytesToBase64(frame) });
        }
      }

      function closeWhenDrained(onClosed?: () => void) {
        onSocketClosed = onClosed;
        const tick = () => {
          if (ws.readyState === WebSocket.CLOSED) {
            const cb = onSocketClosed;
            onSocketClosed = undefined;
            cb?.();
            return;
          }
          if (ws.readyState === WebSocket.CLOSING) {
            setTimeout(tick, 20);
            return;
          }
          flushPending();
          if (
            pending.length > 0 ||
            ws.readyState === WebSocket.CONNECTING ||
            ws.bufferedAmount > 0
          ) {
            setTimeout(tick, 20);
            return;
          }
          // Prefer waiting for EOF from the server; close only if still open after drain.
          if (ws.readyState === WebSocket.OPEN && sawEof) ws.close();
          else if (ws.readyState === WebSocket.OPEN) setTimeout(tick, 50);
        };
        tick();
      }

      function endStream(onClosed?: () => void) {
        // Flush remaining PCM, then send AUDIO_END (not RU9G / Ambient data field).
        if (closing) return;
        closing = true;
        if (pcmBuffer.length > 0) {
          sendJson({ type: 'AUDIO', audioData: bytesToBase64(pcmBuffer) });
          pcmBuffer = new Uint8Array(0);
        }
        sendJson({ type: 'EVENT', event: 'AUDIO_END' });
        closeWhenDrained(onClosed);
      }

      /**
       * Stream an entire ArrayBuffer (for example from FileReader.readAsArrayBuffer).
       * Strips a RIFF/WAVE header when present, then pushes PCM and ends the stream.
       */
      function streamArrayBuffer(buffer: ArrayBuffer, onClosed?: () => void) {
        const pcm = stripWavHeader(new Uint8Array(buffer));
        for (let offset = 0; offset < pcm.byteLength; offset += CHUNK_BYTES) {
          const end = Math.min(offset + CHUNK_BYTES, pcm.byteLength);
          pushPcm(pcm.subarray(offset, end));
        }
        endStream(onClosed);
      }

      /**
       * File input helper (same pattern as the Stream Dictation Session API reference).
       * Expects <input type="file" id="audioFile" /> in the page.
       */
      function streamFromFileInput(inputId = 'audioFile', onClosed?: () => void) {
        const input = document.getElementById(inputId) as HTMLInputElement | null;
        const file = input?.files?.[0];
        if (!file) {
          console.error(`Select a file for #${inputId}`);
          ws.close();
          return;
        }
        const reader = new FileReader();
        reader.onload = () => {
          streamArrayBuffer(reader.result as ArrayBuffer, onClosed);
        };
        reader.readAsArrayBuffer(file);
      }

      ws.onopen = () => {
        flushPending();
      };

      ws.onmessage = (ev) => {
        let payload: TranscriptFrame;
        try {
          payload = JSON.parse(String(ev.data)) as TranscriptFrame;
        } catch {
          console.warn('Non-JSON frame:', ev.data);
          return;
        }
        const text = payload.transcript?.transcript;
        if (text === 'EOF') {
          sawEof = true;
          onEof?.(finals.slice());
          return;
        }
        if (payload.is_final && text) {
          finals.push(text);
          console.log('Final:', text);
        } else if (text) {
          console.log('Partial:', text);
        }
      };

      ws.onerror = (e) => {
        lastSocketError = e;
        console.error('WebSocket error', e);
      };

      ws.onclose = (ev) => {
        if (!ev.wasClean || ev.code !== 1000) {
          console.error(
            `WebSocket closed abnormally: code=${ev.code} reason=${ev.reason || '(none)'}`,
            lastSocketError,
          );
        }
        const cb = onSocketClosed;
        onSocketClosed = undefined;
        cb?.();
      };

      return {
        ws,
        pushPcm,
        endStream,
        streamArrayBuffer,
        streamFromFileInput,
        stripWavHeader,
        getFinals: () => finals.slice(),
        onEof(cb: (finals: string[]) => void) {
          onEof = cb;
        },
      };
    }

    // ---------------------------------------------------------------------------
    // End session over REST (final_transcript is in the end response; no separate poll APIs)
    // ---------------------------------------------------------------------------
    async function endDictationSession(
      sukiToken: string,
      transcriptionSessionId: string,
    ): Promise<EndSessionResponse> {
      const url = `${BASE_URL}/api/v1/transcription/session/${transcriptionSessionId}/end`;
      const response = await fetch(url, {
        method: 'POST',
        headers: restHeaders(sukiToken),
      });
      await expectStatus(response, url, 200);
      return response.json() as Promise<EndSessionResponse>;
    }

    // ---------------------------------------------------------------------------
    // Run the full flow end to end
    // ---------------------------------------------------------------------------
    async function main() {
      try {
        // 1) Authenticate (register once if the provider is new).
        const sukiToken = await loginWithRegisterFallback(
          partnerId,
          partnerToken,
          providerId,
          'Dr. John Smith', // Example only — replace with the provider's name
          'org-123', // Example only — replace with your org ID
        );
        // 2) Create the Dictation session.
        const transcriptionSessionId = await createDictationSession(sukiToken);

        // 3) Stream audio from a file input, then end the session.
        //    Add <input type="file" id="audioFile" /> to your HTML before calling main().
        const client = createDictationStreamClient(sukiToken, transcriptionSessionId);
        client.onEof((liveFinals) => {
          console.log('Live finals before EOF:', liveFinals);
        });
        // Example: expects <input type="file" id="audioFile" /> with a LINEAR16 PCM or WAV file
        client.streamFromFileInput('audioFile', async () => {
          const endBody = await endDictationSession(sukiToken, transcriptionSessionId);
          console.log('End response:', endBody);
        });

        // Live capture alternative:
        // client.pushPcm(client.stripWavHeader(pcmBytes));
        // client.endStream(async () => { ... });
      } catch (err) {
        if (err instanceof SukiApiError) console.error(hintForError(err), err);
        throw err;
      }
    }

    // Prefer a backend proxy for partner_token in production.
    // Call main() only after credentials are set and #audioFile has a file selected.
    // main();
    ```
  </div>

  <div id="full-example-go" className="doc-tutorial-lang-panel" data-lang-panel="go" hidden>
    ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    // Dictation streaming tutorial (Go)
    // Flow: login → create session → stream PCM over WebSocket → read frames to EOF → end session
    // Install: go get github.com/gorilla/websocket
    package main

    import (
    	"bytes"
    	"encoding/base64"
    	"encoding/json"
    	"errors"
    	"fmt"
    	"io"
    	"net/http"
    	"os"
    	"strings"

    	"github.com/gorilla/websocket"
    )

    // Endpoints, streaming defaults, and partner credentials to replace.
    const (
    	baseURL        = "https://sdp.suki.ai"            // REST API host
    	wsURL          = "wss://sdp.suki.ai/ws/transcribe" // Dictation WebSocket stream
    	chunkBytes     = 3200                             // ~100 ms of 16 kHz mono 16-bit PCM
    	wavHeaderBytes = 44                               // Standard WAV header size to strip
    	partnerID      = "your-partner-id"                      // Example only — replace with your partner ID
    	partnerToken   = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." // Example only — replace; keep on backend only
    	providerID     = "provider-123"                   // Example only — optional; required for Bearer / Single Auth Token partners
    	sdpProviderID  = ""                          // Optional. Leave empty unless partnership requires it
    )

    type loginResponse struct {
    	SukiToken string `json:"suki_token"`
    }

    type createSessionResponse struct {
    	TranscriptionSessionID string `json:"transcription_session_id"`
    }

    type endSessionResponse struct {
    	TranscriptionSessionID string `json:"transcription_session_id"`
    	Status                 string `json:"status"`
    	FinalTranscript        string `json:"final_transcript"`
    	Duration               int    `json:"duration"`
    	EndedAt                string `json:"ended_at"`
    }

    type inboundTranscript struct {
    	Transcript *struct {
    		Transcript string `json:"transcript"`
    	} `json:"transcript"`
    	IsFinal      bool   `json:"is_final"`
    	TranscriptID string `json:"transcript_id"`
    }

    // Error helpers: map REST failures to the documented code / message / details shape.

    // SukiApiError is a REST failure with documented {code, message, details} when present.
    type SukiApiError struct {
    	HTTPStatus int
    	URL        string
    	Code       any
    	Message    string
    	Details    any
    	RawBody    string
    }

    func (e *SukiApiError) Error() string {
    	return fmt.Sprintf("HTTP %d %s: code=%v message=%q", e.HTTPStatus, e.URL, e.Code, e.Message)
    }

    // Matches reports whether message equals errorId or uses it as a prefix.
    func (e *SukiApiError) Matches(errorID string) bool {
    	m := strings.TrimSpace(e.Message)
    	return m == errorID || strings.HasPrefix(m, errorID+":") || strings.HasPrefix(m, errorID+" ")
    }

    func parseSukiErrorBody(raw string) (code any, message string, details any) {
    	var data map[string]any
    	if err := json.Unmarshal([]byte(raw), &data); err != nil {
    		return nil, truncate(raw, 500), []any{}
    	}
    	if msg, ok := data["message"]; ok && msg != nil {
    		message = fmt.Sprint(msg)
    	}
    	return data["code"], message, data["details"]
    }

    func truncate(s string, n int) string {
    	if len(s) <= n {
    		return s
    	}
    	return s[:n]
    }

    func newSukiAPIError(status int, url, raw string) *SukiApiError {
    	code, message, details := parseSukiErrorBody(raw)
    	return &SukiApiError{
    		HTTPStatus: status,
    		URL:        url,
    		Code:       code,
    		Message:    message,
    		Details:    details,
    		RawBody:    raw,
    	}
    }

    func hintForError(err *SukiApiError) string {
    	if err.Matches("invalid_sdp_token") || strings.Contains(strings.ToLower(err.Message), "invalid sdp token") {
    		return "Missing, expired, or invalid sdp_suki_token. Call login again."
    	}
    	if err.Matches("provider_not_registered") {
    		return "Provider is not registered. Call register once, then login again."
    	}
    	if err.Matches("transcription_session_not_accepting") || strings.Contains(strings.ToLower(err.Message), "not accepting new speech") {
    		return "Session is not READY/IDLE. Wait until the session accepts a new speech session, or end the parent session if you are done."
    	}
    	if err.HTTPStatus == http.StatusUnauthorized {
    		return "Unauthorized. Check partner credentials and sdp_suki_token."
    	}
    	if err.HTTPStatus == http.StatusPreconditionFailed {
    		return "FailedPrecondition. Open /ws/transcribe only when the session is READY or IDLE."
    	}
    	if err.HTTPStatus >= 500 {
    		return "Server error. Retry with backoff; see API error messages."
    	}
    	return "See /api-reference/error-messages"
    }

    func b64(data []byte) string {
    	return base64.StdEncoding.EncodeToString(data)
    }

    func restHeaders(sukiToken string) http.Header {
    	h := make(http.Header)
    	h.Set("sdp_suki_token", sukiToken)
    	h.Set("Content-Type", "application/json")
    	if sdpProviderID != "" {
    		h.Set("sdp_provider_id", sdpProviderID)
    	}
    	return h
    }

    // Authentication: exchange partner credentials for a Suki token (send later as sdp_suki_token).

    func login(partnerID, partnerToken, providerID string) (string, error) {
    	url := baseURL + "/api/v1/auth/login"
    	body := map[string]string{
    		"partner_id":    partnerID,
    		"partner_token": partnerToken,
    	}
    	if providerID != "" {
    		body["provider_id"] = providerID
    	}
    	raw, _ := json.Marshal(body)
    	resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
    	if err != nil {
    		return "", err
    	}
    	defer resp.Body.Close()
    	b, _ := io.ReadAll(resp.Body)
    	if resp.StatusCode != http.StatusOK {
    		return "", newSukiAPIError(resp.StatusCode, url, string(b))
    	}
    	var out loginResponse
    	if err := json.Unmarshal(b, &out); err != nil {
    		return "", err
    	}
    	if out.SukiToken == "" {
    		return "", &SukiApiError{HTTPStatus: 200, URL: url, Message: "login response missing suki_token"}
    	}
    	return out.SukiToken, nil
    }

    func registerProvider(partnerID, partnerToken, providerName, providerOrgID, providerID string) error {
    	url := baseURL + "/api/v1/auth/register"
    	body := map[string]string{
    		"partner_id":         partnerID,
    		"partner_token":      partnerToken,
    		"provider_name":      providerName,
    		"provider_org_id":    providerOrgID,
    		"provider_specialty": "CARDIOLOGY",
    	}
    	if providerID != "" {
    		body["provider_id"] = providerID
    	}
    	raw, _ := json.Marshal(body)
    	resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
    	if err != nil {
    		return err
    	}
    	defer resp.Body.Close()
    	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
    		b, _ := io.ReadAll(resp.Body)
    		return newSukiAPIError(resp.StatusCode, url, string(b))
    	}
    	return nil
    }

    func loginWithRegisterFallback(partnerID, partnerToken, providerID, providerName, providerOrgID string) (string, error) {
    	token, err := login(partnerID, partnerToken, providerID)
    	if err == nil {
    		return token, nil
    	}
    	var apiErr *SukiApiError
    	if errors.As(err, &apiErr) && apiErr.Matches("provider_not_registered") {
    		fmt.Println(hintForError(apiErr))
    		if err := registerProvider(partnerID, partnerToken, providerName, providerOrgID, providerID); err != nil {
    			return "", err
    		}
    		return login(partnerID, partnerToken, providerID)
    	}
    	if errors.As(err, &apiErr) {
    		fmt.Println(hintForError(apiErr))
    	}
    	return "", err
    }

    func doJSON(method, url, sukiToken string, payload any, wantStatus int, dest any) error {
    	var reader io.Reader
    	if payload != nil {
    		raw, err := json.Marshal(payload)
    		if err != nil {
    			return err
    		}
    		reader = bytes.NewReader(raw)
    	}
    	req, err := http.NewRequest(method, url, reader)
    	if err != nil {
    		return err
    	}
    	req.Header = restHeaders(sukiToken)
    	resp, err := http.DefaultClient.Do(req)
    	if err != nil {
    		return err
    	}
    	defer resp.Body.Close()
    	b, _ := io.ReadAll(resp.Body)
    	if resp.StatusCode != wantStatus {
    		return newSukiAPIError(resp.StatusCode, url, string(b))
    	}
    	if dest == nil {
    		return nil
    	}
    	return json.Unmarshal(b, dest)
    }

    // Dictation session setup.

    func createDictationSession(sukiToken string, body map[string]any) (string, error) {
    	// Create a Dictation session and return transcription_session_id (HTTP 201).
    	if body == nil {
    		body = map[string]any{
    			"audio_config": map[string]any{
    				"audio_encoding":    "LINEAR16",
    				"audio_language":    "en-US",
    				"sample_rate_hertz": 16000,
    			},
    		}
    	}
    	var out createSessionResponse
    	err := doJSON(http.MethodPost, baseURL+"/api/v1/transcription/session/create", sukiToken, body, http.StatusCreated, &out)
    	if err != nil {
    		return "", err
    	}
    	if out.TranscriptionSessionID == "" {
    		return "", &SukiApiError{
    			HTTPStatus: http.StatusCreated,
    			URL:        baseURL + "/api/v1/transcription/session/create",
    			Message:    "create response missing transcription_session_id",
    		}
    	}
    	return out.TranscriptionSessionID, nil
    }

    func stripWavHeader(raw []byte) []byte {
    	// If the file is WAV, drop the 44-byte header and keep only raw PCM bytes.
    	if len(raw) >= 12 && string(raw[:4]) == "RIFF" && string(raw[8:12]) == "WAVE" {
    		if len(raw) > wavHeaderBytes {
    			return raw[wavHeaderBytes:]
    		}
    	}
    	return raw
    }

    func sendJSON(conn *websocket.Conn, obj map[string]any) error {
    	return conn.WriteJSON(obj)
    }

    // WebSocket streaming (Go style: auth headers, not Sec-WebSocket-Protocol).
    // Open /ws/transcribe, stream LINEAR16 PCM as AUDIO/audioData, then AUDIO_END; recv until EOF.

    func streamPCMFile(sukiToken, transcriptionSessionID, path string) ([]string, error) {
    	hdr := http.Header{}
    	hdr.Set("sdp_suki_token", sukiToken)
    	hdr.Set("transcription_session_id", transcriptionSessionID)
    	if sdpProviderID != "" {
    		hdr.Set("sdp_provider_id", sdpProviderID)
    	}

    	conn, resp, err := websocket.DefaultDialer.Dial(wsURL, hdr)
    	if err != nil {
    		if resp != nil {
    			b, _ := io.ReadAll(resp.Body)
    			resp.Body.Close()
    			return nil, fmt.Errorf("WebSocket connect failed: %w; body=%s", err, truncate(string(b), 500))
    		}
    		return nil, fmt.Errorf("WebSocket connect failed: %w", err)
    	}
    	defer conn.Close()

    	raw, err := os.ReadFile(path)
    	if err != nil {
    		return nil, err
    	}
    	pcm := stripWavHeader(raw)
    	// Send ~100 ms JSON AUDIO frames (base64 PCM in audioData). Do not send binary WebSocket frames.
    	for i := 0; i < len(pcm); i += chunkBytes {
    		end := i + chunkBytes
    		if end > len(pcm) {
    			end = len(pcm)
    		}
    		if err := sendJSON(conn, map[string]any{
    			"type":      "AUDIO",
    			"audioData": b64(pcm[i:end]),
    		}); err != nil {
    			return nil, fmt.Errorf("WebSocket send AUDIO failed: %w", err)
    		}
    	}

    	// Explicit end-of-audio for this speech session (not RU9G / Ambient data field).
    	if err := sendJSON(conn, map[string]any{"type": "EVENT", "event": "AUDIO_END"}); err != nil {
    		return nil, fmt.Errorf("WebSocket send AUDIO_END failed: %w", err)
    	}

    	finals := make([]string, 0)
    	for {
    		_, msg, err := conn.ReadMessage()
    		if err != nil {
    			// Socket closed after EOF is expected.
    			break
    		}
    		var frame inboundTranscript
    		if err := json.Unmarshal(msg, &frame); err != nil {
    			fmt.Println("Non-JSON frame:", truncate(string(msg), 200))
    			continue
    		}
    		var text string
    		if frame.Transcript != nil {
    			text = frame.Transcript.Transcript
    		}
    		if text == "EOF" {
    			break
    		}
    		if frame.IsFinal && text != "" {
    			finals = append(finals, text)
    			fmt.Println("Final:", text)
    		} else if text != "" {
    			fmt.Println("Partial:", text)
    		}
    	}
    	return finals, nil
    }

    // End session over REST (final_transcript is in the end response; no separate poll APIs).

    func endDictationSession(sukiToken, transcriptionSessionID string) (*endSessionResponse, error) {
    	var out endSessionResponse
    	err := doJSON(
    		http.MethodPost,
    		fmt.Sprintf("%s/api/v1/transcription/session/%s/end", baseURL, transcriptionSessionID),
    		sukiToken,
    		nil,
    		http.StatusOK,
    		&out,
    	)
    	if err != nil {
    		return nil, err
    	}
    	return &out, nil
    }

    func main() {
    	// Full Dictation path: auth → create → stream → read EOF → end (final_transcript in response).
    	sukiToken, err := loginWithRegisterFallback(partnerID, partnerToken, providerID, "Dr. John Smith", "org-123") // Example provider name / org only
    	if err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}

    	transcriptionSessionID, err := createDictationSession(sukiToken, nil)
    	if err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}
    	// Point path at your LINEAR16 PCM or WAV file (example filename: audio.wav).
    	liveFinals, err := streamPCMFile(sukiToken, transcriptionSessionID, "audio.wav")
    	if err != nil {
    		panic(err)
    	}
    	fmt.Println("Live finals:", liveFinals)

    	endBody, err := endDictationSession(sukiToken, transcriptionSessionID)
    	if err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}
    	fmt.Printf(
    		"status=%s duration=%d ended_at=%s final_transcript=%q\n",
    		endBody.Status,
    		endBody.Duration,
    		endBody.EndedAt,
    		endBody.FinalTranscript,
    	)
    }
    ```
  </div>
</div>

<span id="troubleshooting" />

## Troubleshooting

<AccordionGroup>
  <Accordion title="Handshake `FailedPrecondition`">
    Open **`/ws/transcribe`** only when the session is **`READY`** or **`IDLE`**, not **`RUNNING`** or **`COMPLETED`**.
  </Accordion>

  <Accordion title="No Transcript / Protocol Errors">
    Use Dictation **`audioData`** and **`AUDIO_END`**. Do not send ambient **`START_TIME`**, **`RU9G`**, or ambient **`AUDIO`** `data` frames on this endpoint.
  </Accordion>

  <Accordion title="Browser Protocol Order">
    Dictation uses `SukiAmbientAuth,<sdp_suki_token>,<transcription_session_id>` (token before session ID). See the auth table above.
  </Accordion>

  <Accordion title="Other API Failures">
    See [API errors](/api-reference/error-messages).
  </Accordion>
</AccordionGroup>

<span id="summary" />

## Summary

In this tutorial, you:

* Authenticated with the Suki API.
* Created a Dictation session and opened **`/ws/transcribe`**.
* Streamed LINEAR16 PCM audio and read transcript frames.
* Ended the session and printed the final transcript.

For production, use the following endpoints:

* API: `https://sdp.suki.ai`
* WebSocket: `wss://sdp.suki.ai/ws/transcribe`

<span id="other-tutorials" />

## Other tutorials

Continue with another end-to-end tutorial.

<div className="hp-io-method-grid tut-hub-card-grid">
  <a className="hp-io-method-card tut-hub-method-card" href="/documentation/tutorials/dictation-sdk-chart-field">
    <div className="tut-hub-card-media tut-hub-card-media--blue" aria-hidden="true" />

    <div className="hp-io-method-card-body">
      <span className="hp-wn-badge hp-wn-badge-new">Dictation</span>
      <h3 className="hp-io-method-card-title">Build Dictation into a Chart Field</h3>

      <p className="hp-io-method-card-desc">
        Mount Dictation in-field mode on a chart textarea with one shared DictationClient in React.
      </p>

      <div className="hp-io-method-card-meta tut-hub-card-foot" aria-label="15 min, Intermediate">
        <div className="tut-hub-card-foot-meta">
          <span className="hp-io-method-card-meta-time">15 min</span>
          <span className="tut-hub-level">Intermediate</span>
        </div>
      </div>
    </div>
  </a>

  <a className="hp-io-method-card tut-hub-method-card" href="/documentation/tutorials/ambient-websocket-code-example">
    <div className="tut-hub-card-media" aria-hidden="true" />

    <div className="hp-io-method-card-body">
      <span className="hp-wn-badge hp-wn-badge-new">Ambient</span>
      <h3 className="hp-io-method-card-title">Build an Ambient Streaming Client</h3>

      <p className="hp-io-method-card-desc">
        Authenticate, create a session, stream PCM audio over WebSocket, and retrieve clinical note results.
      </p>

      <div className="hp-io-method-card-meta tut-hub-card-foot" aria-label="20 min, Intermediate">
        <div className="tut-hub-card-foot-meta">
          <span className="hp-io-method-card-meta-time">20 min</span>
          <span className="tut-hub-level">Intermediate</span>
        </div>
      </div>
    </div>
  </a>
</div>
