Free trial · 7 days

Get a free key

We email you a link; opening it shows your API key. Then message @oddspush on Telegram and we activate your 7-day free trial. No card.

By continuing you agree to the Terms and the Privacy Policy.

Already have a key?

Lost your key?

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:

  1. You {"type": "login", "token": "opk_…"}
  2. Server {"type": "welcome", …} Feed state, wire limits and your plan.
  3. You {"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
  4. Server {"type": "subscription", "filter": {…}} Then the snapshot, in parts, with updates flowing alongside.
  5. 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/…, GET only.
  • 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 401 unauthorized.
  • 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 login or by a REST request, that IP takes over and any open connection from the old one closes with replaced (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:

login JSON
{"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.

error · then close 4101 JSON
{"type": "error", "code": "unauthorized"}
  • No need to wait for welcome. Send your subscribe right after the login; it is read once you are in.
  • Keep the key out of URLs and logs. It only ever travels inside the login message, and the REST API reads it from a header.

welcome

welcome JSON
{
  "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_time int
Server clock, Unix milliseconds.
feed.alive bool
false while our source is down. Stay connected: you get feed "up" when it is back.
feed.ready bool
false for a short while after our relay restarts, until it holds the current state. A snapshot asked for then can be incomplete.
limits.queue_size int
Updates queued for you. Past this the oldest are dropped and you get missed. See Keep up.
limits.keepalive_s number
Seconds of silence after which the server sends a keepalive.
limits.max_events int
events your filter can hold, counted across every subscribe.
limits.snapshot_buffer_bytes int
Snapshot bytes queued for you at once. The rest waits until you read.
limits.max_lag_s number
How far behind you may fall before the connection closes with too_slow.
account.id string
Your account id.
account.plan string
"trial" is the free trial, "paid" any paid plan; limits.stream and limits.rest say which. A plan arranged with you can show "custom".
account.expires_at int | null
When the plan ends, Unix milliseconds. null if it does not.
account.limits object
connections (at once), snapshots_per_hour and snapshot_mb_per_hour (rolling hour), reconnects_per_minute, rest and stream (whether your plan includes the REST API and the WebSocket stream), and sports and streams, the lists your plan may receive. null means 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 YesYesYes
WebSocket stream YesNoYes
Simultaneous connections 101
Snapshots per hour 6—12
Snapshot volume per hour 200 MB—400 MB
Reconnects per minute 6—12
Sports AllAllAll
Streams LiveLiveLive
IPs at a time 111
  • 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) with retry_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 login or a REST request from a new IP takes over, and an open connection from the old one closes with replaced (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.

subscribe · by sport JSON
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
subscribe · by event JSON
{"type": "subscribe", "events": [1638780974, 1638781120], "channels": ["ld", "both"]}
sports int[]
Sports to receive. Ids in the table below.
events int[]
Single events, by rec.id. Up to 1,000 across all your subscribes (limits.max_events).
channels string[]
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, dz included.
streams string[]
["live"] keeps you to live updates. Without it nothing is filtered by stream, and you may see other data.type values: skip them.
all bool
Everything your plan covers, on every channel: channels does not narrow all. Prefer sports.
snapshot bool default true
Send a snapshot of what this message adds. false for updates only.

How a filter matches

  • Within one field, values are OR: "sports": [1, 2] is soccer or tennis.
  • sports and events are OR with each other: all of soccer, plus these three tennis matches.
  • channels narrows the result. On its own, with no sports or events, it means those channels for every sport your plan covers.
  • A connection has one filter. channels applies to every sport and event in it: adding {"events": [X], "channels": ["dz"]} to a soccer subscription brings dz for all of soccer. To follow dz for a few events only, subscribe to dz and drop the updates you do not want by rec.id or topic. Not with a second connection: your plan has one, and a new one closes the old with 4107.
  • Subscriptions add up: you can send several. unsubscribe takes the same fields and removes the values you list; "all": true in it clears all.
  • 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.
unsubscribe JSON
{"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.

subscription JSON
{
  "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.

You send
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.
The server sends
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.

odds JSON
{
  "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.type string
"live" for an update. Skip other values you do not handle (see below).
data.sport_id int
Sport id, as in Sports. Filter and key by this one.
data.topic string
Its last segment is the channel: …/live/ld, …/live/both, …/live/dz, …/pre, or another one, which is other.
data.op string
"upd" or "add": merge rec. "del": the event is gone, remove it.
data.rec object
The event, with the markets that changed. Fields below.
data.ts int
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

id int
Event id. The key for your book.
parentId int
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.
type string
"matchup" for the game itself, "special" for props. Markets of specials currently arrive without status: the merge below ignores them, and snapshots do not include them.
startTime string
Scheduled start, ISO 8601 UTC.
league object
id, name and more.
participants object[]
name, alignment (home, away, neutral), and state with the score in state.score where there is one.
state object
In-play state, such as minutes. Contents vary by sport.
periods object[]
period and status: "open", "closed" or "settled". A closed or settled period closes all its markets.
markets object[]
Markets that changed. Markets not listed are unchanged, not gone.
version int
Board-level stamp that freezes once the game is in play. Do not order by it. Use markets[i].version.

A market

markets[i] JSON
{
  "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}
  ]
}
key string
Unique per event: s;<period>;m (moneyline), s;<period>;s;<line> (spread), s;<period>;ou;<line> (total), s;<period>;tt;<line>;home (team total).
type string
moneyline, spread, total or team_total.
period int
0 is the full game. Other numbers are halves, sets, quarters, periods or innings, by sport.
side string
home or away, on team totals.
status string
"open", or the market is closed: remove it. Missing on the markets of specials.
version int
Order updates by this. Strictly increasing per event and key, across channels. Keep the highest you have seen per key.
prices object[]
designation (home, away, draw, over, under), price in American odds, and points (the line). Specials carry participantId instead of designation.
limits object[]
Maximum stake, such as maxRiskStake.
isAlternate bool
false for the main line.

Prices are American odds. To decimal:

odds.py Python
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.

snapshot JSON
{
  "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}
          ]
        }
      ]
    }
  ]
}
part int
Part index, from 0. Part 0 starts a new snapshot.
final bool
true on the last part. It may be empty.
events object[]
rec objects like the ones in odds, each with its open markets.

Ask for one at any time:

snapshot · everything you follow JSON
{"type": "snapshot"}
snapshot · a subset JSON
{"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 ld and both, merged by version. dz, pre and other come only if the request (or, for a bare snapshot, your filter) lists them, and every channel listed is merged into one markets list. If you follow dz, subscribe to it with "snapshot": false and ask for its snapshot on its own. With dz in your filter, never send a bare snapshot: it would fold dz into 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": true part arrives, drop what it did not mention (rule 7).
snapshot · dz on its own JSON
{"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.

  1. Key events by rec.id. Several records can describe the same fixture (see parentId), so names are not a key.
  2. Key markets by key. It is unique within an event.
  3. Apply a market only if its version is higher. Lower or equal is a reordered update: skip it. Never use rec.version.
  4. A sharp move with a higher version is real. Apply it. Do not filter prices that look odd.
  5. Remove what has closed. Drop the event on op: "del". Drop a market when its period is "closed" or "settled", or when its status is anything but "open".
  6. Merge ld and both by version; keep dz apart. When the same key appears on ld and both, the higher version is the current price.
  7. Sweep after a snapshot of everything you follow. Note when you ask for it: on connect, on missed, on feed "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.
book.py Python
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 subscribe does 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 error snapshot_rate_limited with retry_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.
error · snapshot_rate_limited JSON
{
  "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 subscribe brings and the one after every reconnect. Put all your sports in one subscribe, and pass "snapshot": false on 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.

keepalive JSON
{
  "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.

feed JSON
{"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.

missed JSON
{"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.

pong JSON
{"type": "pong", "time": 1793458714106, "id": 7}
round trip Python
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.

status JSON
{"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 ld and both, not dz or pre.
  • Watch pending. It comes in every keepalive and in the reply to status. 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 Terminal
$ 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.

GET /v1/events?sport_id=1 JSON
{
  "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:

GET /v1/events/1638780974?channel=ld JSON
{
  "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.

GET /v1/membership?sport_id=1 JSON
{"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.

GET /v1/health JSON
{"ok": true}

Errors

Errors come as {"detail": "<code>"}:

403 JSON
{"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.

  1. 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 ** attempt on an unbounded counter eventually overflows. Never reconnect in a tight loop: you will hit reconnect_rate_limited.
  2. 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.
  3. Honour retry_after_s. After reconnect_rate_limited, wait at least that long.
  4. Stop on 4101, 4103, 4107 and 4109. Retrying cannot fix them. 4107 means 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.
  5. Watch for silence. With a keepalive every 15 s of idleness, 40 s without any message means the connection is dead even if the socket says otherwise. Close it and reconnect.
  6. Log in and subscribe again; keep your book, then sweep it. Filters do not survive a reconnect. Send login and your subscribe again 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.
Waits with that policy
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

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.