Skip to main content

Real-Time Odds

Odds move while a bettor watches. One WebSocket channel carries those changes, so prices update without a page refresh.

The channel carries odds and nothing else. Everything else you poll.

Connect​

wss://<your predictions host>/ws/connection/websocket

Take the URL from your own configuration, and use wss:// only. Do not derive it from window.location: your page is served from your own domain, and that path does not exist there.

The browser connects to the platform directly. The connection limit counts per source IP, so do not route it through a server of your own.

The connection is anonymous. Do not build token minting for it. The platform does not use one.

Use the official client​

Use the centrifuge JavaScript client. It owns the transport, the reconnect, and the subscription state. Hand-rolling the protocol buys nothing.

Remove your handlers when a component stops watching an event. getSubscription returns the existing subscription for a channel, and unsubscribe() alone leaves both the subscription and its handlers in place. A bettor who opens the same event twice then gets every update twice.

import { Centrifuge } from 'centrifuge'

const centrifuge = new Centrifuge('wss://<your predictions host>/ws/connection/websocket', {})

// One subscription per event, shared by every component watching it.
const handlerCounts = new Map<string, number>()

function subscribeToEventOdds(eventId: string, onUpdate: (update: MarketOddsUpdate) => void) {
const channel = `odds:event-${eventId}`
const existing = centrifuge.getSubscription(channel)
const subscription = existing ?? centrifuge.newSubscription(channel, { recoverable: true })

const onPublication = (ctx) => onUpdate(ctx.data)
const onSubscribed = (ctx) => {
if (ctx.recovered === false) refetchEvent(eventId)
}

subscription.on('publication', onPublication)
subscription.on('subscribed', onSubscribed)
handlerCounts.set(channel, (handlerCounts.get(channel) ?? 0) + 1)

if (!existing) subscription.subscribe()

return () => {
subscription.off('publication', onPublication)
subscription.off('subscribed', onSubscribed)

const remaining = (handlerCounts.get(channel) ?? 1) - 1
handlerCounts.set(channel, remaining)

if (remaining === 0) {
handlerCounts.delete(channel)
subscription.unsubscribe()
centrifuge.removeSubscription(subscription)
}
}
}

centrifuge.connect()

Subscribe to one event​

The channel name is odds:event-<eventId>, where <eventId> is the Event id from the REST API. Subscribe to the events currently on screen.

There is exactly one channel pattern. No other channel exists.

Read the payload​

{
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"marketId": "def67890-e89b-41d4-a716-446655440002",
"externalId": "0x1f2a...",
"outcomes": [
{
"outcomeId": "abc12345-e89b-41d4-a716-446655440001",
"status": "active",
"displayOdds": "1.92",
"displayProbability": "52.0"
},
{
"outcomeId": "bcd23456-e89b-41d4-a716-446655440002",
"index": 1,
"status": "locked",
"displayOdds": "2.10",
"displayProbability": "47.6"
}
],
"occurredAt": "2026-09-11T10:04:11.402Z"
}

One publication carries one Market. An Event with several Markets sends several publications on the same channel.

Merge the channel into your REST data​

The two surfaces disagree on names and on lock authority. Follow these rules.

RuleWhy
Join on outcomeIdindex is absent when it is 0, so an index-based join drops the first outcome
The channel is camelCase, REST is snake_casedisplayOdds on the channel is display_odds over REST. A key-by-key merge silently drops every field
Accept a channel status: "locked"The channel applies a subset of the lock rules REST applies, so a lock it reports is real
Never let a channel status: "active" clear a lock REST reportedREST applies lock rules the channel does not carry
Recompute odds_display yourself from displayOddsThe channel does not carry the formatted string

RECOMMENDED: do not assert that displayProbability sums to 100. REST normalizes the outcome set before it answers. The channel does not.

Do not treat silence as health​

The channel carries odds only. Poll for bet status, as Placing Bets describes, and refetch the event for availability, settlement, and suspension.

Ticks stop when a market is suspended, and the last payload you received still reads active, so re-read the event before you rely on availability.

The absence of ticks is not a signal either way. A suspended market and a quiet market both publish nothing, so a maximum-age rule on channel data fires on healthy markets too. Do not set one.

RECOMMENDED: refetch the visible event when the window regains focus. Focus fires when a person is actually looking, and it costs nothing on an idle tab.

Recover from a connection gap​

Subscribe with recoverable: true. Centrifugo then replays the publications you missed during a short disconnect.

Recovery is best-effort and covers a short window.

RECOMMENDED: re-GET the event when subscribed fires with recovered === false. That flag is the only signal that you missed publications you cannot recover.

Diagnose a failed connection​

If the WebSocket handshake fails with HTTP 403, your origin is not registered.

The match is exact, on the full origin string:

  • http://app.example.com is refused when https://app.example.com is registered.
  • https://app.example.com:8443 is refused when https://app.example.com is registered.

Send your account manager the exact origin string, including the scheme and the port, and check every origin you serve from, including your staging and local development origins.