# Realtime

A socket transport that tells you when something moved. It carries the **same**
contracts as the HTTP routes — the same events, the same sequence numbers, the
same payloads.

```
wss://api.helixodds.com/realtime
```

## What it is, and what it is not

| | |
|---|---|
| **Bootstrap** | HTTP. `GET /fixtures`, `GET /live`. Always. |
| **Recovery** | HTTP. `GET /changes?cursor=…`. |
| **Notification** | this transport. |

The socket never hands you the initial state. A subscribe tells you where the
sequence currently is; the picture comes from the HTTP routes. That is
deliberate: a second bootstrap path would mean a second set of pagination rules,
a second set of failure modes and a second set of bugs.

**Everything here is readable over HTTP.** If your network or proxy will not
upgrade a websocket, you lose latency and nothing else — poll `/changes` instead
and your integration is complete. Build it that way and the socket becomes an
optimisation you can switch on, not a dependency you have to debug.

## Connecting

Socket.IO v4 client, with your HelixOdds client token.

```js
import { io } from 'socket.io-client';

const socket = io('https://api.helixodds.com', {
  path: '/realtime',
  transports: ['websocket', 'polling'],
  auth: { token: process.env.HELIXODDS_TOKEN },
});
```

A server-to-server client that cannot set `auth` may send the header instead:

```js
const socket = io('https://api.helixodds.com', {
  path: '/realtime',
  extraHeaders: { Authorization: `Bearer ${process.env.HELIXODDS_TOKEN}` },
});
```

The token identifies the tenant. There is no account parameter, and nothing you
send over the socket can change which account you are.

### Refusals

A handshake is refused with the **same codes** the HTTP API uses, on
`connect_error`:

```js
socket.on('connect_error', (error) => {
  // error.data = { code, message, request_id }
  switch (error.data?.code) {
    case 'INVALID_API_TOKEN':  // wrong or unknown token — do not retry
    case 'CLIENT_SUSPENDED':
    case 'CLIENT_REVOKED':
    case 'ORIGIN_NOT_ALLOWED': // browser origin not in your allowlist
      return stop(error.data);
    case 'RATE_LIMITED':       // too many open connections for this account
      return backOff();
    default:
      return backOff();
  }
});
```

Up to **8** connections per account. A reconnect loop in one client must not
become everyone's problem.

## The handshake tells you what you may ask for

```js
socket.on('ready', (ready) => {
  // {
  //   client_id, contract: 'v1', server_time, tick_ms,
  //   channels: [
  //     { channel: 'changes', mirrors: 'GET /api/v1/changes', available: true,  reason: null },
  //     { channel: 'live',    mirrors: 'GET /api/v1/live',    available: false, reason: 'SUBSCRIPTION_REQUIRED' }
  //   ],
  //   bootstrap: { fixtures: '…', changes: '…', note: '…' }
  // }
});
```

A channel your account does not hold is listed as unavailable with the reason.
You do not have to try it to find out.

## Channels

| Channel | Mirrors | Requires |
|---|---|---|
| `changes` | `GET /api/v1/changes` | `market_engine` + a connected provider |
| `live` | `GET /api/v1/live` | `live_intelligence` + `live_match` + a connected provider |

A deployment may narrow the set. The handshake is the authority on what is
actually available to you.

## `changes`

The incremental feed. Bootstrap over HTTP, then subscribe with the cursor it
gave you.

```js
// 1. get your picture and a cursor over HTTP
const page = await get('/changes?limit=200');
let cursor = page.pagination.next_cursor;   // opaque, for the HTTP route

// 2. subscribe with the numeric seq the HTTP route reported
socket.emit('subscribe', { channel: 'changes', cursor: page.meta.window.last_seq },
  (answer) => {
    if (!answer.ok) return handle(answer.error);
    // answer = { ok, channel, cursor, events, window, mirrors }
  });

// 3. receive
socket.on('changes', ({ events, cursor }) => {
  for (const event of events) refresh(event.fixture_id);
  store(cursor);          // resume from here after a disconnection
});
```

### Where a subscription starts

| `cursor` | Behaviour |
|---|---|
| absent | start at the newest event — you receive what happens **next** |
| `N` | receive everything after `N`, oldest first |

`backlog` **caps the page**; it does not decide whether there is one. Anything
the cap leaves over arrives on the next tick, so a small cap costs a round trip
and never a gap. The maximum is **200**; past that you are re-bootstrapping and
should use the HTTP route, which paginates properly.

A cursor older than the retained window is refused with `INVALID_CURSOR` and a
message naming the HTTP route. We would rather tell you there is a gap than
stream you a feed that quietly skips.

### The events are the HTTP route's events

```json
{
  "seq": 418,
  "type": "fixture_markets_updated",
  "fixture_id": "fx_0011bd8f26e2625aae7576f3",
  "published_at": "2026-10-08T08:22:11.004Z",
  "cycle_id": "cyc-2026100808",
  "provenance": { "provider_fixture_id": "202419381" }
}
```

Field for field what `GET /api/v1/changes` returns, from the same mapper. A gate
compares the two on every run.

`fixture_markets_updated` means **refreshed**, not **changed** — see
[Data contract](data-contract-v1.md). Re-read the fixture to see what it holds.

The engine runs hourly, so this channel is quiet most of the time. That is the
steady state, not a broken connection.

## `live`

The in-play list, pushed when the snapshot advances — seconds, not hours. This
is where the transport earns its place.

```js
socket.emit('subscribe', { channel: 'live' }, (answer) => {
  // { ok, channel, processed_at, fixture_count, note }
  // The fixtures themselves come from GET /api/v1/live.
});

socket.on('live', ({ data, meta }) => {
  // `data` is exactly the body of GET /api/v1/live
  // meta = { live_fixture_count, provider_fetched_at, processed_at, published_at }
  render(data);
});
```

Bounded by the same page ceiling as the HTTP route (**200**).
`meta.live_fixture_count` is the true total, so you can tell a bounded page from
a complete one.

## A channel can close under you

This is the part most worth handling. A connection outlives a subscription
state, so an expiry, a suspension or a disabled provider ends the channel while
you are still connected:

```js
socket.on('channel_closed', ({ channel, error }) => {
  // error.code ∈ SUBSCRIPTION_REQUIRED | FEATURE_NOT_ENABLED |
  //              CLIENT_SUSPENDED | CLIENT_REVOKED | PROVIDER_NOT_CONNECTED
  fallBackToPolling(channel);
});
```

Entitlement is re-resolved on **every tick**, not cached at subscribe. When the
reason is fixed, subscribe again — the socket stays open.

`unsubscribe` stops a channel without disconnecting:

```js
socket.emit('unsubscribe', { channel: 'live' }, () => {});
```

## Cadence

The server looks for something new every **2 seconds** (`tick_ms` in the
handshake). Both sources move more slowly than that, so the tick is not your
latency floor — the data's own cadence is.

## Usage accounting

A `subscribe` counts as **one request** against your usage, attributed to the
channel's product. **Pushed events are not counted.** They are not requests, and
metering them would change what the counter means.

Realtime consumption is therefore not metered per event in v1. Counters remain
[telemetry](public-api-v1.md), not billing records.

## A complete consumer

```js
import { io } from 'socket.io-client';

const BASE = 'https://api.helixodds.com';
const TOKEN = process.env.HELIXODDS_TOKEN;

const get = async (path) => {
  const response = await fetch(`${BASE}/api/v1${path}`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  const body = await response.json();
  if (!response.ok) throw new Error(body.error.code);
  return body;
};

// 1. BOOTSTRAP over HTTP — always, and after any gap
let cursor = null;
for (let page = await get('/fixtures?limit=200'); ; ) {
  for (const fixture of page.data) store(fixture);
  if (!page.pagination.next_cursor) break;
  page = await get(`/fixtures?limit=200&cursor=${encodeURIComponent(page.pagination.next_cursor)}`);
}
cursor = (await get('/changes?limit=1')).meta.window.last_seq;

// 2. POLLING is the baseline. It is complete on its own.
const poll = setInterval(async () => {
  const page = await get(`/changes?limit=200${cursor ? `&cursor=${encodeURIComponent(btoa(JSON.stringify({ s: cursor })))}` : ''}`);
  for (const event of page.data) { refresh(event.fixture_id); cursor = event.seq; }
}, 60_000);

// 3. The socket makes it faster. If it never connects, step 2 still works.
const socket = io(BASE, { path: '/realtime', auth: { token: TOKEN } });

socket.on('ready', (ready) => {
  for (const channel of ready.channels) {
    if (!channel.available) continue;
    socket.emit('subscribe',
      channel.channel === 'changes' ? { channel: 'changes', cursor } : { channel: 'live' },
      (answer) => { if (!answer.ok) console.warn(answer.error.code); });
  }
  clearInterval(poll);                 // the socket is live; slow the polling down
});

socket.on('changes', ({ events, cursor: next }) => {
  for (const event of events) refresh(event.fixture_id);
  cursor = next;
});

socket.on('live', ({ data }) => render(data));

socket.on('channel_closed', ({ channel, error }) => console.warn(channel, error.code));
socket.on('disconnect', () => resumePolling(cursor));   // back to step 2
socket.on('connect_error', (e) => resumePolling(cursor));
```

The shape worth copying: **polling is the baseline and the socket accelerates
it.** Not the other way around.

## Deployment note

The transport shares the API's host, port and TLS. Your reverse proxy must
forward the websocket upgrade:

```nginx
location /realtime/ {
    proxy_pass http://127.0.0.1:4310;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host       $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 120s;
}
```

Without those headers the upgrade fails and clients fall back to long-polling —
which still works, so a misconfigured proxy shows up as higher latency rather
than as an outage. Worth checking on purpose rather than discovering later.

See also: [Public API](public-api-v1.md) · [Data contract](data-contract-v1.md) ·
[Live Intelligence](live-intelligence.md) · [Errors](errors.md)
