# The WebSocket in Hoppscotch

`/v1/stream` carries requests as messages, subscriptions and pushed events (AsyncAPI: https://developers.igh.app/asyncapi.html).

Hoppscotch has no WebSocket requests to import: a collection holds HTTP and GraphQL requests only, and the **Realtime → WebSocket** tab keeps just one URL and its protocols, with no saved messages, no headers and no environment variables. So the WebSocket is set up by hand there, once, from this page.

## The URLs

| Environment | `wsUrl` |
| --- | --- |
| IGH local (home network) | `wss://igh-<<serial>>.local:8490/v1/stream` |
| IGH staging cloud | `wss://api.staging.igh.app/v1/stream` |
| IGH cloud | `wss://api.igh.app/v1/stream` |

The Realtime tab can't set an `Authorization` header (it uses the app window's own WebSocket), so the token goes in the query: `?token=<the token>`. The gateway and the cloud both accept the query token on the upgrade (the cloud strips it before relaying). The tab doesn't fill `<<…>>` variables either: paste the token itself, copied from the environment (the eye button shows a secret's value).

## Connect

1. **Realtime** (left bar) → **WebSocket**.
2. URL: the environment's `wsUrl` with the token, for example `wss://api.igh.app/v1/stream?token=igh_agent_…`. Leave **Protocols** empty.
3. **Connect**. The log shows the connection, then every frame.
4. In the message box, pick **JSON**, paste a message below, replace each `<<…>>` with its value (the Realtime tab sends the text as it is), **Send**.

On the home network (`wss://igh-<serial>.local:8490/v1/stream`) the WebSocket is opened by the app window, not by the Native interceptor, so the CA certificate of README step 1 doesn't apply to it: it trusts the system's CAs. Either add the gateway's CA to the system's trust (Fedora: `sudo trust anchor ca.pem`; every program on the computer then trusts it), or use a cloud URL. Never accept an untrusted certificate instead.

## The messages to send

A request message `{ id, method, path, query?, body? }` (`path` with `/v1`, as on HTTPS) is answered `{ id, status, body }`; `{ id, subscribe: [...] }` chooses the topics (`state`, `sensor`, `scene`, `exposure`; all four until a client subscribes).

### Subscribe: state

Only `state` events from now on (answered `{ id, status: 200, body: { subscribed } }`).

```json
{
  "id": "subscribe-state",
  "subscribe": [
    "state"
  ]
}
```

### Subscribe: sensors

Only `sensor` events from now on (answered `{ id, status: 200, body: { subscribed } }`).

```json
{
  "id": "subscribe-sensor",
  "subscribe": [
    "sensor"
  ]
}
```

### Subscribe: scenes

Only `scene` events from now on (answered `{ id, status: 200, body: { subscribed } }`).

```json
{
  "id": "subscribe-scene",
  "subscribe": [
    "scene"
  ]
}
```

### Subscribe: exposure

Only `exposure` events from now on (answered `{ id, status: 200, body: { subscribed } }`).

```json
{
  "id": "subscribe-exposure",
  "subscribe": [
    "exposure"
  ]
}
```

### Subscribe: everything

All four topics (what a new socket gets until it subscribes).

```json
{
  "id": "subscribe-all",
  "subscribe": [
    "state",
    "sensor",
    "scene",
    "exposure"
  ]
}
```

### Request: state now

`GET /v1/state` over the socket: read it once after (re)connecting to catch up.

```json
{
  "id": "state",
  "method": "GET",
  "path": "/v1/state"
}
```

### Command: light on [switches a device]

A request over the socket, answered `{ id, status, body }` as over HTTPS. **Switches a real device.**

```json
{
  "id": "light-on",
  "method": "POST",
  "path": "/v1/devices/<<lightId>>/control",
  "body": {
    "op": "set_power",
    "args": {
      "on": true
    }
  }
}
```

### Command: light off [switches a device]

A request over the socket, answered `{ id, status, body }` as over HTTPS. **Switches a real device.**

```json
{
  "id": "light-off",
  "method": "POST",
  "path": "/v1/devices/<<lightId>>/control",
  "body": {
    "op": "set_power",
    "args": {
      "on": false
    }
  }
}
```

### Keep-alive (no ping message: a cheap request)

The stream has no ping message, and a text that is not JSON (`ping`) closes it (4400 `invalid_request`). WebSocket ping frames are answered by the server; to see traffic, send this cheap request.

```json
{
  "id": "keep-alive",
  "method": "GET",
  "path": "/v1/about"
}
```

## What arrives

### state

A unit's or gang's new state.

```json
{
  "event": "state",
  "house": "Cohen Home",
  "device": "8F3K2M9Q4T7W:5b0e6c1a-2f4d-4c3b-9a8e-7d6f5e4c3b2a:u-kitchen-light",
  "class": "light_dimmer",
  "state": {
    "on": true,
    "bri": 40
  },
  "at": 1760780000000
}
```

### sensor

A sensor reading or detection.

```json
{
  "event": "sensor",
  "house": "Cohen Home",
  "device": "8F3K2M9Q4T7W:5b0e6c1a-2f4d-4c3b-9a8e-7d6f5e4c3b2a:u-hall-sensor",
  "class": "sensor",
  "state": {
    "motion": true,
    "lux": 120
  },
  "at": 1760780000000
}
```

### scene

A scene runs (`running` true, then false).

```json
{
  "event": "scene",
  "house": "Cohen Home",
  "device": "8F3K2M9Q4T7W:5b0e6c1a-2f4d-4c3b-9a8e-7d6f5e4c3b2a:scene:s-evening",
  "class": "scene",
  "state": {
    "running": true
  },
  "at": 1760780000000
}
```

### exposure

What the integration shows changed: read `GET /v1/house` again.

```json
{
  "event": "exposure",
  "house": "Cohen Home",
  "at": 1760780000000
}
```

### answer

The answer to a request message.

```json
{
  "id": "light-on",
  "status": 200,
  "body": {
    "house": "Cohen Home",
    "device": "8F3K2M9Q4T7W:5b0e6c1a-2f4d-4c3b-9a8e-7d6f5e4c3b2a:u-kitchen-light",
    "state": {
      "on": true,
      "bri": 40
    }
  }
}
```

### error (rate limited, the socket stays open)

Through the cloud, a message past the limit.

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests for this home. Wait and try again.",
    "retryAfter": 1
  }
}
```

### error (before a close)

A credential that stopped passing: this frame, then close 4403 `agent_access_disabled`.

```json
{
  "error": {
    "code": "agent_access_disabled",
    "message": "Agent access is off for this API integration.",
    "appAction": "integrations.record"
  }
}
```

## Errors and close codes

- A refused upgrade (401 without or with a bad credential, 403, 429 past 20 sockets per home through the cloud, 503 `gateway_offline`) shows in the log as a failed connection: the Realtime tab doesn't show the HTTP answer. Send `GET /v1/integration` from the collection with the same token to see why.
- A message that is refused (bad JSON shape, 413 over 16 KiB, 429 `rate_limited` with `retryAfter` through the cloud) is answered with an error frame or `{ id, status, body }`; the socket stays open.
- A text that is not JSON (`ping`) closes the socket with 4400 `invalid_request`.
- A credential that stops passing (revoked, Agent access or Cloud access turned off, the integration disabled) gets an `{ error }` frame, then the close code **4000 + the HTTP status** with the error code as the reason: 4401 `unauthorized` or `session_expired`, 4403 `agent_access_disabled`, `cloud_access_disabled` or `record_disabled`, 4404, 4429, and 4503 `gateway_offline` when the gateway leaves the cloud.
- On AWS (api.igh.app, api.staging.igh.app) the close carries no code: the error frame is the only signal.
- Reconnect with a backoff, then send "Request: state now" to catch up.
