> ## 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 Form Filling Session Streaming Client

> Tutorial: Build an end-to-end Form filling session client from login to structured form data

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

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

  * Login and create a Form filling session
  * Add form template context before streaming
  * Stream raw PCM audio over WebSocket
  * End the session and retrieve structured form data
  * 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 Form filling streaming client. You authenticate, create a Form filling session, add form template context, stream PCM audio over a [WebSocket](/documentation/how-to/audio-streaming/ambient-audio-streaming) connection, end the session, and retrieve structured form data. 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/stream`** API, and handles common [API errors](/api-reference/error-messages).

<Note>
  Form filling uses the same **`/ws/stream`** wire format as ambient clinical notes. The response field **`ambient_session_id`** from [Create Form filling session](/form-filling-api-reference/form-filling-sessions/create) is your **Form filling session ID**. Do not use an ID from [Create ambient session](/api-reference/ambient-sessions/create).
</Note>

<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.
* At least one **`form_template_id`** from [Suki Medical form templates](/form-filling-api-reference/info/suki-medical-form-templates) (`GET /api/v1/info/suki-medical-form-templates`, field **`template_id`**). End session requires at least one template in context before you call end.
* 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 Form filling session]
  B --> C[Add form template context]
  C --> D[Open WebSocket]
  D --> E[Stream PCM audio]
  E --> F[End session]
  F --> G[Retrieve structured data]
```

<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.
     * Set at least one real **`form_template_id`** from the templates list before you stream.
   </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 `ambient_session_id` as HTTP headers (`sdp_provider_id` when required) |
| TypeScript (browser) | Send `Sec-WebSocket-Protocol`: `SukiAmbientAuth,<sdp_suki_token>,<ambient_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.
  * Send audio at least every **25 seconds**, or the server closes the stream. While paused, send **`KEEP_ALIVE`** every **five seconds**.
  * Send raw LINEAR16 PCM. If you start from a WAV file, strip the **44-byte** header first. Do not send binary WebSocket frames on this endpoint.
  * Browser protocol order matches ambient: **token, then session ID** (`SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id>`).
  * Use the Form filling create response **`ambient_session_id`** as the Form filling session ID on the WebSocket.
</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 = {"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 Example", "org-1"  # 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 Example', 'org-1' // 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 Form Filling Session">
    Create a session with the [Form filling session API](/form-filling-api-reference/form-filling-sessions/create). The response field **`ambient_session_id`** is your Form filling session ID.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def create_form_filling_session(suki_token: str, body: Optional[dict[str, str]] = None) -> str:
      url = f"{BASE_URL}/api/v1/form-filling/session/create"
      r = requests.post(url, headers=rest_headers(suki_token), json=body or {}, timeout=60)
      expect_status(r, url, 201)
      return r.json()["ambient_session_id"]

      ambient_session_id = create_form_filling_session(token)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      async function createFormFillingSession(sukiToken: string, body = {}) {
      const url = `${BASE_URL}/api/v1/form-filling/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();
      return data.ambient_session_id as string;
      }

      const ambientSessionId = await createFormFillingSession(token);
      ```

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

  <Step title="Add Form Template Context">
    Seed at least one **`form_template_id`** with the [Form filling Context API](/form-filling-api-reference/form-filling-sessions/context) before you end the session.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      def seed_form_filling_context(suki_token: str, ambient_session_id: str, form_template_id: str) -> None:
      url = f"{BASE_URL}/api/v1/form-filling/session/{ambient_session_id}/context"
      payload = {
          "form_filling": {
              "values": [
                  {"form_template_id": form_template_id},
              ]
          }
      }
      r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
      expect_status(r, url, 200)

      seed_form_filling_context(token, ambient_session_id, FORM_TEMPLATE_ID)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      async function seedFormFillingContext(
      sukiToken: string,
      ambientSessionId: string,
      formTemplateId: string,
      ) {
      const url = `${BASE_URL}/api/v1/form-filling/session/${ambientSessionId}/context`;
      const response = await fetch(url, {
      method: 'POST',
      headers: restHeaders(sukiToken),
      body: JSON.stringify({
        form_filling: {
          values: [{ form_template_id: formTemplateId }],
        },
      }),
      });
      await expectStatus(response, url, 200);
      }

      await seedFormFillingContext(token, ambientSessionId, formTemplateId);
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      func seedFormFillingContext(sukiToken, ambientSessionID, formTemplateID string) error {
      payload := map[string]any{
      	"form_filling": map[string]any{
      		"values": []map[string]string{{"form_template_id": formTemplateID}},
      	},
      }
      return doJSON(http.MethodPost,
      	fmt.Sprintf("%s/api/v1/form-filling/session/%s/context", baseURL, ambientSessionID),
      	sukiToken, payload, http.StatusOK, nil)
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Open the WebSocket">
    Open **`wss://sdp.suki.ai/ws/stream`** with the same ambient handshake. 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"ambient_session_id: {ambient_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},${ambientSessionId}`,
      ]);
      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("ambient_session_id", ambientSessionID)
      if sdpProviderID != "" {
      header.Set("sdp_provider_id", sdpProviderID)
      }
      conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
      ```
    </CodeGroup>
  </Step>

  <Step title="Stream Audio">
    Send **`START_TIME`** (Base64 of an RFC 3339 timestamp), then JSON **`AUDIO`** frames with Base64 LINEAR16 PCM (\~100 ms chunks). End the segment with **`{"type":"AUDIO","data":"RU9G"}`**. Same wire format as ambient. Do not send binary WebSocket frames.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      rfc3339 = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
      send_json(ws, {"type": "START_TIME", "data": b64(rfc3339.encode("utf-8"))})

      pcm = strip_wav_header(open(pcm_path, "rb").read())
      for i in range(0, len(pcm), CHUNK_BYTES):
      send_json(ws, {"type": "AUDIO", "data": b64(pcm[i : i + CHUNK_BYTES])})

      send_json(ws, {"type": "AUDIO", "data": "RU9G"})  # end marker (Base64 for EOF)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      // After open: send START_TIME once, then ~100 ms AUDIO frames, then RU9G.
      const rfc3339 = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
      sendJson({ type: 'START_TIME', data: utf8ToBase64(rfc3339) });
      sendJson({ type: 'AUDIO', data: bytesToBase64(frame) });
      sendJson({ type: 'AUDIO', data: 'RU9G' }); // end marker (Base64 for EOF)
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      rfc3339 := time.Now().UTC().Format("2006-01-02T15:04:05Z")
      _ = sendJSON(conn, map[string]any{"type": "START_TIME", "data": b64([]byte(rfc3339))})
      // Write AUDIO frames from stripWavHeader(pcm)...
      _ = sendJSON(conn, map[string]any{"type": "AUDIO", "data": "RU9G"}) // end marker
      ```
    </CodeGroup>
  </Step>

  <Step title="Optional Control Events">
    While streaming, you can send **`PAUSE`**, **`RESUME`**, or **`KEEP_ALIVE`** (every five seconds while paused).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      send_event(ws, "PAUSE")
      # While paused, send KEEP_ALIVE at least every five seconds.
      send_event(ws, "KEEP_ALIVE")
      send_event(ws, "RESUME")
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      ws.send(JSON.stringify({ type: 'EVENT', event: 'PAUSE' }));
      ws.send(JSON.stringify({ type: 'EVENT', event: 'KEEP_ALIVE' }));
      ws.send(JSON.stringify({ type: 'EVENT', event: 'RESUME' }));
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      _ = sendEvent(conn, "PAUSE")
      _ = sendEvent(conn, "KEEP_ALIVE")
      _ = sendEvent(conn, "RESUME")
      ```
    </CodeGroup>
  </Step>

  <Step title="End the Session and Retrieve Results">
    Close the WebSocket, call [End](/form-filling-api-reference/form-filling-sessions/end), poll [status](/form-filling-api-reference/form-filling-sessions/status), then get [structured data](/form-filling-api-reference/form-filling-sessions/structured-data).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      end_form_filling_session(token, ambient_session_id)
      results = wait_for_results(token, ambient_session_id)
      print(results)
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      await endFormFillingSession(token, ambientSessionId);
      const results = await waitForResults(token, ambientSessionId);
      console.log(results);
      ```

      ```go Go theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      _ = endFormFillingSession(sukiToken, ambientSessionID)
      results, err := waitForResults(sukiToken, ambientSessionID)
      fmt.Printf("%v\n", results)
      ```
    </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."
      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.';
      }
      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."
      }
      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"}}
    # Form filling streaming tutorial (Python)
    # Flow: login → create Form filling session → seed template context → stream PCM over WebSocket → end → poll structured data
    # Install: pip install requests websocket-client
    #
    # ambient_session_id from Form filling create is the Form filling session ID (same field name as Ambient; different ID).

    import base64
    import json
    import time
    from datetime import datetime, timezone
    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/stream"    # Shared Ambient / Form filling WebSocket stream
    CHUNK_BYTES = 3200                        # ~100 ms of 16 kHz mono 16-bit PCM
    WAV_HEADER_BYTES = 44                     # Standard WAV header size to strip
    KEEP_ALIVE_SEC = 5                        # Send KEEP_ALIVE this often while paused

    # ---------------------------------------------------------------------------
    # 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

    # form_template_id must come from GET /api/v1/info/suki-medical-form-templates (template_id).
    # Replace this example UUID with a real template from your account.
    FORM_TEMPLATE_ID = "YOUR_FORM_TEMPLATE_ID"  # Example placeholder — replace with template_id from GET /api/v1/info/suki-medical-form-templates


    # ---------------------------------------------------------------------------
    # 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.http_status == 401:
            return "Unauthorized. Check partner credentials and sdp_suki_token."
        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 (same as Ambient)
    # ---------------------------------------------------------------------------
    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


    # ---------------------------------------------------------------------------
    # Form filling session setup
    # ---------------------------------------------------------------------------
    def create_form_filling_session(suki_token: str, body: Optional[dict[str, str]] = None) -> str:
        """
        Create a Form filling session (POST /api/v1/form-filling/session/create, HTTP 201).
        Returns ambient_session_id: this is the Form filling session ID despite the field name.
        Never pass an Ambient clinical-note create ID here.
        """
        url = f"{BASE_URL}/api/v1/form-filling/session/create"
        r = requests.post(url, headers=rest_headers(suki_token), json=body or {}, timeout=60)
        expect_status(r, url, 201)
        sid = r.json().get("ambient_session_id")
        if not sid:
            raise SukiApiError(201, url, message="create response missing ambient_session_id")
        return sid


    def seed_form_filling_context(suki_token: str, ambient_session_id: str, form_template_id: str) -> None:
        """
        Seed Form filling context with at least one form_template_id before end.
        form_template_id must come from GET /api/v1/info/suki-medical-form-templates (template_id).
        Replace the example UUID with a real template from your account.
        """
        url = f"{BASE_URL}/api/v1/form-filling/session/{ambient_session_id}/context"
        payload = {
            "form_filling": {
                "values": [
                    {"form_template_id": form_template_id},
                ]
            }
        }
        r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
        expect_status(r, url, 200)


    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))


    def send_event(ws: websocket.WebSocket, event: str) -> None:
        send_json(ws, {"type": "EVENT", "event": event})


    # ---------------------------------------------------------------------------
    # WebSocket streaming (same wire format as Ambient; auth headers, not Sec-WebSocket-Protocol)
    # ---------------------------------------------------------------------------
    def stream_pcm_file(
        suki_token: str,
        ambient_session_id: str,
        pcm_or_wav_path: str,
    ) -> None:
        """
        Open /ws/stream, send START_TIME, stream LINEAR16 PCM chunks, then end with RU9G.
        Auth uses HTTP headers: sdp_suki_token + ambient_session_id (+ sdp_provider_id when required).
        ambient_session_id must be the Form filling create ID.
        """
        try:
            ws = websocket.create_connection(
                WS_URL,
                header=(
                    [
                        f"sdp_suki_token: {suki_token}",
                        f"ambient_session_id: {ambient_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

        try:
            # Mark the start of this audio segment (RFC3339 timestamp, base64-encoded).
            rfc3339 = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
            send_json(ws, {"type": "START_TIME", "data": b64(rfc3339.encode("utf-8"))})

            with open(pcm_or_wav_path, "rb") as f:
                pcm = strip_wav_header(f.read())

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

            # Optional controls while streaming (same EVENT enum as Ambient):
            # send_event(ws, "PAUSE")
            # while paused: send_event(ws, "KEEP_ALIVE"); time.sleep(KEEP_ALIVE_SEC)
            # send_event(ws, "RESUME")
            # send_event(ws, "CANCEL")  # cancels the session; server closes the socket
            # ABORT is deprecated and is not handled by the stream handler

            # End marker for this stream segment (EOF as base64 text "RU9G").
            send_json(ws, {"type": "AUDIO", "data": "RU9G"})
        except (OSError, websocket.WebSocketException) as err:
            raise RuntimeError(f"WebSocket stream failed: {err}") from err
        finally:
            try:
                ws.close()
            except Exception:
                pass


    # ---------------------------------------------------------------------------
    # End session and fetch structured data over REST
    # ---------------------------------------------------------------------------
    def end_form_filling_session(suki_token: str, ambient_session_id: str) -> None:
        """End the Form filling session after the WebSocket stream is closed."""
        url = f"{BASE_URL}/api/v1/form-filling/session/{ambient_session_id}/end"
        r = requests.post(url, headers=rest_headers(suki_token), timeout=60)
        expect_status(r, url, 200)


    def get_status(suki_token: str, ambient_session_id: str) -> str:
        url = f"{BASE_URL}/api/v1/form-filling/session/{ambient_session_id}/status"
        r = requests.get(url, headers=rest_headers(suki_token), timeout=60)
        expect_status(r, url, 200)
        return r.json()["status"]


    def get_structured_data(suki_token: str, ambient_session_id: str) -> Any:
        """
        GET structured-data. OpenAPI/API samples wrap the payload under the structured_data key.
        """
        url = f"{BASE_URL}/api/v1/form-filling/session/{ambient_session_id}/structured-data"
        r = requests.get(url, headers=rest_headers(suki_token), timeout=60)
        expect_status(r, url, 200)
        data = r.json()
        if not isinstance(data, dict) or "structured_data" not in data:
            raise SukiApiError(200, url, message="response missing structured_data key")
        return data["structured_data"]


    def wait_for_results(suki_token: str, ambient_session_id: str, poll_sec: float = 2.0) -> dict[str, Any]:
        """Poll session status until completed, failed, or aborted, then load structured data."""
        while True:
            status = get_status(suki_token, ambient_session_id)
            if status == "completed":
                return {
                    "status": status,
                    "structured_data": get_structured_data(suki_token, ambient_session_id),
                }
            if status in ("failed", "aborted"):
                return {"status": status, "structured_data": None}
            time.sleep(poll_sec)


    # ---------------------------------------------------------------------------
    # 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 Form filling session and seed template context.
            #    ambient_session_id here is the Form filling session ID (not Ambient clinical create).
            ambient_session_id = create_form_filling_session(suki_token, {})
            seed_form_filling_context(suki_token, ambient_session_id, FORM_TEMPLATE_ID)
            # 3) Stream audio, then end the session and wait for structured data.
            stream_pcm_file(suki_token, ambient_session_id, "audio.wav")  # Example path — replace with your LINEAR16 PCM/WAV file
            end_form_filling_session(suki_token, ambient_session_id)
            print(wait_for_results(suki_token, ambient_session_id))
        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"}}
    // Form filling streaming tutorial (TypeScript / browser)
    // Flow: login → create Form filling session → seed template context → stream PCM over WebSocket → end → poll structured data
    // Browser WebSocket auth uses Sec-WebSocket-Protocol (not HTTP headers).
    // Prefer running login / create / context on your backend so partner_token never ships to the client.
    //
    // ambient_session_id from Form filling create is the Form filling session ID (same field name as Ambient; different ID).

    // ---------------------------------------------------------------------------
    // Endpoints and streaming defaults
    // ---------------------------------------------------------------------------
    const BASE_URL = 'https://sdp.suki.ai'; // REST API host
    const WS_URL = 'wss://sdp.suki.ai/ws/stream'; // Shared Ambient / Form filling 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
    const KEEP_ALIVE_MS = 5000; // Send KEEP_ALIVE this often while paused

    // ---------------------------------------------------------------------------
    // 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

    // form_template_id must come from GET /api/v1/info/suki-medical-form-templates (template_id).
    // Replace this example UUID with a real template from your account.
    const formTemplateId = 'YOUR_FORM_TEMPLATE_ID'; // Example placeholder — replace with template_id from the templates API

    type ControlEvent = 'PAUSE' | 'RESUME' | 'KEEP_ALIVE' | 'CANCEL';

    // ---------------------------------------------------------------------------
    // 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.httpStatus === 401) {
        return 'Unauthorized. Check partner credentials and sdp_suki_token.';
      }
      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 (same as Ambient)
    // ---------------------------------------------------------------------------
    /** 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;
      }
    }

    // ---------------------------------------------------------------------------
    // Form filling session setup
    // ---------------------------------------------------------------------------
    /**
     * Create a Form filling session (POST /api/v1/form-filling/session/create, HTTP 201).
     * Returns ambient_session_id: this is the Form filling session ID despite the field name.
     * Never pass an Ambient clinical-note create ID here.
     */
    async function createFormFillingSession(
      sukiToken: string,
      body: { ambient_session_id?: string; encounter_id?: string } = {},
    ): Promise<string> {
      const url = `${BASE_URL}/api/v1/form-filling/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.ambient_session_id) {
        throw new SukiApiError(201, url, {
          message: 'create response missing ambient_session_id',
        });
      }
      return data.ambient_session_id as string;
    }

    /**
     * Seed Form filling context with at least one form_template_id before end.
     * form_template_id must come from GET /api/v1/info/suki-medical-form-templates (template_id).
     * Replace the example UUID with a real template from your account.
     */
    async function seedFormFillingContext(
      sukiToken: string,
      ambientSessionId: string,
      formTemplateId: string,
    ): Promise<void> {
      const url = `${BASE_URL}/api/v1/form-filling/session/${ambientSessionId}/context`;
      const response = await fetch(url, {
        method: 'POST',
        headers: restHeaders(sukiToken),
        body: JSON.stringify({
          form_filling: {
            values: [{ form_template_id: formTemplateId }],
          },
        }),
      });
      await expectStatus(response, url, 200);
    }

    function utf8ToBase64(s: string): string {
      const bytes = new TextEncoder().encode(s);
      let binary = '';
      for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
      return btoa(binary);
    }

    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;
    }

    // ---------------------------------------------------------------------------
    // WebSocket streaming (browser: Sec-WebSocket-Protocol auth; same wire format as Ambient)
    // ---------------------------------------------------------------------------
    /**
     * Browser stream client for Form filling (shared /ws/stream).
     * Authenticate with: Sec-WebSocket-Protocol = SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id>
     * (token, then session ID). ambient_session_id must be the Form filling create ID.
     * Sends START_TIME, JSON AUDIO chunks (~100 ms), optional EVENTs, then RU9G to end the segment.
     */
    function createFormFillingStreamClient(sukiToken: string, ambientSessionId: string) {
      let pcmBuffer = new Uint8Array(0);
      const pending: string[] = [];
      let startSent = false;
      let paused = false;
      let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
      let closing = false;
      let onSocketClosed: (() => void) | undefined;
      let lastSocketError: Event | undefined;

      const ws = new WebSocket(WS_URL, [
        `SukiAmbientAuth,${sukiToken},${ambientSessionId}`,
      ]);
      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 ensureStartTime() {
        // Mark the start of this audio segment once (RFC3339 timestamp, base64-encoded).
        if (startSent) return;
        startSent = true;
        const rfc3339 = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
        pending.unshift(
          JSON.stringify({ type: 'START_TIME', data: utf8ToBase64(rfc3339) }),
        );
        flushPending();
      }

      function stopKeepAlive() {
        if (keepAliveTimer !== null) {
          clearInterval(keepAliveTimer);
          keepAliveTimer = null;
        }
      }

      function startKeepAlive() {
        stopKeepAlive();
        sendJson({ type: 'EVENT', event: 'KEEP_ALIVE' });
        keepAliveTimer = setInterval(() => {
          if (!paused || closing) return;
          sendJson({ type: 'EVENT', event: 'KEEP_ALIVE' });
        }, KEEP_ALIVE_MS);
      }

      function sendEvent(event: ControlEvent) {
        ensureStartTime();
        sendJson({ type: 'EVENT', event });
        if (event === 'PAUSE') {
          paused = true;
          startKeepAlive();
          return;
        }
        if (event === 'RESUME') {
          paused = false;
          stopKeepAlive();
          return;
        }
        if (event === 'CANCEL') {
          paused = false;
          stopKeepAlive();
          closing = true;
          pcmBuffer = new Uint8Array(0);
          return;
        }
      }

      function pushPcm(chunk: Uint8Array) {
        // Buffer live PCM and flush ~100 ms JSON AUDIO frames (base64). Do not send binary frames.
        if (closing) return;
        ensureStartTime();
        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', data: 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;
          }
          if (ws.readyState === WebSocket.OPEN) ws.close();
        };
        tick();
      }

      function endStream(onClosed?: () => void) {
        // Flush remaining PCM, then send the end marker (EOF as base64 text "RU9G").
        if (closing) return;
        closing = true;
        paused = false;
        stopKeepAlive();
        ensureStartTime();
        if (pcmBuffer.length > 0) {
          sendJson({ type: 'AUDIO', data: bytesToBase64(pcmBuffer) });
          pcmBuffer = new Uint8Array(0);
        }
        sendJson({ type: 'AUDIO', data: 'RU9G' });
        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. 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 LINEAR16 PCM or WAV file for #${inputId}`);
          ws.close();
          return;
        }
        const reader = new FileReader();
        reader.onload = () => {
          streamArrayBuffer(reader.result as ArrayBuffer, onClosed);
        };
        reader.readAsArrayBuffer(file);
      }

      ws.onopen = () => {
        ensureStartTime();
        flushPending();
      };
      ws.onmessage = (ev) => {
        // Final structured data is not guaranteed over WebSocket. Prefer REST.
        console.log('Received:', ev.data);
      };
      ws.onerror = (e) => {
        lastSocketError = e;
        console.error('WebSocket error', e);
      };
      ws.onclose = (ev) => {
        stopKeepAlive();
        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,
        sendEvent,
        endStream,
        streamArrayBuffer,
        streamFromFileInput,
        stripWavHeader,
      };
    }

    // ---------------------------------------------------------------------------
    // End session and fetch structured data over REST
    // ---------------------------------------------------------------------------
    async function endFormFillingSession(sukiToken: string, ambientSessionId: string) {
      /** End the Form filling session after the WebSocket stream is closed. */
      const url = `${BASE_URL}/api/v1/form-filling/session/${ambientSessionId}/end`;
      const response = await fetch(url, {
        method: 'POST',
        headers: restHeaders(sukiToken),
      });
      await expectStatus(response, url, 200);
    }

    async function getSessionStatus(sukiToken: string, ambientSessionId: string) {
      const url = `${BASE_URL}/api/v1/form-filling/session/${ambientSessionId}/status`;
      const response = await fetch(url, { headers: restHeaders(sukiToken) });
      await expectStatus(response, url, 200);
      const body = await response.json();
      return body.status as string;
    }

    async function getStructuredData(sukiToken: string, ambientSessionId: string) {
      /** OpenAPI/API samples wrap the payload under the structured_data key. */
      const url = `${BASE_URL}/api/v1/form-filling/session/${ambientSessionId}/structured-data`;
      const response = await fetch(url, { headers: restHeaders(sukiToken) });
      await expectStatus(response, url, 200);
      const data = await response.json();
      if (!data || typeof data !== 'object' || !('structured_data' in data)) {
        throw new SukiApiError(200, url, { message: 'response missing structured_data key' });
      }
      return data.structured_data;
    }

    async function waitForResults(
      sukiToken: string,
      ambientSessionId: string,
      pollMs = 2000,
    ) {
      for (;;) {
        const status = await getSessionStatus(sukiToken, ambientSessionId);
        if (status === 'completed') {
          const structuredData = await getStructuredData(sukiToken, ambientSessionId);
          return { status, structured_data: structuredData };
        }
        if (status === 'failed' || status === 'aborted') {
          return { status, structured_data: null };
        }
        await new Promise((r) => setTimeout(r, pollMs));
      }
    }

    // ---------------------------------------------------------------------------
    // 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 Form filling session and seed template context.
        //    ambientSessionId here is the Form filling session ID (not Ambient clinical create).
        const ambientSessionId = await createFormFillingSession(sukiToken);
        await seedFormFillingContext(sukiToken, ambientSessionId, formTemplateId);

        // 3) Stream audio from a file input, then end the session and poll structured data.
        //    Add <input type="file" id="audioFile" /> to your HTML before calling main().
        const client = createFormFillingStreamClient(sukiToken, ambientSessionId);
        // Example: expects <input type="file" id="audioFile" /> with a LINEAR16 PCM or WAV file
        client.streamFromFileInput('audioFile', async () => {
          await endFormFillingSession(sukiToken, ambientSessionId);
          console.log(await waitForResults(sukiToken, ambientSessionId));
        });

        // 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, template ID, and #audioFile are set.
    // 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"}}
    // Form filling streaming tutorial (Go)
    // Flow: login → create Form filling session → seed template context → stream PCM over WebSocket → end → poll structured data
    // Install: go get github.com/gorilla/websocket
    //
    // ambient_session_id from Form filling create is the Form filling session ID (same field name as Ambient; different ID).
    package main

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

    	"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/stream" // Shared Ambient / Form filling 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
    	// form_template_id must come from GET /api/v1/info/suki-medical-form-templates (template_id).
    	// Replace this example UUID with a real template from your account.
    	formTemplateID = "YOUR_FORM_TEMPLATE_ID" // Example placeholder — replace with template_id from the templates API
    )

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

    type createSessionResponse struct {
    	AmbientSessionID string `json:"ambient_session_id"`
    }

    type statusResponse struct {
    	Status string `json:"status"`
    }

    type structuredDataEnvelope struct {
    	StructuredData json.RawMessage `json:"structured_data"`
    }

    // 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.HTTPStatus == http.StatusUnauthorized {
    		return "Unauthorized. Check partner credentials and sdp_suki_token."
    	}
    	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)
    }

    // Form filling session setup.

    func createFormFillingSession(sukiToken string, body map[string]string) (string, error) {
    	// Create a Form filling session and return ambient_session_id (HTTP 201).
    	// That value is the Form filling session ID despite the field name. Never use Ambient clinical create IDs.
    	if body == nil {
    		body = map[string]string{}
    	}
    	var out createSessionResponse
    	err := doJSON(http.MethodPost, baseURL+"/api/v1/form-filling/session/create", sukiToken, body, http.StatusCreated, &out)
    	if err != nil {
    		return "", err
    	}
    	if out.AmbientSessionID == "" {
    		return "", &SukiApiError{
    			HTTPStatus: http.StatusCreated,
    			URL:        baseURL + "/api/v1/form-filling/session/create",
    			Message:    "create response missing ambient_session_id",
    		}
    	}
    	return out.AmbientSessionID, nil
    }

    func seedFormFillingContext(sukiToken, ambientSessionID, formTemplateID string) error {
    	// Seed Form filling context. form_template_id must come from
    	// GET /api/v1/info/suki-medical-form-templates (template_id). End requires at least one template.
    	payload := map[string]any{
    		"form_filling": map[string]any{
    			"values": []map[string]string{
    				{"form_template_id": formTemplateID},
    			},
    		},
    	}
    	return doJSON(
    		http.MethodPost,
    		fmt.Sprintf("%s/api/v1/form-filling/session/%s/context", baseURL, ambientSessionID),
    		sukiToken,
    		payload,
    		http.StatusOK,
    		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)
    }

    func sendEvent(conn *websocket.Conn, event string) error {
    	return sendJSON(conn, map[string]any{"type": "EVENT", "event": event})
    }

    // WebSocket streaming (Go style: auth headers, not Sec-WebSocket-Protocol).
    // Same wire format as Ambient: Open /ws/stream, send START_TIME, stream LINEAR16 PCM chunks, then end with RU9G.

    func streamPCMFile(sukiToken, ambientSessionID, path string) error {
    	hdr := http.Header{}
    	hdr.Set("sdp_suki_token", sukiToken)
    	hdr.Set("ambient_session_id", ambientSessionID)
    	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 fmt.Errorf("WebSocket connect failed: %w; body=%s", err, truncate(string(b), 500))
    		}
    		return fmt.Errorf("WebSocket connect failed: %w", err)
    	}
    	defer conn.Close()

    	// Mark the start of this audio segment (RFC3339 timestamp, base64-encoded).
    	rfc3339 := time.Now().UTC().Format("2006-01-02T15:04:05Z")
    	if err := sendJSON(conn, map[string]any{
    		"type": "START_TIME",
    		"data": b64([]byte(rfc3339)),
    	}); err != nil {
    		return fmt.Errorf("WebSocket send START_TIME failed: %w", err)
    	}

    	raw, err := os.ReadFile(path)
    	if err != nil {
    		return err
    	}
    	pcm := stripWavHeader(raw)
    	// Send ~100 ms JSON AUDIO frames (base64 PCM). 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",
    			"data": b64(pcm[i:end]),
    		}); err != nil {
    			return fmt.Errorf("WebSocket send AUDIO failed: %w", err)
    		}
    	}

    	// Optional controls while streaming (same EVENT enum as Ambient):
    	// sendEvent(conn, "PAUSE"); sendEvent(conn, "KEEP_ALIVE"); /* every 5s while paused */ sendEvent(conn, "RESUME")
    	// sendEvent(conn, "PAUSE"); sendEvent(conn, "KEEP_ALIVE"); /* every 5s while paused */ sendEvent(conn, "RESUME")
    	// sendEvent(conn, "CANCEL") // ABORT is deprecated and is not handled by the stream handler

    	if err := sendJSON(conn, map[string]any{"type": "AUDIO", "data": "RU9G"}); err != nil {
    		return fmt.Errorf("WebSocket send RU9G failed: %w", err)
    	}
    	return nil
    }

    // End session and fetch structured data over REST.

    func endFormFillingSession(sukiToken, ambientSessionID string) error {
    	return doJSON(
    		http.MethodPost,
    		fmt.Sprintf("%s/api/v1/form-filling/session/%s/end", baseURL, ambientSessionID),
    		sukiToken,
    		nil,
    		http.StatusOK,
    		nil,
    	)
    }

    func getStatus(sukiToken, ambientSessionID string) (string, error) {
    	var out statusResponse
    	err := doJSON(
    		http.MethodGet,
    		fmt.Sprintf("%s/api/v1/form-filling/session/%s/status", baseURL, ambientSessionID),
    		sukiToken,
    		nil,
    		http.StatusOK,
    		&out,
    	)
    	return out.Status, err
    }

    func getStructuredData(sukiToken, ambientSessionID string) (any, error) {
    	// OpenAPI/API samples wrap the payload under the structured_data key.
    	url := fmt.Sprintf("%s/api/v1/form-filling/session/%s/structured-data", baseURL, ambientSessionID)
    	var envelope structuredDataEnvelope
    	if err := doJSON(http.MethodGet, url, sukiToken, nil, http.StatusOK, &envelope); err != nil {
    		return nil, err
    	}
    	if len(envelope.StructuredData) == 0 {
    		return nil, &SukiApiError{HTTPStatus: 200, URL: url, Message: "response missing structured_data key"}
    	}
    	var payload any
    	if err := json.Unmarshal(envelope.StructuredData, &payload); err != nil {
    		return nil, err
    	}
    	return payload, nil
    }

    func waitForResults(sukiToken, ambientSessionID string) (map[string]any, error) {
    	// Poll status until terminal (completed, failed, or aborted). On completed, fetch structured data.
    	for {
    		status, err := getStatus(sukiToken, ambientSessionID)
    		if err != nil {
    			return nil, err
    		}
    		switch status {
    		case "completed":
    			sd, err := getStructuredData(sukiToken, ambientSessionID)
    			if err != nil {
    				return nil, err
    			}
    			return map[string]any{"status": status, "structured_data": sd}, nil
    		case "failed", "aborted":
    			return map[string]any{"status": status, "structured_data": nil}, nil
    		}
    		time.Sleep(2 * time.Second)
    	}
    }

    func main() {
    	// Full Form filling path: auth → create → context → stream → end → poll structured data.
    	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)
    	}

    	ambientSessionID, err := createFormFillingSession(sukiToken, map[string]string{})
    	if err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}
    	if err := seedFormFillingContext(sukiToken, ambientSessionID, formTemplateID); 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).
    	if err := streamPCMFile(sukiToken, ambientSessionID, "audio.wav"); err != nil {
    		panic(err)
    	}
    	if err := endFormFillingSession(sukiToken, ambientSessionID); err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}
    	results, err := waitForResults(sukiToken, ambientSessionID)
    	if err != nil {
    		var apiErr *SukiApiError
    		if errors.As(err, &apiErr) {
    			fmt.Println(hintForError(apiErr))
    		}
    		panic(err)
    	}
    	fmt.Printf("%v\n", results)
    }
    ```
  </div>
</div>

<span id="troubleshooting" />

## Troubleshooting

<AccordionGroup>
  <Accordion title="Wrong Session ID on the WebSocket">
    Use the Form filling create response **`ambient_session_id`** as the Form filling session ID. Do not use an ambient clinical-notes session ID.
  </Accordion>

  <Accordion title="End Session Fails without Templates">
    Seed at least one **`form_template_id`** in context before you call end.
  </Accordion>

  <Accordion title="WebSocket Closes During a Pause">
    Send **`KEEP_ALIVE`** every five seconds while paused, and send audio at least every 25 seconds while streaming.
  </Accordion>

  <Accordion title="Auth or Stream Errors">
    Re-login for a fresh `suki_token`, and see [API errors](/api-reference/error-messages).
  </Accordion>
</AccordionGroup>

<span id="summary" />

## Summary

In this tutorial, you:

* Authenticated with the Suki API.
* Created a Form filling session and seeded form template context.
* Streamed LINEAR16 PCM audio over a WebSocket connection.
* Ended the session and retrieved structured form data.

For production, use the following endpoints:

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

<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/form-filling-sdk-ehr-handoff">
    <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">Form Filling</span>
      <h3 className="hp-io-method-card-title">Build a Form Filling Session with EHR Handoff</h3>

      <p className="hp-io-method-card-desc">
        Open a Form filling session and map structured\_data to your EHR using correlation\_id.
      </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>

  <a className="hp-io-method-card tut-hub-method-card" href="/documentation/tutorials/webhook-notification-receiver">
    <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">APIs</span>
      <h3 className="hp-io-method-card-title">Build a Webhook Notification Receiver</h3>

      <p className="hp-io-method-card-desc">
        Verify HMAC signatures, parse partner notifications, and handle success and failure events.
      </p>

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