---
title: WebSocket Protocol
description: >-
  External clients cannot open a job WebSocket; follow jobs with the SSE job
  event proxy instead
tags:
  - realtime
  - websocket
  - streaming
---
The platform does not offer a public WebSocket endpoint. The job WebSocket is internal to the compute backend, and external clients cannot connect to it. To follow a job in real time, use the [SSE job event proxy](/docs/api/realtime-sse#job-event-proxy-sse). The web app opens the job socket on its own server and relays every update to you over plain HTTP, so you get the same updates without a socket to manage.

  intermediate
  6 min
  [authentication](/docs/api/authentication)

## Following a job in real time

> Earlier versions of this page documented a `wss://` URL on a separate API host. That host does not serve external traffic, and no WebSocket URL on our domain replaces it. Code that opens a job WebSocket directly will not connect. Move it to the SSE stream below.

```
GET https://www.ivybiosciences.com/api/v1/jobs/{jobId}/events
Authorization: Bearer <clerk_session_token>
```

The stream needs a signed-in session: a Clerk session JWT in the `Authorization` header, or, from a browser on the same origin, the session cookie. An `ivy_` API key alone is refused with `503`, so a server holding only a key polls `GET /api/v1/jobs/{jobId}` instead. The route checks that the caller can read the job before it opens the upstream socket.

| Behavior | Detail |
|----------|--------|
| Update cadence | Pushed as the job changes, typically every 1-5 seconds while it computes |
| Terminal status | The stream closes itself after it relays a terminal status (`COMPLETED`, `SUCCESS`, `FAILURE`, `FAILED` or `CANCELLED`) |
| Maximum length | About five minutes per connection, then a `close` event. Open the stream again to keep following a long job |
| Guest jobs | `GET /api/v1/jobs/{jobId}/guest-events?guest_id=guest_...` needs no credential |

---

## Update payload

Each `job_update` event carries the job's status in `data`:

```json
{
  "type": "job_update",
  "data": {
    "job_id": "job_abc123",
    "status": "PROCESSING",
    "progress": 45,
    "phase_name": "main_computation",
    "result": null,
    "error_message": null
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `data.job_id` | `string` | Job identifier |
| `data.status` | `string` | Current status (PENDING, STARTED, PROCESSING, etc.) |
| `data.progress` | `number` | Completion percentage (0-100) |
| `data.phase_name` | `string` | Current computational phase |
| `data.result` | `object\|null` | Result data (only when COMPLETED) |
| `data.error_message` | `string\|null` | Error description (only when the job fails) |

The full list of event names (`subscribed`, `job_update`, `error`, `close`) is in [SSE Streaming](/docs/api/realtime-sse#event-names).

---

## Implementation Examples

**Browser**

In a signed-in browser on the same origin, `EventSource` sends the session cookie and reconnects on its own.

```ts
const source = new EventSource(`/api/v1/jobs/${jobId}/events`)

source.addEventListener("job_update", (event) => {
  const { data } = JSON.parse(event.data)
  console.log(`[${data.phase_name}] ${data.progress}% — ${data.status}`)
})

source.addEventListener("close", () => source.close())
```

**Node.js**

`EventSource` cannot set an `Authorization` header, so a server holding an API key reads the stream with `fetch`.

```ts
const response = await fetch(
  `https://www.ivybiosciences.com/api/v1/jobs/${jobId}/events`,
  { headers: { Authorization: `Bearer ${process.env.IVY_API_KEY}` } },
)

const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ""

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })
  const frames = buffer.split("\n\n")
  buffer = frames.pop() ?? ""
  for (const frame of frames) {
    const event = frame.match(/^event: (.*)$/m)?.[1]
    const data = frame.match(/^data: (.*)$/m)?.[1]
    if (event === "job_update" && data) {
      const update = JSON.parse(data).data
      console.log(`[${update.phase_name}] ${update.progress}% — ${update.status}`)
    }
  }
}
```

**Python**

```python
import json
import httpx

def monitor_job(job_id: str, session_token: str):
    url = f"https://www.ivybiosciences.com/api/v1/jobs/{job_id}/events"
    headers = {"Authorization": f"Bearer {session_token}"}

    with httpx.stream("GET", url, headers=headers, timeout=None) as response:
        response.raise_for_status()
        event = None
        for line in response.iter_lines():
            if line.startswith("event:"):
                event = line[len("event:"):].strip()
            elif line.startswith("data:") and event == "job_update":
                update = json.loads(line[len("data:"):])["data"]
                print(f"[{update.get('phase_name', '?')}] {update.get('progress', 0)}%")

monitor_job("job_abc123", "<clerk_session_token>")
```

If the stream ends before the job finishes, open it again. For a job you only need to check occasionally, poll [`GET /api/v1/jobs/{jobId}`](/docs/api/rest-jobs) instead.

## Read next

- [SSE Streaming](/docs/api/realtime-sse) — the job event proxy and harness streams
- [SDK real-time updates](/docs/api/sdk-websocket) — the planned SDK wrapper
- [Jobs API](/docs/api/rest-jobs) — HTTP polling alternative
