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.
| Rule | Why |
|---|---|
Join on outcomeId | index is absent when it is 0, so an index-based join drops the first outcome |
| The channel is camelCase, REST is snake_case | displayOdds 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 reported | REST applies lock rules the channel does not carry |
Recompute odds_display yourself from displayOdds | The 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.comis refused whenhttps://app.example.comis registered.https://app.example.com:8443is refused whenhttps://app.example.comis 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.