Documentation · v1
Streaming API reference
One WebSocket with Pinnacle odds as they move, plus a small REST API to look up the current state. This page is the whole client side: log in, filters, snapshots, keeping up, close codes and reconnection.
- Stream
-
wss://feed.oddspush.com
/v1/stream - Messages
- JSON, one per frame
- Access
- Key, one IP at a time
Getting started
Overview
OddsPush reads a live Pinnacle odds feed and pushes every update to the clients whose filter matches
it. An update is never parsed and re-encoded on the way: what you receive in data is the
frame exactly as our source publishes it, byte for byte. Snapshots are the exception: we build them from our own copy of the
state, in the same record shape, so one merge handles both.
Every message, in both directions, is a JSON object with a type field. A session looks
like this:
-
You
{"type": "login", "token": "opk_…"} -
Server
{"type": "welcome", …}Feed state, wire limits and your plan. -
You
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true} -
Server
{"type": "subscription", "filter": {…}}Then the snapshot, in parts, with updates flowing alongside. -
Server
{"type": "odds", "data": {"type": "live", …}}One per Pinnacle update that matches your filter.
-
Stream:
wss://feed.oddspush.com/v1/stream. REST:https://feed.oddspush.com/v1/…,GETonly. - Access: your key, from one IP at a time (see Your key). A new IP takes over, and the connection from the old one closes.
Coverage is live markets for 13 sports. The full pre-match board:
on request. Some
pre-match prices already travel on the live stream, on the pre
channel.
Your key
One key authenticates you everywhere: opk_…, that is opk_ and 43
characters. It goes in the token field of login and, for REST, in the
x-token header.
- Get one. Sign up with your email and open the link we send: it works once, for 30 minutes. The key is on that page.
-
Activate your trial. A new account receives no data until its Free trial is active.
Message @oddspush on Telegram with
the email of your account; we switch it on by hand and reply when it is active. The 7 days
count from then. You can get your bot ready meanwhile. Until your trial is activated, the stream
rejects your key with
unauthorized(4101), and REST answers 401unauthorized. - It is shown once, on that page. We keep a hash, not the key, so we cannot show it again. One key per account, and only we change it: if you lose it or think it has leaked, message @oddspush on Telegram and we issue a new one. The old key stops working, open connections included.
-
It works from one IP at a time. There is nothing to set: when your key connects
from a new IP, by
loginor by a REST request, that IP takes over and any open connection from the old one closes withreplaced(4107). Run your bot from one server; a second machine with the same key kicks the first. -
Keep it in the environment, not in your code or your repo. The examples on this page
read it from
ODDSPUSH_KEY.
Connect and log in
One endpoint, TLS only:
wss://feed.oddspush.com/v1/stream
The server sends binary frames, each holding one UTF-8 JSON object: parse the bytes as JSON. You can send text or binary frames; both are read the same way.
- Turn per-message compression off. permessage-deflate only adds latency on every large frame. Both examples below do this.
- Accept large messages. Snapshot parts aim at 256 KB, but a part always holds at least one whole event. Set your client's limit to 16 MB.
-
Keep what you send small. A message over 256 KB closes the
connection with
1009.
Log in
The first message must be the login, within 15 seconds of the socket
opening:
{"type": "login", "token": "opk_…"}
If it is accepted you get welcome. If not, you get one error and the
connection closes with its code: unauthorized, subscription_expired,
reconnect_rate_limited, connection_limit or
server_full (see close codes). Anything other than a
login first is unauthorized.
{"type": "error", "code": "unauthorized"}
-
No need to wait for
welcome. Send yoursubscriberight after thelogin; it is read once you are in. -
Keep the key out of URLs and logs. It only ever travels inside the
loginmessage, and the REST API reads it from a header.
welcome
{
"type": "welcome",
"server_time": 1793458714075,
"feed": {"alive": true, "ready": true},
"limits": {
"queue_size": 1000,
"keepalive_s": 15,
"max_events": 1000,
"snapshot_buffer_bytes": 4194304,
"max_lag_s": 15
},
"account": {
"id": "acme",
"plan": "paid",
"expires_at": 1796050714075,
"limits": {
"connections": 1,
"sports": null,
"streams": null,
"snapshots_per_hour": 12,
"snapshot_mb_per_hour": 400,
"reconnects_per_minute": 12,
"rest": true,
"stream": true
}
}
}
-
server_timeint - Server clock, Unix milliseconds.
-
feed.alivebool falsewhile our source is down. Stay connected: you getfeed"up"when it is back.-
feed.readybool falsefor a short while after our relay restarts, until it holds the current state. A snapshot asked for then can be incomplete.-
limits.queue_sizeint - Updates queued for you. Past this the oldest are dropped and you get
missed. See Keep up. -
limits.keepalive_snumber - Seconds of silence after which the server sends a
keepalive. -
limits.max_eventsint eventsyour filter can hold, counted across everysubscribe.-
limits.snapshot_buffer_bytesint - Snapshot bytes queued for you at once. The rest waits until you read.
-
limits.max_lag_snumber - How far behind you may fall before the connection closes with
too_slow. -
account.idstring - Your account id.
-
account.planstring "trial"is the free trial,"paid"any paid plan;limits.streamandlimits.restsay which. A plan arranged with you can show"custom".-
account.expires_atint | null - When the plan ends, Unix milliseconds.
nullif it does not. -
account.limitsobject connections(at once),snapshots_per_hourandsnapshot_mb_per_hour(rolling hour),reconnects_per_minute,restandstream(whether your plan includes the REST API and the WebSocket stream), andsportsandstreams, the lists your plan may receive.nullmeans no cap. See Plan limits.
Plan limits
Limits are per account. The Free trial lasts 7 days from the moment we activate it. Your own values are in
welcome.account.limits.
| Limit | Free trial | REST | REST + WebSocket |
|---|---|---|---|
| Price | Free · for 7 days | $69 / month | $138 / month |
| REST API | Yes | Yes | Yes |
| WebSocket stream | Yes | No | Yes |
| Simultaneous connections | 1 | 0 | 1 |
| Snapshots per hour | 6 | — | 12 |
| Snapshot volume per hour | 200 MB | — | 400 MB |
| Reconnects per minute | 6 | — | 12 |
| Sports | All | All | All |
| Streams | Live | Live | Live |
| IPs at a time | 1 | 1 | 1 |
-
Connections. Opening one more than your plan allows does not fail: the oldest
connection closes with
replaced(4107) and the new one takes its slot. A bot that crashed and comes back does not wait for its dead socket to time out. -
Reconnects per minute. Counted when a connection gets in. Past the limit:
reconnect_rate_limited(4106) withretry_after_s. - Snapshots per hour. By count and by volume, across all your connections. See snapshot limits.
-
One IP at a time. Your connections and REST calls come from one IP. A
loginor a REST request from a new IP takes over, and an open connection from the old one closes withreplaced(4107).
Stream
Subscribe
A subscribe adds to your filter. By default it also asks for a snapshot of what it adds,
so one message gives you the current state and every change after it.
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
{"type": "subscribe", "events": [1638780974, 1638781120], "channels": ["ld", "both"]}
-
sportsint[] - Sports to receive. Ids in the table below.
-
eventsint[] - Single events, by
rec.id. Up to 1,000 across all your subscribes (limits.max_events). -
channelsstring[] ld,both,dz,pre,other. Unknown names are ignored, and only the first 16 values are read. Set it, always: without it, or with no name that exists, every channel arrives,dzincluded.-
streamsstring[] ["live"]keeps you to live updates. Without it nothing is filtered by stream, and you may see otherdata.typevalues: skip them.-
allbool - Everything your plan covers, on every channel:
channelsdoes not narrowall. Prefersports. -
snapshotbool - Send a snapshot of what this message adds.
falsefor updates only.
How a filter matches
- Within one field, values are OR:
"sports": [1, 2]is soccer or tennis. -
sportsandeventsare OR with each other: all of soccer, plus these three tennis matches. -
channelsnarrows the result. On its own, with nosportsorevents, it means those channels for every sport your plan covers. -
A connection has one filter.
channelsapplies to every sport and event in it: adding{"events": [X], "channels": ["dz"]}to a soccer subscription bringsdzfor all of soccer. To followdzfor a few events only, subscribe todzand drop the updates you do not want byrec.idortopic. Not with a second connection: your plan has one, and a new one closes the old with4107. -
Subscriptions add up: you can send several.
unsubscribetakes the same fields and removes the values you list;"all": truein it clearsall. - An empty field does not narrow anything. Unsubscribe your last channel and every channel arrives. Unsubscribe your last sport or event while channels or streams remain, and those arrive for every sport your plan covers. To stop everything, unsubscribe the channels and the streams too, or close the connection.
{"type": "unsubscribe", "events": [1638780974]}
subscription
Your whole filter after each subscribe or unsubscribe, lists sorted. Use it
to check what the server holds for you.
{
"type": "subscription",
"filter": {
"all": false,
"sports": [1],
"events": [],
"streams": [],
"channels": ["both", "ld"]
}
}
Sports
| Id | Sport | Markets |
|---|---|---|
| 1 | Soccer | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 2 | Tennis | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 3 | Basketball | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 4 | Hockey | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 5 | American Football | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 6 | Baseball | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 7 | Rugby | Moneyline, Spread / handicap, Totals, By period |
| 8 | MMA | Moneyline, Totals |
| 9 | Boxing | Moneyline, Totals |
| 10 | Volleyball, handball | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 11 | Esports | Moneyline, Spread / handicap, Totals, Team totals, By period |
| 12 | Golf | Moneyline |
| 13 | Cricket | Moneyline, Totals |
Use data.sport_id in your code. The number inside topic is Pinnacle's own
sport id and does not match this table.
Channels
An update belongs to one channel, the last segment of its topic:
matchups/reg/sp/29/live/ld is ld, matchups/reg/sp/29/pre is
pre. Any other last segment is other.
| Channel | Is it the price? | What it carries |
|---|---|---|
| ld | Yes | Live delay. The price Pinnacle displays. Source of truth. |
| both | Yes | Both. Displayed prices too, mostly for events that never appear on ld. Read it with ld or you lose whole matches. |
| dz | Volatile | Danger zone. Moments of risk: goal threat, break point, imminent suspension. Keep it in a book of its own. |
| pre | Pre-match | Pre-match. Pre-match prices for some upcoming matchups, as they travel on the live stream: open markets with versions, like ld. Not the full pre-match board. |
| other | No | Other. Any other last segment. Rare. channels never filters these updates out: they reach you whenever their sport or event matches. List other to get their markets in a snapshot. |
What you receive
Switch on type. Skip any type you do not handle: new ones can appear without a version
bump.
| Type | Purpose |
|---|---|
| login | First message, within 15 s. Carries your key. |
| subscribe | Adds sports, events or channels to your filter. Brings a snapshot by default. |
| unsubscribe | Takes values out of your filter. |
| snapshot | Asks for the current state: everything you follow, or a subset. |
| ping | Round-trip check. The pong echoes your numeric id. |
| status | Counters for this connection. |
| Type | Purpose |
|---|---|
| welcome | You are in: feed state, wire limits, your plan. |
| odds | Every update, as it happens. data is the frame exactly as our source publishes it, byte for byte. |
| snapshot | The current state of what you asked for, in parts. |
| subscription | Your whole filter, after each subscribe or unsubscribe. |
| keepalive | After 15 s with nothing else to send. |
| feed | Our source stopped (down), is back (up), or we are restarting (maintenance). |
| missed | You fell behind and lost count updates. Ask for a snapshot. |
| pong | Reply to ping. |
| status | Reply to status. |
| error | A request was refused, or the connection is about to close. |
odds
Every update, as it happens. data is the frame as our source publishes it, verbatim. The
example is trimmed: real frames carry many more fields.
{
"type": "odds",
"data": {
"type": "live",
"sport_id": 1,
"topic": "matchups/reg/sp/29/live/ld",
"op": "upd",
"rec": {
"id": 1638780974,
"parentId": 1638780960,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"league": {"id": 1980, "name": "England - Premier League"},
"participants": [
{
"name": "Brentford",
"alignment": "home",
"state": {"score": 1}
},
{"name": "Fulham", "alignment": "away", "state": {"score": 0}}
],
"state": {"minutes": 38},
"periods": [
{"period": 0, "status": "open"},
{"period": 1, "status": "open"}
],
"markets": [
{
"key": "s;0;m",
"type": "moneyline",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -238},
{"designation": "away", "price": 655},
{"designation": "draw", "price": 318}
],
"limits": [{"type": "maxRiskStake", "amount": 3000}]
},
{
"key": "s;0;s;-0.75",
"type": "spread",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -105, "points": -0.75},
{"designation": "away", "price": -115, "points": 0.75}
]
},
{
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
]
},
"ts": 1793458714075
}
}
-
data.typestring "live"for an update. Skip other values you do not handle (see below).-
data.sport_idint - Sport id, as in Sports. Filter and key by this one.
-
data.topicstring - Its last segment is the channel:
…/live/ld,…/live/both,…/live/dz,…/pre, or another one, which isother. -
data.opstring "upd"or"add": mergerec."del": the event is gone, remove it.-
data.recobject - The event, with the markets that changed. Fields below.
-
data.tsint - When the price was published, Unix milliseconds.
Now and then data.type is "snapshot": a partial state our source resends
when its session restarts, usually around a feed down and up.
It is not the snapshot you ask for: it may carry counters of its own and
a final inside data, and you can ignore both. Its data.events
are rec objects. Merge them by version like any other record, and never sweep on
them.
The event: rec
-
idint - Event id. The key for your book.
-
parentIdint - The record this one hangs from: a live matchup points to its pre-match fixture, a special or an alternative line to its matchup. A parent and a child may carry the same market key; each keeps its own markets.
-
typestring "matchup"for the game itself,"special"for props. Markets of specials currently arrive withoutstatus: the merge below ignores them, and snapshots do not include them.-
startTimestring - Scheduled start, ISO 8601 UTC.
-
leagueobject id,nameand more.-
participantsobject[] name,alignment(home,away,neutral), andstatewith the score instate.scorewhere there is one.-
stateobject - In-play state, such as
minutes. Contents vary by sport. -
periodsobject[] periodandstatus:"open","closed"or"settled". A closed or settled period closes all its markets.-
marketsobject[] - Markets that changed. Markets not listed are unchanged, not gone.
-
versionint - Board-level stamp that freezes once the game is in play. Do not order by it. Use
markets[i].version.
A market
{
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
-
keystring - Unique per event:
s;<period>;m(moneyline),s;<period>;s;<line>(spread),s;<period>;ou;<line>(total),s;<period>;tt;<line>;home(team total). -
typestring moneyline,spread,totalorteam_total.-
periodint 0is the full game. Other numbers are halves, sets, quarters, periods or innings, by sport.-
sidestring homeoraway, on team totals.-
statusstring "open", or the market is closed: remove it. Missing on the markets of specials.-
versionint - Order updates by this. Strictly increasing per event and key, across channels. Keep the highest you have seen per
key. -
pricesobject[] designation(home,away,draw,over,under),pricein American odds, andpoints(the line). Specials carryparticipantIdinstead ofdesignation.-
limitsobject[] - Maximum stake, such as
maxRiskStake. -
isAlternatebool falsefor the main line.
Prices are American odds. To decimal:
def decimal(american: int) -> float:
return 1 + american / 100 if american > 0 else 1 + 100 / -american
decimal(-238) # 1.42
decimal(655) # 7.55
snapshot: the current state, in parts
After a subscribe (unless it says "snapshot": false) or a
snapshot request, you get the state of every event that matches and has at least one
market on the channels you asked for (ld and both unless you asked for
others). A pre-match event priced only on pre is left out unless you ask for
pre; it arrives with its first update on your channels. It comes in parts of at most 100 events or about 256 KB, ordered by
event id, with each event's markets ordered by key.
{
"type": "snapshot",
"part": 0,
"final": false,
"events": [
{
"id": 1638780974,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"markets": [
{
"key": "s;0;m",
"type": "moneyline",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -238},
{"designation": "away", "price": 655},
{"designation": "draw", "price": 318}
],
"limits": [{"type": "maxRiskStake", "amount": 3000}]
}
]
},
{
"id": 1638781120,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"markets": [
{
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
]
}
]
}
-
partint - Part index, from 0. Part 0 starts a new snapshot.
-
finalbool trueon the last part. It may be empty.-
eventsobject[] recobjects like the ones inodds, each with its open markets.
Ask for one at any time:
{"type": "snapshot"}
{"type": "snapshot", "sports": [2], "channels": ["ld", "both"]}
With filter fields, the request describes that one snapshot from scratch: it does not intersect with
your subscription. Include sports or events, or you get every sport your
plan covers.
-
Channels follow the request. By default a snapshot carries
ldandboth, merged by version.dz,preandothercome only if the request (or, for a baresnapshot, your filter) lists them, and every channel listed is merged into onemarketslist. If you followdz, subscribe to it with"snapshot": falseand ask for its snapshot on its own. Withdzin your filter, never send a baresnapshot: it would folddzinto the price list. Ask for one with"channels": ["ld", "both"]and a separate one with["dz"], and route each to its own book. - Updates keep flowing. They have their own queue and jump ahead of snapshot parts still queued; at most they wait for the part being written. They can arrive between parts, and merging by version makes the order irrelevant.
-
Merge, never clear. Do not empty your book because of a snapshot: apply each part
as it arrives. A snapshot only lists open markets, so a close you missed never shows up in it. When
the
"final": truepart arrives, drop what it did not mention (rule 7).
{"type": "snapshot", "sports": [1], "channels": ["dz"]}
Keeping a correct book
None of these rules raises an error when broken. You just end up with wrong prices.
-
Key events by
rec.id. Several records can describe the same fixture (seeparentId), so names are not a key. -
Key markets by
key. It is unique within an event. -
Apply a market only if its
versionis higher. Lower or equal is a reordered update: skip it. Never userec.version. -
A sharp move with a higher version is real. Apply it. Do not filter prices that look odd.
-
Remove what has closed. Drop the event on
op: "del". Drop a market when its period is"closed"or"settled", or when itsstatusis anything but"open". -
Merge
ldandbothby version; keepdzapart. When the same key appears onldandboth, the higher version is the current price. -
Sweep after a snapshot of everything you follow. Note when you ask for it: on connect, on
missed, onfeed"up". When its final part arrives, drop every market and event that neither the snapshot nor an update has mentioned since then: it closed while you were not looking, and merging alone cannot see that. Never sweep after a snapshot of a subset.
class Book:
"""Events by rec.id, markets by key, ordered by markets[i].version."""
def __init__(self) -> None:
# events[id] = {"rec": event fields, "markets": {key: market}, "seen": {key: tick}, "tick": n}
self.events: dict[int, dict] = {}
self.tick = 0 # bumped on every record applied
self.sweep_from = None # tick when the snapshot being served was asked for
self.sweep_next = None # the same, for one asked while another is in flight
def apply_rec(self, rec: dict) -> None:
"""Merge one event record (from odds or from a snapshot)."""
self.tick += 1
ev = self.events.setdefault(rec["id"], {"rec": {}, "markets": {}, "seen": {}, "tick": 0})
ev["tick"] = self.tick
ev["rec"].update((k, v) for k, v in rec.items() if k != "markets")
markets, seen = ev["markets"], ev["seen"]
for m in rec.get("markets") or []:
key = m.get("key")
if key is None:
continue
seen[key] = self.tick # mentioned, even if not newer
prev = markets.get(key)
new_v, old_v = m.get("version"), prev.get("version") if prev else None
if isinstance(new_v, int) and isinstance(old_v, int) and new_v <= old_v:
continue # older than what we hold: skip
if m.get("status") == "open":
markets[key] = m
else:
markets.pop(key, None) # anything but "open" closes it
periods = rec.get("periods") or []
for p in periods.values() if isinstance(periods, dict) else periods:
if isinstance(p, dict) and p.get("status") in ("closed", "settled"):
n = str(p.get("period"))
for key in [k for k, m in markets.items() if str(m.get("period")) == n]:
del markets[key] # a closed period closes its markets
def delete(self, event_id) -> None:
self.events.pop(event_id, None)
# A snapshot lists only open markets: a close you missed never shows up in it.
# So note when you ask for one, and on its final part drop what nobody mentioned since.
def snapshot_asked(self) -> None:
"""Call when you ask for a snapshot of everything you follow (subscribe included)."""
if self.sweep_from is None:
self.sweep_from = self.tick
elif self.sweep_next is None:
self.sweep_next = self.tick # served after the one in flight
def snapshot_refused(self) -> None:
"""On snapshot_rate_limited. It is about the latest request, and it can arrive
before the final part of the one still being delivered: forget the latest."""
if self.sweep_next is not None:
self.sweep_next = None
else:
self.sweep_from = None
def snapshot_done(self) -> None:
"""On the final part."""
since, self.sweep_from, self.sweep_next = self.sweep_from, self.sweep_next, None
if since is None:
return
for eid in [i for i, ev in self.events.items() if ev["tick"] <= since]:
del self.events[eid] # not in the snapshot, no update since
for ev in self.events.values():
for key in [k for k in ev["markets"] if ev["seen"].get(k, 0) <= since]:
del ev["markets"][key] # closed while we were not looking
ev["seen"].pop(key, None)
def new_connection(self) -> None:
"""A snapshot in flight dies with its socket. Keep the book, forget the requests."""
self.sweep_from = self.sweep_next = None
When to ask for one
- On connect: your
subscribedoes it for you. - On
missed: you lost updates. -
On
feed"up": updates were lost while our source was down. If you run several bots, wait a random 0 to 30 s first.
Not on a timer. The book stays correct from updates alone, and a periodic snapshot spends your hourly budget for nothing.
Snapshot limits
- Snapshots are counted per account over a rolling hour, by number and by megabytes served: Free trial 6 and 200 MB, paid plans 12 and 400 MB.
-
Over the limit, you get
errorsnapshot_rate_limitedwithretry_after_s. The connection stays open and updates keep coming. The refusal can arrive before the final part of a snapshot still being delivered: it is about your latest request, not that one.
{
"type": "error",
"code": "snapshot_rate_limited",
"retry_after_s": 1260.4,
"per_hour": 12,
"mb_per_hour": 400
}
-
Every snapshot counts, including the one each
subscribebrings and the one after every reconnect. Put all yoursportsin onesubscribe, and pass"snapshot": falseon a later one that only adds events you already hold. - Snapshots are served one at a time, in the order you asked for them, and the parts of two never interleave. Up to 3 distinct requests queue per connection; a duplicate of one still queued is dropped without parts or error. Ask for more and they collapse into one snapshot of everything you follow. A request is counted when it is served, not when it is made.
Other messages
keepalive
Sent when your connection has had nothing to send for keepalive_s (15)
seconds. On a busy filter you may never see one: any message proves the connection is alive.
feed_alive is false while our source is down; pending is the
number of updates queued for you.
{
"type": "keepalive",
"time": 1793458714075,
"feed_alive": true,
"pending": 0
}
feed
"down": our source stopped. Stay connected. "up": it is back; ask for a
snapshot. "maintenance": we are restarting; the connection closes, so reconnect in a few
seconds.
{"type": "feed", "status": "down"}
missed
You read slower than updates arrived, your queue of 1,000 filled, and the oldest
count were dropped. Your book may be stale: ask for a snapshot. missed is
the only loss signal.
{"type": "missed", "count": 214}
pong
Send {"type": "ping", "id": 7} and the reply carries the same id, if it is a number
(otherwise null). time is the server clock.
{"type": "pong", "time": 1793458714106, "id": 7}
await ws.send(json.dumps({"type": "ping", "id": int(time.time() * 1000)}))
# ... later, on {"type": "pong"}:
rtt_ms = time.time() * 1000 - msg["id"]
status
Send {"type": "status"} for counters on this connection: messages sent to you,
updates dropped because your queue was full, and updates pending right now.
{"type": "status", "sent": 184322, "dropped": 0, "pending": 0}
error
code names the problem; some errors carry more fields. Some precede a close, others
refuse one request and leave the connection open. Both lists are under
errors and close codes.
Keep up
We queue up to limits.queue_size (1,000) updates for you. If you fall
behind, the oldest are dropped and you get missed with how many. If you stay more than
limits.max_lag_s (15 s) behind, the connection closes with
too_slow (4108); while a snapshot is still downloading you get
60 s.
- Read in one task, process in another. Do no heavy work in the read loop: hand messages to a queue and let your strategy consume it.
-
Ask only for what you use. A narrower filter means fewer updates and smaller
snapshots. Most bots need
ldandboth, notdzorpre. -
Watch
pending. It comes in everykeepaliveand in the reply tostatus. If it keeps growing, you are falling behind. - Host your bot close to Ireland (Western Europe) for the lowest latency. From further away a large snapshot takes longer to download, and your updates wait behind it.
Protocol limits
The same on every plan.
Time to send login |
15 s |
|---|---|
| Largest message you can send | 256 KB |
events across all your subscribes |
1,000 |
Values in sports or streams |
64 |
Updates queued for you (queue_size) |
1,000 |
| Snapshot data queued for you | 4 MB |
Behind before too_slow (max_lag_s) |
15 s (60 s in a snapshot) |
| Snapshot requests queued per connection | 3 |
Reference
REST
A small API to look up the current state, at https://feed.oddspush.com/v1. Same key, in the
x-token header, from the same IP as your stream: a request from a new IP takes over. Only GET, and only if your plan
includes REST (account.limits.rest). Responses are JSON.
$ curl -s -H "x-token: $ODDSPUSH_KEY" "https://feed.oddspush.com/v1/events?sport_id=1&channel=ld"
< {"n": 2, "events": [{"event_id": 1638780974, "sport_id": 1, "ld": 3, …}, …]}
| GET | Query | Returns |
|---|---|---|
| /v1/events | sport_id, channel | Every event we hold, with the number of markets per channel. markets counts the ones on channel (default ld). |
| /v1/events/{event_id} | channel | One event: meta, periods, its markets per channel and merged, the price to show. |
| /v1/membership | sport_id | Pre-match events about to go live, per sport. |
| /v1/health | — | {"ok": true} while the feed is flowing. No key needed. |
sport_id is optional: without it you get every sport your plan covers.
channel is one of ld, both, dz, pre
or other.
/v1/events
One row per event, ordered by sport and event id. The channel fields count the open markets on each;
an event can show 0 on all of them.
{
"n": 2,
"events": [
{
"event_id": 1638780974,
"sport_id": 1,
"ld": 3,
"dz": 0,
"pre": 0,
"both": 1,
"other": 0,
"markets": 3
},
{
"event_id": 1638781120,
"sport_id": 1,
"ld": 0,
"dz": 0,
"pre": 0,
"both": 5,
"other": 0,
"markets": 0
}
]
}
/v1/events/{event_id}
The event's fields in meta (a rec without markets), its
periods, and its markets by key for each channel. Without
channel you get all five, and merged is ld and
both merged by version, the price to show. With channel you get
that one, and merged holds the same markets. Trimmed:
{
"event_id": 1638780974,
"ld": {
"s;0;ou;2.5": {
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
},
"merged": {
"s;0;ou;2.5": {
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
},
"meta": {
"id": 1638780974,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"league": {"id": 1980, "name": "England - Premier League"}
},
"periods": {"0": {"period": 0, "status": "open"}},
"sport_id": 1,
"topic": "matchups/reg/sp/29/live/ld"
}
/v1/membership
Per sport id, the pre-match events about to go live: how many, and their ids.
{"1": {"n": 3, "ids": [1638790112, 1638790380, 1638791004]}}
/v1/health
ok is true while the feed is flowing, false while our source
is down. It needs no key.
{"ok": true}
Errors
Errors come as {"detail": "<code>"}:
{"detail": "rest_not_in_plan"}
| Status | detail | When |
|---|---|---|
| 400 | bad_request | A query parameter of the wrong type, such as sport_id=x. |
| 401 | unauthorized | Missing, unknown or replaced key. |
| 403 | subscription_expired · rest_not_in_plan · sport_not_in_plan | Plan ended, no REST on your plan, or a sport outside your plan. |
| 404 | not_found | No such event, or one in a sport outside your plan. Any other path or method answers 404 with "Not Found". |
| 429 | rate_limited | Too many requests for your account: a burst, then a steady rate per minute. Wait the seconds in the retry-after header. |
| 503 | unavailable | The state is not available right now. Retry shortly. |
Errors and close codes
Before closing, the server sends an error whose code names the reason, then
closes with the matching WebSocket code. The four marked stop will not clear up by
retrying: reconnecting in a loop only burns your reconnect budget.
| Close | error.code | When | What to do |
|---|---|---|---|
| 4101 | unauthorized | Unknown or replaced key, or a first message that is not a login. With no valid JSON within 15 s, the socket closes with no error first. |
Stop. Check the key. |
| 4102 | server_full | Our relay is at its connection capacity. Sent right after login. |
Try again in a minute, with backoff. |
| 4103 | subscription_expired | Your plan has ended. On connect, the error carries expires_at. |
Stop. Renew or switch plan in your account, paid in crypto. |
| 4104 | connection_limit | Every connection on your plan is still closing. The error carries max. |
Back off and retry. |
| 4106 | reconnect_rate_limited | Too many new connections in the last minute. The error carries retry_after_s. |
Wait retry_after_s, then reconnect. |
| 4107 | replaced | Your key connected from another IP (a login or a REST request), which takes over; or you opened a new connection with every slot in use, and this one, the oldest, was closed. |
Stop. Do not reconnect from this process. Run your bot from one server. |
| 4108 | too_slow | You stayed more than max_lag_s (15 s) behind, 60 s while a snapshot is still downloading. Or you held the most queued data while our relay ran short of memory. |
Reconnect with backoff. Read faster or narrow the filter. |
| 4109 | stream_not_in_plan | Your plan is REST only (account.limits.stream is false). Also sent to an open stream, within about 30 s, when your plan changes to REST only. It does not count against connections or reconnects_per_minute, and REST keeps working. |
Stop. Use the REST API, or move to a plan with the WebSocket. |
| 1009 | — | You sent a message over 256 KB. | Fix the client, then reconnect. |
| Other | — | Network loss, a relay restart, anything not listed above. | Reconnect with backoff. |
unauthorized, subscription_expired, replaced and
too_slow can also arrive on an open connection: a key we replaced, a plan that ends
mid-session, your key connecting from another IP or a newer connection of yours, a client that stopped
reading.
Errors that leave the connection open
The message is ignored, or the snapshot refused; everything else carries on.
| error.code | When |
|---|---|
| snapshot_rate_limited | Your hourly snapshot count or volume is used up. Carries retry_after_s, per_hour and mb_per_hour. |
| too_many_events | More than 1,000 events in one message, or a subscribe that would take your filter past 1,000. Also a sports or streams list of more than 64 values. |
| filter_too_large | A subscribe that would take any field but events past 64 values in total, across your subscribes. That subscribe is ignored. |
| empty_filter | A subscribe with nothing to filter on: no field, or only channel names that do not exist. |
| bad_json | A message that is not valid JSON. |
| unknown_type | A missing or unknown type. A second login on an open connection is ignored without an error. |
Reconnecting
Connections drop: networks fail, we restart for a deploy. A client that reconnects well loses a few seconds; one that reconnects badly locks itself out.
-
Back off exponentially, with jitter. Start at 1 s, double up to 30 s, and wait a random time between half the delay and all of it. Grow each delay from the previous one; a
2 ** attempton an unbounded counter eventually overflows. Never reconnect in a tight loop: you will hitreconnect_rate_limited. -
Reset only after a healthy session. Go back to 1 s after 60 s connected, not on connect. Getting in proves nothing if you are thrown out a second later.
-
Honour
retry_after_s. Afterreconnect_rate_limited, wait at least that long. -
Stop on 4101, 4103, 4107 and 4109. Retrying cannot fix them.
4107means another process took over your key, on this machine or from another IP: two instances fighting for one key throw each other out until the reconnect limit stops both. -
Watch for silence. With a
keepaliveevery 15 s of idleness, 40 s without any message means the connection is dead even if the socket says otherwise. Close it and reconnect. -
Log in and subscribe again; keep your book, then sweep it. Filters do not survive a reconnect. Send
loginand yoursubscribeagain with"snapshot": true, merge the snapshot into the book you already hold, and sweep it on the final part (rule 7): markets that closed while you were away are not in it.
| Attempt | Wait |
|---|---|
| 1 | 0.5–1 s |
| 2 | 1–2 s |
| 3 | 2–4 s |
| 4 | 4–8 s |
| 5 | 8–16 s |
| 6 and after | 15–30 s |
Code
Examples
Two complete clients that do everything on this page: log in, subscribe to soccer on
ld and both, keep a book by version and sweep it after each snapshot, ask
for a snapshot on missed and on feed "up", watch for silence
and reconnect with backoff. Set ODDSPUSH_KEY to your key and run.
feed.py
"""OddsPush stream client: log in, subscribe, keep a book, reconnect with backoff.
pip install "websockets>=13"
ODDSPUSH_KEY=opk_... python feed.py
"""
import asyncio
import json
import logging
import os
import random
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, WebSocketException
URL = "wss://feed.oddspush.com/v1/stream"
KEY = os.environ["ODDSPUSH_KEY"] # opk_ and 43 characters
SUBSCRIBE = {
"type": "subscribe",
"sports": [1], # soccer
"channels": ["ld", "both"], # the displayed price; dz stays out
"snapshot": True, # the default, spelled out
}
PRICE = ("/ld", "/both") # the channels of the price book (topic suffix)
STOP = {4101, 4103, 4107, 4109} # unauthorized, plan ended, replaced, REST-only plan
SILENCE_S = 40 # nothing at all for this long: the connection is dead
BACKOFF_MIN_S, BACKOFF_MAX_S = 1.0, 30.0
STABLE_S = 60 # a session this long resets the backoff
log = logging.getLogger("oddspush")
class Book:
"""Events by rec.id, markets by key, ordered by markets[i].version."""
def __init__(self) -> None:
# events[id] = {"rec": event fields, "markets": {key: market}, "seen": {key: tick}, "tick": n}
self.events: dict[int, dict] = {}
self.tick = 0 # bumped on every record applied
self.sweep_from = None # tick when the snapshot being served was asked for
self.sweep_next = None # the same, for one asked while another is in flight
def apply_rec(self, rec: dict) -> None:
"""Merge one event record (from odds or from a snapshot)."""
self.tick += 1
ev = self.events.setdefault(rec["id"], {"rec": {}, "markets": {}, "seen": {}, "tick": 0})
ev["tick"] = self.tick
ev["rec"].update((k, v) for k, v in rec.items() if k != "markets")
markets, seen = ev["markets"], ev["seen"]
for m in rec.get("markets") or []:
key = m.get("key")
if key is None:
continue
seen[key] = self.tick # mentioned, even if not newer
prev = markets.get(key)
new_v, old_v = m.get("version"), prev.get("version") if prev else None
if isinstance(new_v, int) and isinstance(old_v, int) and new_v <= old_v:
continue # older than what we hold: skip
if m.get("status") == "open":
markets[key] = m
else:
markets.pop(key, None) # anything but "open" closes it
periods = rec.get("periods") or []
for p in periods.values() if isinstance(periods, dict) else periods:
if isinstance(p, dict) and p.get("status") in ("closed", "settled"):
n = str(p.get("period"))
for key in [k for k, m in markets.items() if str(m.get("period")) == n]:
del markets[key] # a closed period closes its markets
def delete(self, event_id) -> None:
self.events.pop(event_id, None)
# A snapshot lists only open markets: a close you missed never shows up in it.
# So note when you ask for one, and on its final part drop what nobody mentioned since.
def snapshot_asked(self) -> None:
"""Call when you ask for a snapshot of everything you follow (subscribe included)."""
if self.sweep_from is None:
self.sweep_from = self.tick
elif self.sweep_next is None:
self.sweep_next = self.tick # served after the one in flight
def snapshot_refused(self) -> None:
"""On snapshot_rate_limited. It is about the latest request, and it can arrive
before the final part of the one still being delivered: forget the latest."""
if self.sweep_next is not None:
self.sweep_next = None
else:
self.sweep_from = None
def snapshot_done(self) -> None:
"""On the final part."""
since, self.sweep_from, self.sweep_next = self.sweep_from, self.sweep_next, None
if since is None:
return
for eid in [i for i, ev in self.events.items() if ev["tick"] <= since]:
del self.events[eid] # not in the snapshot, no update since
for ev in self.events.values():
for key in [k for k in ev["markets"] if ev["seen"].get(k, 0) <= since]:
del ev["markets"][key] # closed while we were not looking
ev["seen"].pop(key, None)
def new_connection(self) -> None:
"""A snapshot in flight dies with its socket. Keep the book, forget the requests."""
self.sweep_from = self.sweep_next = None
class Feed:
def __init__(self) -> None:
self.book = Book()
self.retry_after = 0.0 # seconds, from a reconnect_rate_limited error
async def ask_snapshot(self, ws) -> None:
await ws.send(json.dumps({"type": "snapshot"})) # everything you follow
self.book.snapshot_asked()
def on_odds(self, data: dict) -> None:
if data.get("type") == "live":
rec = data.get("rec") or {}
if data.get("op") == "del":
self.book.delete(rec.get("id"))
elif "id" in rec and str(data.get("topic")).endswith(PRICE):
self.book.apply_rec(rec) # other channels pass any filter: not a price
elif data.get("type") == "snapshot":
# A partial state the source resends after it reconnects: merge, never sweep.
for rec in data.get("events") or []:
self.book.apply_rec(rec)
# other data.type values: skip
def on_error(self, msg: dict) -> None:
code = msg.get("code")
if code == "reconnect_rate_limited":
self.retry_after = float(msg.get("retry_after_s") or 0)
elif code == "snapshot_rate_limited":
self.book.snapshot_refused() # no final part will come for it
log.warning("server error: %s", msg)
async def session(self, ws) -> None:
self.book.new_connection()
await ws.send(json.dumps({"type": "login", "token": KEY}))
await ws.send(json.dumps(SUBSCRIBE)) # no need to wait for welcome
self.book.snapshot_asked() # the subscribe brings one
while True:
msg = json.loads(await asyncio.wait_for(ws.recv(), SILENCE_S))
kind = msg.get("type")
if kind == "odds":
self.on_odds(msg["data"])
elif kind == "snapshot":
for rec in msg.get("events") or []:
self.book.apply_rec(rec)
if msg.get("final"):
self.book.snapshot_done()
log.info("snapshot done, %d events in book", len(self.book.events))
elif kind == "missed":
log.warning("missed %s updates: asking for a snapshot", msg.get("count"))
await self.ask_snapshot(ws)
elif kind == "feed":
log.warning("feed %s", msg.get("status"))
if msg.get("status") == "up": # updates were lost while it was down
await self.ask_snapshot(ws)
elif kind == "error":
self.on_error(msg) # before a close, the close code follows
elif kind == "welcome":
log.info("logged in, plan %s", msg["account"]["plan"])
# subscription, keepalive, pong, status and any new type: nothing to do
async def run(self) -> None:
loop = asyncio.get_running_loop()
delay = BACKOFF_MIN_S
while True:
opened = None
try:
async with connect(URL, compression=None, max_size=16 * 2**20,
open_timeout=10) as ws:
opened = loop.time()
await self.session(ws)
except ConnectionClosed as e:
code = e.rcvd.code if e.rcvd else 1006
if code in STOP:
log.error("closed with %s: not reconnecting", code)
return
log.warning("closed with %s", code)
except (OSError, asyncio.TimeoutError, WebSocketException) as e:
log.warning("connection lost: %r", e) # DNS, TCP, TLS, handshake, silence
if opened is not None and loop.time() - opened > STABLE_S:
delay = BACKOFF_MIN_S
wait = max(delay / 2 + random.uniform(0, delay / 2), self.retry_after)
self.retry_after = 0.0
log.info("reconnecting in %.1f s", wait)
await asyncio.sleep(wait)
delay = min(delay * 2, BACKOFF_MAX_S) # grow from the last delay, capped
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
asyncio.run(Feed().run())
feed.mjs
// OddsPush stream client: log in, subscribe, keep a book, reconnect with backoff.
//
// npm install ws
// ODDSPUSH_KEY=opk_... node feed.mjs
import WebSocket from 'ws';
const URL = 'wss://feed.oddspush.com/v1/stream';
const KEY = process.env.ODDSPUSH_KEY; // opk_ and 43 characters
const SUBSCRIBE = {
type: 'subscribe',
sports: [1], // soccer
channels: ['ld', 'both'], // the displayed price; dz stays out
snapshot: true, // the default, spelled out
};
const PRICE = /\/(ld|both)$/; // the channels of the price book (topic suffix)
const STOP = new Set([4101, 4103, 4107, 4109]); // unauthorized, plan ended, replaced, REST-only plan
const SILENCE_MS = 40_000; // nothing at all for this long: the connection is dead
const BACKOFF_MIN_MS = 1_000;
const BACKOFF_MAX_MS = 30_000;
const STABLE_MS = 60_000; // a session this long resets the backoff
// book: event id -> { rec: event fields, markets: Map(key -> market), seen: Map(key -> tick), tick }
const book = new Map();
let tick = 0; // bumped on every record applied
let sweepFrom = null; // tick when the snapshot being served was asked for
let sweepNext = null; // the same, for one asked while another is in flight
function applyRec(rec) {
tick += 1;
let ev = book.get(rec.id);
if (!ev) {
ev = { rec: {}, markets: new Map(), seen: new Map(), tick: 0 };
book.set(rec.id, ev);
}
ev.tick = tick;
const { markets, ...fields } = rec;
Object.assign(ev.rec, fields);
for (const m of markets || []) {
const key = m.key;
if (key === undefined) continue;
ev.seen.set(key, tick); // mentioned, even if not newer
const prev = ev.markets.get(key);
if (prev && Number.isInteger(m.version) && Number.isInteger(prev.version)
&& m.version <= prev.version) {
continue; // older than what we hold: skip
}
if (m.status === 'open') ev.markets.set(key, m);
else ev.markets.delete(key); // anything but "open" closes it
}
const periods = Array.isArray(rec.periods) ? rec.periods : Object.values(rec.periods || {});
for (const p of periods) {
if (p && (p.status === 'closed' || p.status === 'settled')) {
for (const [key, m] of ev.markets) {
if (String(m.period) === String(p.period)) ev.markets.delete(key);
}
}
}
}
// A snapshot lists only open markets: a close you missed never shows up in it.
// So note when you ask for one, and on its final part drop what nobody mentioned since.
function snapshotAsked() { // a snapshot of everything you follow, subscribe included
if (sweepFrom === null) sweepFrom = tick;
else if (sweepNext === null) sweepNext = tick; // served after the one in flight
}
// On snapshot_rate_limited. It is about the latest request, and it can arrive before the
// final part of the one still being delivered: forget the latest.
function snapshotRefused() {
if (sweepNext !== null) sweepNext = null;
else sweepFrom = null;
}
function snapshotDone() { // on the final part
const since = sweepFrom;
sweepFrom = sweepNext;
sweepNext = null;
if (since === null) return;
for (const [id, ev] of book) {
if (ev.tick <= since) { book.delete(id); continue; } // not in the snapshot, no update since
for (const key of ev.markets.keys()) {
if ((ev.seen.get(key) ?? 0) <= since) { // closed while we were not looking
ev.markets.delete(key);
ev.seen.delete(key);
}
}
}
}
function onOdds(data) {
if (data.type === 'live') {
const rec = data.rec || {};
if (data.op === 'del') book.delete(rec.id);
else if (rec.id !== undefined && PRICE.test(data.topic)) applyRec(rec); // other channels pass any filter
} else if (data.type === 'snapshot') {
// A partial state the source resends after it reconnects: merge, never sweep.
for (const rec of data.events || []) applyRec(rec);
}
// other data.type values: skip
}
let delay = BACKOFF_MIN_MS;
let retryAfterMs = 0; // from a reconnect_rate_limited error
function connect() {
let openedAt = 0;
let silence;
const ws = new WebSocket(URL, {
perMessageDeflate: false,
maxPayload: 16 * 1024 * 1024,
handshakeTimeout: 10_000,
});
const alive = () => {
clearTimeout(silence);
silence = setTimeout(() => ws.terminate(), SILENCE_MS);
};
const askSnapshot = () => {
ws.send(JSON.stringify({ type: 'snapshot' })); // everything you follow
snapshotAsked();
};
ws.on('open', () => {
openedAt = Date.now();
sweepFrom = sweepNext = null; // a snapshot in flight died with the old socket
ws.send(JSON.stringify({ type: 'login', token: KEY }));
ws.send(JSON.stringify(SUBSCRIBE)); // no need to wait for welcome
snapshotAsked(); // the subscribe brings one
alive();
});
ws.on('message', (raw) => {
alive();
const msg = JSON.parse(raw.toString());
switch (msg.type) {
case 'odds':
onOdds(msg.data);
break;
case 'snapshot':
for (const rec of msg.events || []) applyRec(rec);
if (msg.final) {
snapshotDone();
console.info('snapshot done,', book.size, 'events in book');
}
break;
case 'missed':
console.warn('missed', msg.count, 'updates: asking for a snapshot');
askSnapshot();
break;
case 'feed':
console.warn('feed', msg.status);
if (msg.status === 'up') askSnapshot(); // updates were lost while it was down
break;
case 'error':
if (msg.code === 'reconnect_rate_limited') retryAfterMs = (msg.retry_after_s || 0) * 1000;
else if (msg.code === 'snapshot_rate_limited') snapshotRefused(); // no final part for it
console.warn('server error:', msg);
break;
case 'welcome':
console.info('logged in, plan', msg.account.plan);
break;
// subscription, keepalive, pong, status and any new type: nothing to do
}
});
// DNS, TCP, TLS, a refused handshake. 'close' always follows, and reconnects.
ws.on('error', (err) => console.warn('connection error:', err.message));
ws.on('close', (code) => {
clearTimeout(silence);
if (STOP.has(code)) {
console.error('closed with', code, '- not reconnecting');
process.exitCode = 1;
return;
}
if (openedAt && Date.now() - openedAt > STABLE_MS) delay = BACKOFF_MIN_MS;
const wait = Math.max(delay / 2 + Math.random() * (delay / 2), retryAfterMs);
retryAfterMs = 0;
delay = Math.min(delay * 2, BACKOFF_MAX_MS); // grow from the last delay, capped
console.info('closed with', code, '- reconnecting in', Math.round(wait), 'ms');
setTimeout(connect, wait);
});
}
connect();
Python needs websockets 13 or newer. Node needs ws and a version with ES
modules. Keep the read loop light: hand messages to another task or thread if your strategy does heavy
work, or you fall behind and get missed.