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

# Migrate from V1 to V2

> Move upload and stream integrations from the Inter-1 V1 endpoints to the Inter-2 V2 endpoints.

The V2 endpoints analyze with **Inter-2**, which is faster and more accurate than Inter-1. This guide covers the client changes for each endpoint pair:

| V1 endpoint | V2 endpoint | Size of the change |
| - | - | - |
| [`POST /v1/upload/analyze`](/api-reference/upload-analyze) | [`POST /v2/upload/analyze`](/api-reference/upload-analyze-v2) and `GET /v2/upload/jobs/{job_id}` | New request field, asynchronous job lifecycle, new result shape |
| [`WS /v1/stream/analyze`](/api-reference/stream-analyze) | [`WS /v2/stream/analyze`](/api-reference/stream-analyze-v2) | New path; signal payloads drop `modality` |

The V2 endpoints are served beside the V1 endpoints, so you can migrate one endpoint at a time.

## What stays the same

* **Base URL and credentials.** Send the same API key, or a client token minted from it, as `Authorization: Bearer <credential>`. Browser WebSocket clients keep using the `Sec-WebSocket-Protocol: access_token, <credential>` subprotocol pair.
* **Scopes.** A scope names an endpoint family across versions: `interhumanai.upload` covers both upload endpoints and reading upload jobs, and `interhumanai.stream` covers both stream endpoints. An existing key or client token needs no new scope. A missing scope is still refused with `ih2003`. See [Authentication](/api-reference/authentication).
* **Client tokens.** Mint them with [`POST /v1/client_tokens`](/api-reference/client-tokens) as before. A token's video budget (`max_video_seconds`) is shared across upload and stream on either version.
* **Errors.** Every error uses the same shape (`error_id`, `correlation_id`, `link`, `message` over HTTP; `data.code` in a stream `error` envelope). See [Error handling](/api-reference/error-handling).
* **Conversation Quality Index.** Request it with the same `include[]` / `include` values, `conversation_quality_overall` and `conversation_quality_timeline`.

## Migrate upload

On V1, one request uploads the file and returns the analysis. On V2, the upload creates an analysis **job**: the request returns a job envelope, and you read the result from the job once it finishes.

To keep V1's whole-file signal list, send `include[]=signals`; see [Keep the V1 signal list](#keep-the-v1-signal-list-with-include=signals).

### 1. Name the model

`model` is a required form field on `POST /v2/upload/analyze`. Send `model=inter-2`. An unknown value is refused with `400` (`ih4005`).

### 2. Check the file

V2 accepts the same containers (mp4, mov, avi, mkv, webm, mpeg-ts), the same 32 MB limit, and the same 3-second minimum. Two requirements are new:

* The file must carry **both a video and an audio track**. A file without video is refused with `ih5001`, and a file without audio with `ih5008`.
* The media must be no longer than 30 minutes. A longer file is refused with `ih4004`.

### 3. Submit the job and read the result

The submit answers `202` with a job envelope whose `status` is `queued`. Read `GET /v2/upload/jobs/{job_id}` (the envelope's `status_url`) until `status` is `completed` or `failed`. The status moves through `queued` and `running` before it reaches one of those two terminal values.

To skip polling for short files, send `wait_seconds`. The API holds the request open for up to that many seconds and answers `200` with the job when it reaches `completed` or `failed` in time, or `202` with the pending job when it does not. A value above 120 seconds is refused with `ih4005`. Keep the polling path either way.

**Before (V1):**

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.interhuman.ai/v1/upload/analyze \
    -H "Authorization: Bearer $API_KEY" \
    -F "file=@meeting.mp4" \
    -F "include[]=conversation_quality_overall"
  ```

  ```python Python theme={null}
  import os

  import requests

  API = "https://api.interhuman.ai"
  headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"}

  with open("meeting.mp4", "rb") as f:
      response = requests.post(
          f"{API}/v1/upload/analyze",
          headers=headers,
          files={"file": f},
          data={"include[]": ["conversation_quality_overall"]},
      )
  response.raise_for_status()
  analysis = response.json()  # signals, engagement_state, conversation_quality
  ```
</CodeGroup>

**After (V2):**

<CodeGroup>
  ```bash cURL theme={null}
  # Submit the job. Answers 202 with the job envelope.
  curl -X POST https://api.interhuman.ai/v2/upload/analyze \
    -H "Authorization: Bearer $API_KEY" \
    -F "file=@meeting.mp4" \
    -F "model=inter-2" \
    -F "include[]=signals" \
    -F "include[]=conversation_quality_overall"

  # Read the job until status is completed or failed.
  # JOB_ID is the job_id from the submit response.
  curl https://api.interhuman.ai/v2/upload/jobs/$JOB_ID \
    -H "Authorization: Bearer $API_KEY"
  ```

  ```python Python theme={null}
  import os
  import time

  import requests

  API = "https://api.interhuman.ai"
  headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"}

  with open("meeting.mp4", "rb") as f:
      response = requests.post(
          f"{API}/v2/upload/analyze",
          headers=headers,
          files={"file": f},
          data={
              "model": "inter-2",
              "include[]": ["signals", "conversation_quality_overall"],
          },
      )
  response.raise_for_status()
  job = response.json()

  deadline = time.monotonic() + 600
  while job["status"] not in ("completed", "failed"):
      if time.monotonic() > deadline:
          raise TimeoutError(f"job {job['job_id']} still {job['status']}")
      time.sleep(2)
      response = requests.get(f"{API}{job['status_url']}", headers=headers)
      if response.status_code == 503:
          continue  # ih1002 / ih1003: retry after a short delay
      response.raise_for_status()
      job = response.json()

  if job["status"] == "failed":
      raise RuntimeError(job["error"]["error_id"])
  result = job["result"]  # duration_seconds, window_seconds, windows, signals, conversation_quality
  ```
</CodeGroup>

`status_url` is a path relative to the API base URL, not an absolute URL.

### 4. Handle job failures and expiry

Validation still happens at submit, so a missing file, an unsupported container, or a file that is too short or too long fails the submit request with the usual HTTP error. A failure during the analysis itself does not: the job ends with `status: "failed"`, and its `error` field carries the standard error body. Check `status` before you read `result`. A job that fails with `ih1004` was interrupted before it finished; submit the file again.

A job stays readable until its `expires_at` (1 hour after submission). After that, and for an id that does not belong to your account, the read answers `404` (`ih4021`). Store the result on your side if you need it longer.

When the API cannot take a job right now, the submit answers `503` (`ih1002` or `ih1003`). Retry after a short delay.

### 5. Read the new result shape

The V1 response lists signals and engagement states over the whole file. The V2 result divides the file into fixed-length windows and reports each window separately.

| V1 `AnalysisResult` | V2 `job.result` |
| - | - |
| `signals[]`, each with `type`, `start`, `end`, `probability`, `rationale`, `modality` | `signals[]` when `include[]` asked for `signals`: a whole-file list merged by the V1 rules, with no `modality`. Per window, `windows[].signals[]`, each with `type`, `start`, `end`, `probability`, `rationale`; no `modality` |
| `engagement_state[]`, each with `state`, `start`, `end` | `windows[].engagement_status`, one value per window, with the window's span in `start_seconds` and `end_seconds` |
| `conversation_quality` | `conversation_quality`, computed once over the whole file; present only when `include[]` asked for it |
| — | `duration_seconds` and `window_seconds` |

A V2 window signal's `start` and `end` are the span of the window it was detected in, so a signal that lasts several windows appears in each of them.

### Keep the V1 signal list with `include[]=signals`

If your integration reads V1's whole-file `signals[]`, send `include[]=signals` with the submit and read `result.signals` instead of the windows. The API builds the list once the job completes, with the same rules as V1:

* Every signal in the file, in the order they start.
* A signal of the same type in adjacent windows becomes one entry spanning them, with the highest `probability` among them and the `rationale` that came with it. On a tie, the earliest one's rationale is kept.

Two details differ from V1:

* The list has no `modality`, like every V2 signal.
* An entry with no `probability` or `rationale` leaves that field out, where V1 returns `null`.

Without the flag, the result has no `signals` key. With it, `signals` is an empty list when nothing was detected. The windows are the same either way, and the flag works alongside the Conversation Quality Index flags.

Don't build the list by concatenating `windows[].signals[]` yourself. That gives one entry per window, not V1's merged spans.

**Before (V1):**

```json theme={null}
{
  "signals": [
    {
      "type": "confidence",
      "start": 5.0,
      "end": 15.0,
      "probability": "medium",
      "rationale": "Steady voice with minimal hesitation."
    }
  ],
  "engagement_state": [
    { "start": 0.0, "end": 5.0, "state": "engaged" }
  ]
}
```

**After (V2), a completed job submitted with `include[]=signals`:**

```json theme={null}
{
  "job_id": "3f1c2b7a9d4e4c8fa1b2c3d4e5f60718",
  "status": "completed",
  "model": "inter-2",
  "created_at": "2026-09-11T10:00:00Z",
  "expires_at": "2026-09-11T11:00:00Z",
  "status_url": "/v2/upload/jobs/3f1c2b7a9d4e4c8fa1b2c3d4e5f60718",
  "result": {
    "duration_seconds": 10.0,
    "window_seconds": 5.0,
    "windows": [
      {
        "index": 0,
        "start_seconds": 0.0,
        "end_seconds": 5.0,
        "engagement_status": "engaged",
        "signals": [
          {
            "type": "confidence",
            "start": 0.0,
            "end": 5.0,
            "probability": "high",
            "rationale": "Steady eye contact, upright posture and a firm, even tone throughout."
          }
        ]
      },
      {
        "index": 1,
        "start_seconds": 5.0,
        "end_seconds": 10.0,
        "engagement_status": "neutral",
        "signals": []
      }
    ],
    "signals": [
      {
        "type": "confidence",
        "start": 0.0,
        "end": 5.0,
        "probability": "high",
        "rationale": "Steady eye contact, upright posture and a firm, even tone throughout."
      }
    ]
  }
}
```

### Upload checklist

* [ ] Send `model=inter-2`.
* [ ] Confirm your files carry both video and audio and fit the duration limit.
* [ ] Handle `202`, poll `status_url`, and stop on `completed` or `failed`.
* [ ] Send `include[]=signals` and read `result.signals` for V1's whole-file signal list, or read per-window signals from `result.windows[].signals[]`.
* [ ] Read engagement from `result.windows[].engagement_status`.
* [ ] Stop reading `modality`.
* [ ] Handle `ih4021` for expired jobs.

## Migrate stream

`WS /v2/stream/analyze` speaks the same protocol as `WS /v1/stream/analyze`: the same handshake and authentication, the same session config frame, binary video chunks in, and the same envelope types out, including the graceful `session.close` handshake. For most clients, the migration is a path change plus two payload details.

### 1. Change the path

Connect to `wss://api.interhuman.ai/v2/stream/analyze`.

**Before (V1):**

<CodeGroup>
  ```javascript JavaScript theme={null}
  const ws = new WebSocket("wss://api.interhuman.ai/v1/stream/analyze", [
    "access_token",
    clientToken,
  ]);
  ws.binaryType = "arraybuffer";

  ws.addEventListener("open", () => {
    ws.send(JSON.stringify({ include: ["conversation_quality_overall"] }));
  });
  ```

  ```python Python theme={null}
  import json
  import os

  import websockets

  headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"}

  ws = await websockets.connect(
      "wss://api.interhuman.ai/v1/stream/analyze",
      additional_headers=headers,
      max_size=None,
  )
  await ws.send(json.dumps({"include": ["conversation_quality_overall"]}))
  ```
</CodeGroup>

**After (V2):**

<CodeGroup>
  ```javascript JavaScript theme={null}
  const ws = new WebSocket("wss://api.interhuman.ai/v2/stream/analyze", [
    "access_token",
    clientToken,
  ]);
  ws.binaryType = "arraybuffer";

  ws.addEventListener("open", () => {
    ws.send(
      JSON.stringify({
        include: ["conversation_quality_overall"],
        model: "inter-2", // optional; inter-2 is the default
      }),
    );
  });
  ```

  ```python Python theme={null}
  import json
  import os

  import websockets

  headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"}

  ws = await websockets.connect(
      "wss://api.interhuman.ai/v2/stream/analyze",
      additional_headers=headers,
      max_size=None,
  )
  await ws.send(
      json.dumps({"include": ["conversation_quality_overall"], "model": "inter-2"})
  )
  ```
</CodeGroup>

### 2. Select the model (optional)

A V2 session opens on `inter-2` by default, so you do not need to name a model. To name one explicitly, use either of these:

* The `model` query parameter on the handshake: `wss://api.interhuman.ai/v2/stream/analyze?model=inter-2`. A value the endpoint cannot take is refused with an `ih4005` error envelope and close code `1008`.
* The `model` field of the session config frame. Unlike `include`, `model` is session state: a config frame that omits it keeps the current model, and a frame that changes it is accepted only before the first video chunk.

V2 reports the model on two envelopes that V1 leaves empty:

* `session.ready` lists the models you can select in `data.supported_session_config_options.model`.
* `session.updated` reports the active model in `data.model`.

On V1, both fields are `null`, and a session config frame that names a `model` is rejected.

If no backend serves the selected model, the session is refused after the handshake with an `ih1003` `error` envelope and close code `1013`. Nothing is analyzed or billed; retry later.

### 3. Update signal handling

`signal.detected` and `signal.updated` on V2 carry `signal_type`, `start`, `probability`, and `rationale`, with no `modality` field. Remove any code that reads or requires `data.modality`.

On V2, `signal.updated` is emitted when an active signal's `probability` or `rationale` changes, so an update can repeat the previous `probability`. V2 carries no `modality`, so a change in modality never triggers an update.

**Before (V1):**

```json theme={null}
{
  "type": "signal.detected",
  "timestamp": "2025-01-01T00:00:00.000000Z",
  "correlation_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "signal_type": "agreement",
    "start": 3.0,
    "probability": "high",
    "rationale": "Subject nodded repeatedly while maintaining eye contact.",
    "modality": ["video"]
  }
}
```

**After (V2):**

```json theme={null}
{
  "type": "signal.detected",
  "timestamp": "2025-01-01T00:00:00.000000Z",
  "correlation_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "signal_type": "agreement",
    "start": 3.0,
    "probability": "high",
    "rationale": "Subject nodded repeatedly while maintaining eye contact."
  }
}
```

Every other envelope keeps its V1 payload: `session.ready` and `session.updated` (apart from `model`), `signal.ended`, `engagement.updated`, `conversation_quality.updated`, `coverage.degraded`, `coverage.dropped`, `session.closing`, `session.ended`, and `error`.

### 4. Send audio with the video

Inter-2 requires sound as well as a picture. Record your chunks with an audio track, for example `video/webm;codecs=vp9,opus` from `MediaRecorder` with a stream from `getUserMedia({ video: true, audio: true })`.

### Stream checklist

* [ ] Change the path to `/v2/stream/analyze`.
* [ ] Optionally name `model` in the query string or in a session config frame sent before the first video chunk.
* [ ] Handle the `ih1003` refusal (close code `1013`) when no backend serves the model.
* [ ] Stop reading `data.modality` on `signal.detected` and `signal.updated`.
* [ ] Record chunks with an audio track.

## Migrate with the SDKs

The TypeScript and Python SDKs cover both versions.

* **Stream:** pass `{ apiVersion: "v2" }` to `client.stream()` in TypeScript, or `api_version="v2"` in Python. The event types are the same on both versions; on V2, `modality` is absent (TypeScript) or `None` (Python) on `signal.detected` and `signal.updated`.
* **Upload:** replace `analyze()` with `submit()`, which takes the model and returns the job envelope, and read the result with `waitForJob()` (TypeScript) or `wait_for_job()` (Python). A failed job is returned rather than thrown, so check its `status`. For V1's whole-file signal list, pass `UploadJobIncludeFlag.Signals` (TypeScript) or `UploadJobIncludeFlag.SIGNALS` (Python) in `include` and read `result.signals`. It requires SDK 1.3.0 or later.

See the [TypeScript SDK](/sdk-reference/typescript-sdk) and [Python SDK](/sdk-reference/python-sdk) references for the full signatures.
