> ## Documentation index
> Fetch the complete documentation index at: https://docs.predictefy.com/llms.txt
> Use it to discover every available page before exploring further.

# WebSocket API reference

> Connection, authentication, subscriptions, acknowledgements, data frames, and capability-honest errors.

Connect to the production WebSocket endpoint:

```text
wss://stream-production-75db.up.railway.app/v1/stream
```

The service accepts JSON text frames. Venue names are normalized to lowercase; venue-native
market ids and feed symbols keep their case. API keys are never accepted in the URL.

## Authentication

Non-browser clients should send the API key in the upgrade request:

```http
Authorization: Bearer pk_live_YOUR_KEY
```

Browser clients cannot set that header. Their first frame must arrive within 10 seconds and have
this exact shape:

```json
{ "op": "auth", "apiKey": "pk_live_YOUR_KEY" }
```

Successful first-frame authentication returns this acknowledgement before queued subscription
acknowledgements:

```json
{ "type": "auth", "status": "ok" }
```

Header-authenticated clients do not receive an auth acknowledgement. A missing, invalid, unknown,
revoked, or non-read-scoped key first receives an `UNAUTHORIZED` error frame, then the service
closes the socket with code `4001`. A browser client that sends another operation before auth, or
does not authenticate before the deadline, is closed the same way.

## Client subscription frames

`marketId` is the venue-native upstream id. For example, a Polymarket order book uses the
outcome's CLOB asset/token id, while Hyperliquid uses its coin symbol.

Per-market order books and trades:

```json
{ "op": "subscribe", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
{ "op": "unsubscribe", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
{ "op": "subscribe", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }
{ "op": "unsubscribe", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }
```

Venue-wide order books are available only when the upstream implements a real firehose or
multiplexed stream:

```json
{ "op": "subscribeAll", "channel": "orderbook", "venue": "polymarket" }
{ "op": "unsubscribeAll", "channel": "orderbook", "venue": "polymarket" }
```

Reference-feed tickers use `feed` and `symbol`, not `venue` and `marketId`:

```json
{ "op": "subscribeFeedTicker", "feed": "binance", "symbol": "BTC/USDT" }
{ "op": "unsubscribeFeedTicker", "feed": "binance", "symbol": "BTC/USDT" }
```

The venue option-price lane carries the on-chain market address separately. It is a price stream,
not an order book:

```json
{ "op": "subscribePrice", "venue": "rain", "marketId": "<market_id>", "marketAddress": "0x..." }
{ "op": "unsubscribePrice", "venue": "rain", "marketId": "<market_id>", "marketAddress": "0x..." }
```

The executable-arbitrage lane is cross-venue. Like the feed-ticker lane it carries neither `venue`
nor `marketId` — one shared surface spans every priced venue:

```json
{ "op": "subscribeArbitrage" }
{ "op": "unsubscribeArbitrage" }
```

## Acknowledgement frames

A successful per-market subscription is acknowledged before any cached snapshot or live frame:

```json
{ "type": "subscribed", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
{ "type": "unsubscribed", "channel": "orderbook", "venue": "polymarket", "marketId": "<asset_id>" }
```

The other acknowledgement shapes are:

```json
{ "type": "subscribed", "channel": "trades", "venue": "hyperliquid", "marketId": "BTC" }
{ "type": "subscribed", "channel": "orderbook:all", "venue": "polymarket" }
{ "type": "subscribed", "channel": "feedTicker", "feed": "binance", "symbol": "BTC/USDT" }
{ "type": "subscribed", "channel": "price", "venue": "rain", "marketId": "<market_id>" }
```

An `unsubscribed` acknowledgement uses the same fields. A Rain, XO, or PRED trade subscription
served from the configured chain-scanner tape also adds a `disclosure` object:

```json
{
  "type": "subscribed",
  "channel": "trades",
  "venue": "rain",
  "marketId": "<market_id>",
  "disclosure": {
    "provenance": "chain-scan",
    "latencyMs": 90000,
    "completeness": "<venue-specific omission disclosure>"
  }
}
```

The cross-venue arbitrage lane has nothing to echo, so its acknowledgements carry `channel` alone:

```json
{ "type": "subscribed", "channel": "arbitrage" }
{ "type": "unsubscribed", "channel": "arbitrage" }
```

## Order-book frames

For a per-market order-book subscription, the first book is a `snapshot`. Later venue ticks are
`update` frames. Both contain a complete book, never a delta. Prices are probabilities in `[0, 1]`;
bids are best-first descending and asks are best-first ascending.

```json
{
  "type": "snapshot",
  "venue": "polymarket",
  "marketId": "<asset_id>",
  "data": {
    "bids": [{ "price": 0.4, "size": 10 }],
    "asks": [{ "price": 0.42, "size": 8 }],
    "timestamp": 1780000000000
  },
  "ts": 1780000000123
}
```

```json
{
  "type": "update",
  "venue": "polymarket",
  "marketId": "<asset_id>",
  "data": {
    "bids": [{ "price": 0.41, "size": 9 }],
    "asks": [{ "price": 0.43, "size": 7 }],
    "timestamp": 1780000000200
  },
  "ts": 1780000000210
}
```

When backpressure coalesces skipped book ticks, the latest complete book is sent as another
`snapshot` once the socket drains.

## Trade frames

Native and configured chain-scanner trade subscriptions share one frame shape:

```json
{
  "type": "trade",
  "venue": "hyperliquid",
  "marketId": "BTC",
  "data": {
    "id": "<trade_id>",
    "time": "2s ago",
    "timestamp": 1780000000000,
    "type": "Buy",
    "usd": 125.5,
    "outcome": "Yes",
    "outcomeIndex": 0,
    "shares": 10,
    "price": 0.55,
    "maker": "hyperliquid",
    "transactionHash": "<transaction_id>",
    "wallet": "0x...",
    "counterparty": "0x..."
  },
  "ts": 1780000000123
}
```

`outcomeIndex`, `wallet`, and `counterparty` can be absent or `null`. `usd` and `price` can be
`null` on parimutuel venues where execution-time values do not exist. A chain-scanner frame adds
`"provenance": "chain-scan"` at the top level. Trade frames are dropped rather than buffered while
the client is backpressured.

## Feed-ticker and option-price frames

Feed tickers carry the normalized ticker under `data`. Only `symbol`, `asOf`, and `provenance` are
always present; price, volume, timestamp, datetime, and `sourceMetadata` fields are present only
when the upstream proves them.

```json
{
  "type": "feedTicker",
  "feed": "binance",
  "symbol": "BTC/USDT",
  "data": {
    "symbol": "BTC/USDT",
    "last": 61714.63,
    "asOf": "2026-08-13T12:00:00.000Z",
    "provenance": { "source": "binance-ws" },
    "sourceMetadata": { "transport": "websocket" }
  },
  "ts": 1780000000123
}
```

The option-price lane currently relays the normalized Rain frame:

```json
{
  "type": "price",
  "venue": "rain",
  "marketId": "<market_id>",
  "marketAddress": "0x...",
  "data": {
    "provider": "rain",
    "marketId": "<market_id>",
    "marketAddress": "0x...",
    "prices": [{ "choiceIndex": 0, "label": "Yes", "price01": 0.55, "rawPrice": "550000000000000000" }],
    "triggeredBy": {
      "eventName": "<event_name>",
      "transactionHash": "0x...",
      "blockNumber": "123",
      "logIndex": 4
    },
    "asOfISO": "2026-08-13T12:00:00.000Z"
  },
  "ts": 1780000000123
}
```

`choiceIndex`, `label`, and `rawPrice` can be `null`. Every field inside `triggeredBy` can also be
`null`, and the whole object can be `null`. Feed-ticker and option-price frames are dropped rather
than buffered under backpressure.

## Arbitrage frames

The arbitrage lane relays one shared server-side recompute of the cross-venue executable-arbitrage
surface — the streaming twin of `GET /api/router/fetchArbitrage`. Every frame is the whole current
surface; there is no incremental delta.

```json
{
  "type": "arbitrage",
  "data": {
    "exchange": "router",
    "computedAt": "2026-08-13T12:00:00.000Z",
    "publishedAt": "2026-08-13T12:00:00.004Z",
    "intervalMs": 3000,
    "heartbeatMs": 30000,
    "contracts": 100,
    "limit": 10,
    "rows": [
      {
        "clusterId": "cluster:real",
        "question": "Will Team A win?",
        "similarity": 0.92,
        "contracts": 100,
        "legs": {
          "buyYes": {
            "venue": "polymarket",
            "canonicalMarketId": "polymarket:real",
            "side": "yes",
            "executable": true,
            "reasons": [],
            "vwap": 0.41,
            "cost": 41,
            "fee": 0,
            "filled": 100,
            "fullyFilled": true
          },
          "buyNo": {
            "venue": "kalshi",
            "canonicalMarketId": "kalshi:real",
            "side": "no",
            "executable": true,
            "reasons": [],
            "vwap": 0.45,
            "cost": 45,
            "fee": 0.7,
            "filled": 100,
            "fullyFilled": true
          }
        },
        "resolution": { "compatible": true, "reason": "", "auditReasons": [] },
        "settlementFee": 0,
        "totalCost": 86.7,
        "payout": 100,
        "netEdge": 13.3,
        "roi": 0.1534,
        "resolutionEquivalence": "verified",
        "executable": true,
        "reasons": [],
        "label": "arbitrage",
        "asOf": "2026-08-13T11:59:58.000Z"
      }
    ]
  },
  "ts": 1780000000123
}
```

A row is labeled `arbitrage` only when it has positive net edge, both legs are depth-executable at
the requested size against live asks, and resolution equivalence is `verified`. Every other row is
served as `indicative price discrepancy` with the per-leg `reasons` codes explaining why. Rows are
never filtered down to the winners — the indicative rows are part of the surface, with their
evidence.

`intervalMs` is the true recompute cadence (3000 ms by default). This is a shared server-side
recompute, not a tick-by-tick feed: a frame is published only when the priced surface actually
changed — a re-read of the same books carries a fresher `asOf` and is not treated as a change —
plus a heartbeat republish so a quiet market is distinguishable from a dead publisher. A heartbeat
frame is a genuine recompute against live books rather than a replay, so its `computedAt` and
`publishedAt` are truthful.

`heartbeatMs` is the bound the server computed for its own configuration, not a nominal target:
a republish can only happen on a recompute tick, so the advertised value is the first tick at or
after the 30000 ms target — 30000 ms at the default 3000 ms interval, and 40000 ms at a 20000 ms
one. Take it from the frame rather than hard-coding it. A healthy publisher republishes at least
that often, so sustained silence beyond the advertised `heartbeatMs` means a publisher outage or
an entitlement teardown, not a quiet market.

The three timestamps let you measure the lane instead of trusting it. `publishedAt − computedAt` is
the time the server spent turning a finished computation into a published frame, and `ts −
publishedAt` is the relay and fan-out to your socket; both are sub-millisecond in practice, because
nothing on that path buffers, batches, or waits for a timer. The recompute interval is the only
deliberate delay in the lane, and it exists to bound upstream venue API cost rather than as a design
preference — expect a change to surface within one interval, and on average within half of one.

A new subscriber receives the current surface immediately after its `subscribed` acknowledgement,
when one is available. If nothing has been published since the service last had a subscriber, no
snapshot is sent and the first frame arrives on the next recompute.

Per-leg `vwap`, `cost`, and `fee` are `null` whenever the leg cannot honestly be priced as the
claimed trade; `note` and `feeBasis` are present only where the venue's fee model needs them.
`settlementFee`, `totalCost`, `netEdge`, and `roi` are `null` when the pair cannot be priced, and
`asOf` is `null` when either book is unavailable. Arbitrage frames are dropped rather than coalesced
or buffered under backpressure — because each frame is the whole surface, the next frame supersedes
anything skipped.

## Errors and unsupported capabilities

Protocol errors are JSON frames. Depending on the failed operation they echo `venue`, `marketId`,
`channel`, `feed`, or `symbol`:

```json
{
  "type": "error",
  "code": "NOT_SUPPORTED",
  "message": "venue 'smarkets' has no native live trade stream",
  "venue": "smarkets",
  "marketId": "<market_id>",
  "channel": "trades"
}
```

If a venue has neither a native live trade stream nor a configured chain-scanner tape, a trades
subscription returns this honest `NOT_SUPPORTED` frame. The socket stays open, no subscription is
created, and the service never fabricates polling or trade data. Unsupported order-book,
venue-wide, feed-ticker, and option-price subscriptions follow the same non-fatal pattern.
`subscribeArbitrage` answers the same `NOT_SUPPORTED` code — echoing `channel` alone — on
deployments where the arbitrage publisher is not enabled.

The arbitrage channel is additionally gated on the same `arbitrage` plan feature as the REST verb
`GET /api/router/fetchArbitrage`. A key whose plan does not include it receives a non-fatal
`PLAN_UPGRADE_REQUIRED` frame instead of a subscription:

```json
{
  "type": "error",
  "code": "PLAN_UPGRADE_REQUIRED",
  "message": "the \"arbitrage\" feature requires the Builder plan or higher (current plan: \"free\")",
  "channel": "arbitrage"
}
```

The Free plan does not include it. The socket stays open and no subscription is created.

The same frame is also sent mid-stream. Entitlements are re-checked on the connection's per-minute
metering tick against uncached key state, so a plan that stops entitling the feature loses the
arbitrage subscription within about a minute: the service unsubscribes it, releases its
subscription slot, and sends `PLAN_UPGRADE_REQUIRED`. If instead the API key itself has stopped
verifying — revoked, deleted, or rotated — the channel is torn down the same way but the frame
carries `UNAUTHORIZED`, because that caller needs to re-authenticate rather than upgrade.

In both cases the socket is never closed and every other subscription on it continues. A
verification attempt that FAILS to complete changes nothing: only a fresh, conclusive answer tears
the channel down, so an unreachable key store never interrupts a paying customer.

Other non-fatal operation codes are `BAD_MESSAGE`, `NOT_SUBSCRIBED`, `MARKET_NOT_FOUND`,
`SUBSCRIPTION_LIMIT`, and post-auth `RATE_LIMITED`. Authentication, credit, platform, connection,
and server-lifecycle failures can close the connection after their error frame or close reason.
