# OddsPush integration guide for coding agents

Streaming API v1. One file, complete on its own: it describes everything a client needs to
receive Pinnacle odds from OddsPush over WebSocket and keep a correct price book.

---

## For the human: how to use this file

1. Open your coding agent (Claude Code, Codex, Cursor or similar) in your project folder.
2. If it can open links, give it this file's address,
   https://oddspush.com/oddspush-agent-guide.md. If it cannot, save this file in the
   folder (for example as `oddspush-agent-guide.md`).
3. Give it a prompt like one of these (with the address instead of the file name, if you
   use the link):

> Read `oddspush-agent-guide.md` and follow it. Build a Python client that streams Pinnacle
> odds for soccer and tennis from OddsPush, keeps a correct price book, and prints every
> price change in decimal odds. My key is in the `ODDSPUSH_KEY` environment variable.

> Read `oddspush-agent-guide.md` and follow it. Add an OddsPush feed to this project, in the
> project's language: keep a live book of basketball prices and call my strategy function on
> every change. Ask me anything the guide says you need before you start.

Before your client can receive data you need two things, both described in section 1:
your key, and an active trial (message @oddspush on Telegram, https://t.me/oddspush). There
is no IP to set: the key works from one IP at a time, the last one to connect.

OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle.

---

## For the coding agent: read this first

You are building a client for the OddsPush streaming API. This file is the only
documentation you have, and it is complete. Follow it literally:

- Use only the URLs, message types, field names and values written here. Do not guess
  other endpoints or fields.
- Ignore any field this file does not describe, in any message: new fields can appear
  without a version change. Skip any message `type` you do not handle.
- Read every number as a number, not as an integer: values such as `keepalive_s` or
  `mb_per_hour` can arrive as `15.0`.
- Section 11 has two complete reference clients (Python and Node.js). Start from the one
  that matches the user's language, or port it. Keep its behaviour.
- Before you tell the user you are done, run the acceptance checklist in section 12.

---

## 1. Before you write code

### 1.1 Ask the user (only what is missing)

| Question | Why | Default if the user has no preference |
|---|---|---|
| Language and runtime? | Pick the reference client to start from. | Python 3.10+ with `websockets` 13+ |
| Which sports? | Goes in `sports` (ids in section 4.3). | Soccer, `[1]` |
| What should happen with each price change: print it, store it (where), or call their code (which function, what arguments)? | Defines the consumer (`on_change` in the reference clients). | Print each change in decimal odds |
| Do they want the volatile `dz` channel? | It needs a separate book (section 5.3). | No |
| Is the key already in the `ODDSPUSH_KEY` environment variable on the machine that will run the client? | The client reads it from there. | Ask the user to set it |

### 1.2 Inputs

- **The key** is `opk_` followed by 43 characters (47 characters in total). Read it from
  the environment variable `ODDSPUSH_KEY`.
  - Never write the key in source code, config files that are committed, URLs, logs,
    error messages or test fixtures. Do not print it.
  - If the project uses a `.env` file, make sure `.env` is in `.gitignore`.
  - At startup, check that the variable exists, starts with `opk_` and is 47 characters
    long. If not, exit with a clear message that does not echo the value.
- The same key is used for the stream (in the `login` message) and for REST (in the
  `x-token` header).

### 1.3 Account prerequisites (the user does these; you check them)

| Prerequisite | How the user gets it | What the client sees if it is missing |
|---|---|---|
| A key | Sign up at https://oddspush.com with an email address and open the link that arrives (it works once, for 30 minutes). The key is shown on that page, once. | — |
| An active trial or plan | Message @oddspush on Telegram (https://t.me/oddspush) with the email of the account and ask to activate the trial. It is switched on by hand; they reply when it is active. The 7 trial days count from then. | `error` `unauthorized`, close `4101` |

- The key works from **one IP address at a time**, and there is nothing to set. When the
  key connects from a new IP (a WebSocket `login` or a REST request), that IP takes over,
  and any open connection from the old one closes with `replaced` (4107).
- So the client runs on one machine. A second machine (or a laptop used for testing while
  the server runs) with the same key kicks the first one off. Moving the client from a
  laptop to a server needs no change: the server's IP takes over when it connects.
- The key is shown only once, at sign-up. There is one key per account, and only OddsPush
  can change it: if it is lost or leaked, the user messages @oddspush on Telegram and gets
  a new one. The old key stops working, open connections included (they close with `4101`).
- Paid plans (REST, or REST + WebSocket) are prepaid in crypto from the account, for 1, 3
  or 6 months. The key stays the same.
- Host the client close to Ireland (Western Europe) for the lowest latency.

---

## 2. Connection facts

| Fact | Value |
|---|---|
| Stream URL | `wss://feed.oddspush.com/v1/stream` (TLS only) |
| REST base | `https://feed.oddspush.com/v1` (`GET` only) |
| Message format | Every message, both ways, is one JSON object with a `type` field |
| Frames from the server | Binary frames, each holding one UTF-8 JSON object: parse the bytes as JSON |
| Frames you send | Text or binary; both are read the same way |
| Per-message compression | **Turn it off** (permessage-deflate only adds latency). Python `websockets`: `compression=None`. Node.js `ws`: `perMessageDeflate: false` |
| Largest message to accept | Set your client limit to 16 MB (`max_size=16 * 2**20` / `maxPayload: 16 * 1024 * 1024`). Snapshot parts aim at 256 KB but always hold at least one whole event |
| Largest message you may send | 256 KB. Larger closes the connection with `1009` |
| `login` deadline | The first message must be `login`, within 15 s of the socket opening. With no valid JSON within 15 s the socket closes (`4101`) with no `error` first |

### 2.1 Message flow

```text
you     -> {"type": "login", "token": "<ODDSPUSH_KEY>"}
you     -> {"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
           (you may send subscribe right after login; it is read once you are in)
server  -> {"type": "welcome", ...}                 or {"type": "error", "code": ...} then close
server  -> {"type": "subscription", "filter": {...}}
server  -> {"type": "snapshot", "part": 0, "final": false, "events": [...]}
server  -> {"type": "odds", "data": {...}}          updates arrive before, between and after parts
server  -> {"type": "snapshot", "part": 1, ...}
server  -> {"type": "snapshot", "part": N, "final": true, "events": [...]}
server  -> {"type": "odds", "data": {...}}          one per update that matches your filter
server  -> {"type": "keepalive", ...}               after 15 s with nothing else to send
```

---

## 3. Messages you send

| `type` | Purpose |
|---|---|
| `login` | First message, within 15 s. Carries the 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. |

### 3.1 `login`

```json
{"type": "login", "token": "opk_..."}
```

- Accepted: you receive `welcome` (section 4.1).
- Refused: you receive one `error` and the connection closes with its code:
  `unauthorized` (4101), `server_full` (4102), `subscription_expired` (4103),
  `connection_limit` (4104), `reconnect_rate_limited` (4106).
  See section 7.
- Anything other than `login` as the first message is `unauthorized`.
- A second `login` on an open connection is ignored, without an error.
- The key only travels inside `login` (and in the REST `x-token` header). Never put it in
  the URL.

### 3.2 `subscribe`

```json
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
{"type": "subscribe", "events": [1638780974, 1638781120], "channels": ["ld", "both"]}
```

| Field | Type | Meaning |
|---|---|---|
| `sports` | int[] | Sports to receive (ids in section 4.3). |
| `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` (section 4.4). **Always set it.** Without it, or with no name that exists, every channel arrives, `dz` included. Unknown names are ignored. Only the first 16 values of one message are read. |
| `streams` | string[] | `["live"]` keeps you to live updates. Without it nothing is filtered by stream and you may see other `data.type` values: skip the ones you do not handle. Optional. |
| `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:

1. Within one field, values are OR: `"sports": [1, 2]` is soccer or tennis.
2. `sports` and `events` are OR with each other: all of soccer, plus these tennis matches.
3. `channels` narrows the result. On its own, with no `sports` or `events`, it means those
   channels for every sport your plan covers.
4. A connection has **one** filter. `channels` applies to every sport and event in it:
   adding `{"events": [X], "channels": ["dz"]}` to a soccer filter brings `dz` for all of
   soccer. To follow `dz` for a few events only, subscribe to `dz` and drop the `dz`
   updates you do not want by `rec.id` or `topic` in your client. Never open a second
   connection for it: every plan has one, and a new one closes the old with `4107`.
5. Subscriptions add up: you can send several. Each `subscribe` brings a snapshot of only
   what **it** adds. So on connect, put your whole filter in **one** `subscribe`: then its
   snapshot is a snapshot of everything you follow (needed for the sweep, rule 7 in
   section 5). Pass `"snapshot": false` on a later `subscribe` that only adds events you
   already hold.
6. Limits: `events` up to 1,000 in one message and across all your subscribes (over:
   `too_many_events`). Every other field holds at most 64 values in total (over:
   `filter_too_large`, the `subscribe` is ignored). Text values longer than 32 characters
   are ignored. A `subscribe` with nothing to filter on (no field, or only channel names
   that do not exist) is refused with `empty_filter`.

### 3.3 `unsubscribe`

```json
{"type": "unsubscribe", "events": [1638780974]}
```

- Takes the same fields as `subscribe` 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.
- You receive `subscription` with the resulting filter.

### 3.4 `snapshot` (request)

```json
{"type": "snapshot"}
{"type": "snapshot", "sports": [2], "channels": ["ld", "both"]}
{"type": "snapshot", "sports": [1], "channels": ["dz"]}
```

- A bare `{"type": "snapshot"}` asks for everything you follow.
- 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. A snapshot with filter fields is a **subset**: never sweep after it
  (exception for a separate `dz` book: section 5.3).
- 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.
- 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.
- Snapshots are served one at a time, in the order you asked, and the parts of two never
  interleave. Up to 3 distinct requests queue per connection. A duplicate of one still
  queued is dropped with no parts and no error. Ask for more than 3 and they collapse into
  one snapshot of everything you follow. A request is counted against your hourly budget
  when it is served, not when it is made.
- When to ask: on connect (your `subscribe` does it), on `missed`, on `feed` `"up"` (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.

### 3.5 `ping` and `status`

```json
{"type": "ping", "id": 7}
{"type": "status"}
```

- `ping`: the `pong` carries the same `id` if it is a number (otherwise `null`). Use a
  timestamp in ms as the `id` to measure the round trip.
- `status`: the reply has counters for this connection (section 4.10).

---

## 4. Messages you receive

| `type` | Purpose |
|---|---|
| `welcome` | You are in: feed state, wire limits, your plan. |
| `subscription` | Your whole filter, after each `subscribe` or `unsubscribe`. |
| `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. |
| `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. |

### 4.1 `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}}
}
```

| Field | Type | Meaning |
|---|---|---|
| `server_time` | int | Server clock, Unix ms. |
| `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 a server restart, until it holds the current state. A snapshot asked for then can be incomplete: merge it, but do not sweep on it. |
| `limits.queue_size` | int | Updates queued for you. Past this the oldest are dropped and you get `missed`. |
| `limits.keepalive_s` | number | Seconds of silence after which the server sends `keepalive`. |
| `limits.max_events` | int | `events` your filter can hold, 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"` or `"paid"`. A plan arranged with you can show `"custom"`. What the plan includes is in `account.limits.stream` and `account.limits.rest`, not in its name. |
| `account.expires_at` | int or null | When the plan ends, Unix ms. `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` (bool: the REST API is included), `stream` (bool: the WebSocket stream is included), and `sports` and `streams`: the lists your plan may receive. `null` means no cap. |

### 4.2 `subscription`

```json
{"type": "subscription", "filter": {"all": false, "sports": [1], "events": [], "streams": [], "channels": ["both", "ld"]}}
```

Your whole filter after each `subscribe` or `unsubscribe`, lists sorted. Use it to check
what the server holds for you.

### 4.3 Sports

Use `data.sport_id` in your code. The number inside `topic` is Pinnacle's own sport id
and does **not** match this table.

| 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 |

Coverage is live markets. The full pre-match board is available on request; some
pre-match prices travel on the live stream, on the `pre` channel.

### 4.4 Channels

An update belongs to one channel: the last segment of its `data.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 | 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 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 | 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. |

**For the displayed price, subscribe to `["ld", "both"]` and merge those two by version
into one book.** Because `other` updates pass any filter, check the channel of every
update before you apply it to the price book.

### 4.5 `odds`

```json
{"type": "odds", "data": {
  "type": "live", "sport_id": 1, "topic": "matchups/reg/sp/29/live/ld", "op": "upd", "ts": 1793458714075,
  "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}]}]}}}
```

The example is trimmed: real frames carry more fields. Ignore what you do not use.

| Field | Type | Meaning |
|---|---|---|
| `data.type` | string | `"live"` for an update. `"snapshot"`: see below. Skip any other value. |
| `data.sport_id` | int | Sport id (section 4.3). Filter and key by this one. |
| `data.topic` | string | Its last segment is the channel (section 4.4). |
| `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 (section 4.6). |
| `data.ts` | int | When the price was published, Unix ms. |

`data.type` `"snapshot"`: now and then, a partial state our source resends when its
session restarts, usually around a `feed` `down` and `up`. It is **not** the `snapshot`
message you ask for. It may carry counters of its own and a `final` inside `data`: ignore
both. Its `data.events` are `rec` objects: merge them by version like any other record, and
**never sweep on them**.

### 4.6 The event: `rec`

| Field | Type | Meaning |
|---|---|---|
| `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 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`. |

Merge the event fields other than `markets` into what you hold (update the fields
present), as the reference `Book` does.

### 4.7 A market: `rec.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}]}
```

| Field | Type | Meaning |
|---|---|---|
| `key` | string | Unique per event. Grammar below. |
| `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. |

Market key grammar (`<period>` is the period number, `<line>` the line):

| Key | Market |
|---|---|
| `s;<period>;m` | Moneyline |
| `s;<period>;s;<line>` | Spread |
| `s;<period>;ou;<line>` | Total |
| `s;<period>;tt;<line>;home` | Team total |

Use the key as an opaque identifier for the book. To display or decide, read `type`,
`period`, `side` and `prices[].points`; do not parse the key.

### 4.8 `snapshot`: the current state, in parts

Trimmed (each market is a full market object, section 4.7):

```json
{"type": "snapshot", "part": 0, "final": false, "events": [
  {"id": 1638780974, "type": "matchup", "startTime": "2026-10-31T15:00:00Z", "markets": [{"key": "s;0;m", "version": 3772905262}]},
  {"id": 1638781120, "type": "matchup", "startTime": "2026-10-31T15:00:00Z", "markets": [{"key": "s;0;ou;2.5", "version": 3772905263}]}]}
```

| Field | Type | Meaning |
|---|---|---|
| `part` | int | Part index, from 0. Part 0 starts a new snapshot. |
| `final` | bool | `true` on the last part. The final part may have no events. |
| `events` | object[] | `rec` objects like the ones in `odds`, each with its open markets. |

- Sent after a `subscribe` (unless it says `"snapshot": false`) or a `snapshot` request.
- It only includes events that have 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.
  Still, do not create book entries for records with no open market: the reference
  clients (section 11) skip them.
- A snapshot event has no `sport_id`: only `odds.data` carries it. If you need the sport of
  an event you have seen only in a snapshot, keep it from its first `odds` update, or ask
  for one snapshot per sport (`"sports": [1]`) and note it from the request. Remember that
  per-sport snapshots are subsets and must not trigger a sweep (section 5).
- Parts hold at most 100 events or about 256 KB, ordered by event id, with each event's
  markets ordered by `key`.
- Updates keep flowing while parts arrive. They have their own queue and jump ahead of
  snapshot parts still queued. Merging by version makes the order irrelevant.
- **Merge, never clear.** Apply each part as it arrives. Do not empty the book because of a
  snapshot. On the final part of a snapshot of everything you follow, sweep (rule 7).

### 4.9 `keepalive`, `feed`, `missed`, `pong`

```json
{"type": "keepalive", "time": 1793458714075, "feed_alive": true, "pending": 0}
{"type": "feed", "status": "down"}
{"type": "missed", "count": 214}
{"type": "pong", "time": 1793458714106, "id": 7}
```

- `keepalive`: sent after `keepalive_s` (15) seconds with nothing else to send. 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.
- `feed` `"down"`: our source stopped. Stay connected; do not reconnect. `"up"`: it is
  back: ask for a snapshot (wait a random 0 to 30 s first if you run several bots).
  `"maintenance"`: we are restarting; the connection closes, so reconnect in a few
  seconds (the normal backoff does this).
- `missed`: you read slower than updates arrived, your queue of 1,000 filled, and the
  oldest `count` updates were dropped. Your book may be stale: ask for a snapshot.
  `missed` is the **only** loss signal.
- `pong`: reply to `ping`. `id` is yours (or `null`), `time` is the server clock.

### 4.10 `status` (reply)

```json
{"type": "status", "sent": 184322, "dropped": 0, "pending": 0}
```

`sent`: messages sent to you on this connection. `dropped`: updates dropped because your
queue was full. `pending`: updates queued for you right now.

### 4.11 `error`

```json
{"type": "error", "code": "unauthorized"}
{"type": "error", "code": "snapshot_rate_limited", "retry_after_s": 1260.4, "per_hour": 12, "mb_per_hour": 400}
```

`code` names the problem; some errors carry more fields (`retry_after_s`, `per_hour`,
`mb_per_hour`, `max`, `expires_at`). Some precede a close, others refuse one request and
leave the connection open. Both lists are in section 7.

---

## 5. Keeping a correct book

None of these rules raises an error when broken: you just end up with wrong prices.
Implement all of them and test them (section 12).

### 5.1 The rules

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 than the one you hold.** Lower or
   equal is a reordered update: skip it. Never use `rec.version`. A market you do not hold
   yet is applied.
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
   `status` is anything but `"open"` (and its version is higher, rule 3), or when its
   period is `"closed"` or `"settled"` in `rec.periods`.
6. **Merge `ld` and `both` by version into one book; keep `dz` apart.** When the same key
   appears on `ld` and `both`, the higher version is the current price. Never apply `dz`,
   `pre` or `other` updates to the price book.
7. **Sweep after a snapshot of everything you follow.** Note the moment you ask for it: on
   connect (the `subscribe`), on `missed`, on `feed` `"up"`. When its final part arrives,
   drop every market and every event that neither the snapshot nor an update has mentioned
   since that moment: it closed while you were not looking, and merging alone cannot see
   that. Never sweep after a snapshot of a subset, after the `data.type` `"snapshot"` of
   section 4.5, or after a snapshot taken while `welcome.feed.ready` was `false`.

Details that follow from the rules:

- A market of a special (`rec.type` `"special"`) has no `status`, so rule 5 never stores
  it. That is expected: snapshots do not include them either.
- A snapshot lists only open markets, so a close you missed never appears in it. That is
  why rule 7 exists.
- Several snapshot requests can be in flight (up to 3, served in order). Track the ask
  moment per request in order. `snapshot_rate_limited` refuses the **latest** request, and
  it can arrive before the final part of a snapshot still being delivered: forget the
  latest ask, not the one being delivered.
- A snapshot in flight dies with its connection. On reconnect, keep the book and forget the
  pending asks; the new `subscribe` brings a new snapshot, and you sweep on its final part.
- Sweeping less than you could is always safe (stale entries wait for the next sweep).
  Sweeping on a partial snapshot deletes live prices: when unsure, do not sweep.

A tick counter implements rule 7: bump it on every record applied, store the tick on each
event and on each market key when mentioned (even when the version was not newer), store
the tick at the ask, and on the final part drop everything whose tick is not newer than
that stored tick. Both reference clients do exactly this (`Book` in section 11).

### 5.2 American odds

`price` is in American odds. To decimal:

```python
def decimal(american: float) -> float:
    return 1 + american / 100 if american > 0 else 1 + 100 / -american

decimal(-238)   # 1.42
decimal(655)    # 7.55
```

Guard against a `price` of `0` or a missing `price` before converting.

### 5.3 If the user wants `dz`

Only if the user asked for it. Otherwise do not put `dz` in `channels`.

- Keep two books: the price book (`ld` and `both`) and a `dz` book. Route each update by the
  channel of `data.topic`.
- Subscribe with `"channels": ["ld", "both", "dz"]` and `"snapshot": false`, then send two
  requests with the same `sports` and `events` as your filter:
  `{"type": "snapshot", "sports": [...], "channels": ["ld", "both"]}` for the price book and
  `{"type": "snapshot", "sports": [...], "channels": ["dz"]}` for the `dz` book.
- The parts do not say which request they answer. Snapshots are served in the order you
  asked and never interleave, so keep a FIFO of your requests: part 0 starts the snapshot
  at the head of the FIFO, the final part ends it and pops it. On `snapshot_rate_limited`,
  remove the **last** entry of the FIFO.
- On `missed`, on `feed` `"up"` and after every reconnect, send the same two requests
  (after a reconnect, the `subscribe` again with `"snapshot": false` first).
- Never send a bare `{"type": "snapshot"}` with `dz` in your filter.
- Each of those two snapshots covers everything one book follows, so each book may sweep
  on its own snapshot's final part.

---

## 6. Keeping up

- The server queues 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.** The read loop only receives, parses and puts
  the message on a local queue. The book and the user's code run in a consumer task. Do no
  heavy work, disk or network I/O, or slow logging in the read loop.
- Bound the local queue. When it is full, the read loop waits, and the server's own
  mechanism (`missed`, `too_slow`) reports the problem instead of your memory growing.
- Ask only for what you use. A narrower filter means fewer updates and smaller snapshots.
  Most clients need `ld` and `both`, not `dz` or `pre`.
- Watch `pending` in `keepalive` and in the `status` reply. If it keeps growing, you are
  falling behind.

---

## 7. Errors and close codes

Before closing, the server sends an `error` whose `code` names the reason, then closes
with the matching WebSocket close code. Decide on the **close code**.

| Close | `error.code` | When | What to do |
|---|---|---|---|
| 4101 | `unauthorized` | Unknown or replaced key, a trial not activated yet, or a first message that is not a `login`. With no valid JSON within 15 s, the socket closes with no `error` first. | **Stop.** Tell the user to check the key and that the trial or plan is active. |
| 4102 | `server_full` | The server is at its connection capacity (also when the slots for trial accounts, or for your IP, are in use). Sent right after `login`. | Retry in a minute, with backoff. |
| 4103 | `subscription_expired` | The plan has ended. On connect, the `error` carries `expires_at`. | **Stop.** Tell the user the plan has ended; they renew at https://oddspush.com/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 at least `retry_after_s`, then reconnect. |
| 4107 | `replaced` | The key connected from another IP (a `login` or a REST request), which takes over; or a new connection of yours opened with every slot in use, and this one, the oldest, was closed. | **Stop.** Do not reconnect from this process: another instance, here or on another machine, holds the key. Tell the user to run the client from one machine. |
| 4108 | `too_slow` | More than `max_lag_s` (15 s) behind, 60 s while a snapshot is still downloading. Or you held the most queued data while the server ran short of memory. | Reconnect with backoff. Read faster or narrow the filter. |
| 4109 | `stream_not_in_plan` | The plan is REST only (`account.limits.stream` is `false`). Also sent to an open stream, within about 30 s, when the plan changes to REST only. The attempt does not count against `connections` or `reconnects_per_minute`, and REST keeps working. | **Stop** the stream. Use the REST API (section 10), or tell the user the plan needs the WebSocket. |
| 1009 | — | You sent a message over 256 KB. | Fix the client, then reconnect. |
| Other | — | Network loss, a server restart, anything not listed above. | Reconnect with backoff. |

`unauthorized`, `subscription_expired`, `replaced` and `too_slow` can also arrive on an
open connection: a key that OddsPush replaced, a plan that ends mid-session, the 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 | What to do |
|---|---|---|
| `snapshot_rate_limited` | Your hourly snapshot count or volume is used up. Carries `retry_after_s`, `per_hour` and `mb_per_hour`. Can arrive before the final part of a snapshot still being delivered: it refuses your latest request, not that one. | Forget the latest ask (section 5.1). Updates keep coming. Ask again after `retry_after_s` only if you still need it. |
| `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 in one message. | Narrow the list. |
| `filter_too_large` | More than 64 values in total in one field of your subscription. The `subscribe` is ignored. | Narrow the list. |
| `empty_filter` | A `subscribe` with nothing to filter on: no field, or only channel names that do not exist. | Fix the message. |
| `bad_json` | A message that is not valid JSON. | Fix the client. |
| `unknown_type` | A missing or unknown `type`. | Fix the client. |

Log any other `code` and carry on.

---

## 8. Reconnecting

Connections drop: networks fail, the server restarts 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
   (`delay = min(delay * 2, 30)`); do not compute `2 ** attempt` on an unbounded counter.
   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. Exit with a clear
   message for the user. `4107` means another process took over the 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 the book, then sweep it.** Filters do not survive a
   reconnect. Send `login` and the same single `subscribe` with `"snapshot": true`, merge
   the snapshot into the book you already hold, and sweep on its final part (rule 7).
7. **On `feed` `"down"` (or `feed_alive: false`), stay connected.** The problem is upstream
   of the server, not your connection. When data resumes you get `feed` `"up"`, and then
   you ask for a snapshot.

| 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 |

---

## 9. Limits

### 9.1 Plan limits (per account)

Your own values are in `welcome.account.limits`; read them from there at run time.

| Limit | Trial | REST | REST + WebSocket |
|---|---|---|---|
| WebSocket stream (`limits.stream`) | Yes, 1 connection | No (close 4109) | Yes, 1 connection |
| REST API (`limits.rest`) | Yes | Yes | Yes |
| Snapshots per hour | 6 | — | 12 |
| Snapshot volume per hour | 200 MB | — | 400 MB |
| Reconnects per minute | 6 | — | 12 |
| Sports | All 13 | All 13 | All 13 |
| Streams | Live | — | Live |
| IPs at a time | 1 | 1 | 1 |

- **Trial** lasts 7 days from the moment it is activated, with REST and one WebSocket.
- **Every plan has at most one WebSocket connection.** There are no extra connections.
- **Connections.** Opening one more than the plan allows does not fail: the oldest
  connection closes with `replaced` (4107) and the new one takes its slot.
- **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 megabytes served, across all your connections,
  over a rolling hour. Every snapshot counts, including the one each `subscribe` brings and
  the one after every reconnect.

### 9.2 Protocol limits (the same on every plan)

| Limit | Value |
|---|---|
| Time to send `login` | 15 s |
| Largest message you can send | 256 KB |
| `events` across all your subscribes | 1,000 |
| Values in one filter field other than `events` | 64 |
| `channels` values read from one message | 16 |
| 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 |
| Silence before you reconnect (client side) | 40 s |
| Largest message to accept (client side) | 16 MB |

---

## 10. REST

A small API to look up the current state. Not needed for streaming. Only on plans with
`account.limits.rest` `true` (every plan today, Trial included).

- Base: `https://feed.oddspush.com/v1`. `GET` only. Responses are JSON.
- The key goes in the `x-token` header. From the same machine as the stream: a request
  from a new IP takes over the key and closes an open stream from the old one (4107).
- Requests are rate limited per account (a burst, then a steady rate per minute).

```bash
curl -s -H "x-token: $ODDSPUSH_KEY" "https://feed.oddspush.com/v1/events?sport_id=1&channel=ld"
```

| GET | Query | Returns |
|---|---|---|
| `/v1/events` | `sport_id`, `channel` | Every event held, one row per event, ordered by sport and event id, with the number of open markets per channel. `markets` counts the ones on `channel` (default `ld`). |
| `/v1/events/{event_id}` | `channel` | One event: `meta` (a `rec` without markets), `periods`, its markets by `key` for each channel, and `merged`: `ld` and `both` merged by `version`, the price to show. With `channel` you get that one channel, and `merged` holds the same markets. |
| `/v1/membership` | `sport_id` | Per sport id, the pre-match events about to go live: `n` and `ids`. |
| `/v1/health` | — | `{"ok": true}` while the feed is flowing, `false` while our source is down. 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`.

```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}
]}
```

Trimmed (each value in `ld` and `merged` is a full market object):

```json
{"event_id": 1638780974, "sport_id": 1, "topic": "matchups/reg/sp/29/live/ld",
 "meta": {"id": 1638780974, "type": "matchup", "startTime": "2026-10-31T15:00:00Z",
          "league": {"id": 1980, "name": "England - Premier League"}},
 "periods": {"0": {"period": 0, "status": "open"}},
 "ld": {"s;0;ou;2.5": {"key": "s;0;ou;2.5", "type": "total", "version": 3772905263}},
 "merged": {"s;0;ou;2.5": {"key": "s;0;ou;2.5", "type": "total", "version": 3772905263}}}
```

```json
{"1": {"n": 3, "ids": [1638790112, 1638790380, 1638791004]}}
```

Errors come as `{"detail": "<code>"}`:

| 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, or a trial not activated yet. |
| 403 | `subscription_expired`, `rest_not_in_plan`, `sport_not_in_plan` | Plan ended, no REST on the plan, or a sport outside the plan. |
| 404 | `not_found` | No such event, or one in a sport outside the plan. Any other path or method answers 404 with `"Not Found"`. |
| 429 | `rate_limited` | Too many requests. Wait the number of seconds in the `retry-after` response header. |
| 503 | `unavailable` | The state is not available right now. Retry shortly. |

---

## 11. Reference implementations

Both clients do everything in this guide: key from the environment, `login`, one
`subscribe` to soccer on `ld` and `both`, a book merged by market version, a sweep after
each snapshot of everything followed, a snapshot on `missed` and on `feed` `"up"`, a read
loop that only parses and queues, a separate consumer for the book, a 40 s silence
watchdog, reconnection with the backoff policy, and a clean stop on 4101, 4103, 4107 and
4109. `dz` is not requested. Each has a `--selftest` that checks the book rules offline.

To adapt one:

- Set `SPORTS` to the user's sports.
- Replace `on_change` with what the user wants. It receives the book, the event id and the
  market keys that changed (an empty list when the event was removed). The current market
  is `book.events[id]["markets"][key]` (Python) or `book.events.get(id).markets.get(key)`
  (Node.js); a key missing there means the market closed. Keep slow work (database writes,
  network calls) out of it or hand it to another task.
- Keep the read loop, the backoff, the stop codes and the `Book` rules as they are.

### 11.1 Python (asyncio + `websockets` 13 or newer)

```python
"""OddsPush client: log in, subscribe, keep a correct book, reconnect with backoff.

    pip install "websockets>=13"
    export ODDSPUSH_KEY=opk_...          # from the environment; never hard-code or commit it
    python oddspush_client.py            # run
    python oddspush_client.py --selftest # check the book rules offline
"""
import asyncio, json, logging, os, random, sys

from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, WebSocketException

URL = "wss://feed.oddspush.com/v1/stream"
SPORTS = [1]                                   # soccer; ids 1-13 are in section 4.3
SUBSCRIBE = {"type": "subscribe", "sports": SPORTS, "channels": ["ld", "both"], "snapshot": True}
PRICE_CHANNELS = {"ld", "both"}                # the displayed price; dz is not requested
FATAL = {4101: "unauthorized", 4103: "subscription_expired", 4107: "replaced", 4109: "stream_not_in_plan"}
SILENCE_S = 40                                 # no message at all for this long: the connection is dead
BACKOFF_MIN_S, BACKOFF_MAX_S, STABLE_S = 1.0, 30.0, 60.0
LOCAL_QUEUE_MAX = 5000                         # bounded: a slow consumer shows up as `missed`, not as memory
log = logging.getLogger("oddspush")


def american_to_decimal(american):
    return 1 + american / 100 if american > 0 else 1 + 100 / -american


def channel_of(topic):
    return str(topic or "").rsplit("/", 1)[-1]  # ld, both, dz, pre; anything else is "other"


class Book:
    """Events by rec.id, markets by key, ordered by markets[i].version (never rec.version)."""

    def __init__(self):
        self.events = {}         # id -> {"rec": fields, "markets": {key: m}, "seen": {key: tick}, "tick": n}
        self.tick = 0            # bumped on every record applied
        self.sweep_from = None   # tick when the full 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):
        """Merge one event record (odds or snapshot). Returns the market keys that changed."""
        self.tick += 1
        ev = self.events.get(rec["id"])
        if ev is None:
            # No entry for a record with nothing to hold: most snapshot events and every
            # special arrive without an open price market.
            if not any(isinstance(m, dict) and m.get("status") == "open" for m in rec.get("markets") or []):
                return []
            ev = self.events[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, changed = 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                                  # equal or lower: reordered, skip
            if m.get("status") == "open":
                markets[key] = m
                changed.append(key)
            elif markets.pop(key, None) is not None:      # anything but "open" closes it
                changed.append(key)
        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"):
                for key in [k for k, m in markets.items() if str(m.get("period")) == str(p.get("period"))]:
                    del markets[key]                      # a closed period closes its markets
                    changed.append(key)
        return changed

    def delete(self, event_id):
        return self.events.pop(event_id, None) is not None

    def snapshot_asked(self):    # on asking for a snapshot of everything you follow
        if self.sweep_from is None:
            self.sweep_from = self.tick
        elif self.sweep_next is None:
            self.sweep_next = self.tick

    def snapshot_refused(self):  # on snapshot_rate_limited: it refuses the latest request
        if self.sweep_next is not None:
            self.sweep_next = None
        else:
            self.sweep_from = None

    def snapshot_done(self):     # on the final part: drop what nobody mentioned since the ask
        since, self.sweep_from, self.sweep_next = self.sweep_from, self.sweep_next, None
        if since is None:
            return 0
        gone = [i for i, ev in self.events.items() if ev["tick"] <= since]
        for eid in gone:
            del self.events[eid]
        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]
                ev["seen"].pop(key, None)
                gone.append(key)
        return len(gone)

    def new_connection(self):    # a snapshot in flight dies with its socket: keep the book
        self.sweep_from = self.sweep_next = None


def on_change(book, event_id, keys):
    """Replace with what the user wants (print, store, call a strategy). Runs in the consumer."""
    ev = book.events.get(event_id)
    if ev is None:
        return log.info("event %s removed", event_id)
    for key in keys:
        m = ev["markets"].get(key)
        if m is None:
            log.info("%s %s closed", event_id, key)
            continue
        quotes = [f"{p.get('designation', p.get('participantId'))}={american_to_decimal(p['price']):.3f}"
                  for p in m.get("prices") or [] if isinstance(p.get("price"), (int, float)) and p["price"]]
        log.info("%s %s v%s %s", event_id, key, m.get("version"), " ".join(quotes))


class Client:
    def __init__(self, key):
        self.key, self.book = key, Book()
        self.queue = asyncio.Queue(maxsize=LOCAL_QUEUE_MAX)
        self.ws, self.conn, self.retry_after = None, 0, 0.0   # conn: number of the current connection

    async def read(self, ws):
        """The read loop: receive, parse, queue. No other work here."""
        self.conn += 1
        conn, self.ws = self.conn, ws
        await self.queue.put((conn, {"type": "_connected"}))
        await ws.send(json.dumps({"type": "login", "token": self.key}))
        await ws.send(json.dumps(SUBSCRIBE))              # no need to wait for welcome
        while True:
            raw = await asyncio.wait_for(ws.recv(), SILENCE_S)
            try:
                msg = json.loads(raw)
            except ValueError:
                continue
            if msg.get("type") == "error" and msg.get("code") == "reconnect_rate_limited":
                self.retry_after = float(msg.get("retry_after_s") or 0)   # the close follows
            await self.queue.put((conn, msg))

    async def ask_snapshot(self):
        await self.ws.send(json.dumps({"type": "snapshot"}))   # everything you follow
        self.book.snapshot_asked()

    async def consume(self):
        while True:
            conn, msg = await self.queue.get()
            try:
                await self.handle(conn == self.conn and self.ws is not None, msg)
            except Exception:
                log.exception("handling %s failed", msg.get("type"))

    async def handle(self, current, msg):
        kind, book = msg.get("type"), self.book
        if kind == "_connected":
            book.new_connection()
            book.snapshot_asked()                         # the subscribe brings one
        elif kind == "welcome":
            acc = msg.get("account") or {}
            log.info("logged in: plan %s, expires_at %s, feed %s", acc.get("plan"), acc.get("expires_at"), msg.get("feed"))
            if (msg.get("feed") or {}).get("ready") is False:
                book.new_connection()                     # snapshot may be incomplete: merge, do not sweep
        elif kind == "odds":
            data = msg.get("data") or {}
            rec = data.get("rec") or {}
            if data.get("type") == "live" and "id" in rec:
                if data.get("op") == "del":
                    if book.delete(rec["id"]):
                        on_change(book, rec["id"], [])
                elif channel_of(data.get("topic")) in PRICE_CHANNELS:
                    keys = book.apply_rec(rec)
                    if keys:
                        on_change(book, rec["id"], keys)
            elif data.get("type") == "snapshot":          # resent by our source: merge, never sweep
                for r in data.get("events") or []:
                    if "id" in r:
                        book.apply_rec(r)
        elif kind == "snapshot":
            for rec in msg.get("events") or []:
                if "id" in rec and (keys := book.apply_rec(rec)):
                    on_change(book, rec["id"], keys)
            if msg.get("final"):
                log.info("snapshot final part: %d events, %d swept", len(book.events), book.snapshot_done())
        elif kind == "missed":
            log.warning("missed %s updates", msg.get("count"))
            if current:
                await self.ask_snapshot()
        elif kind == "feed":
            log.warning("feed %s", msg.get("status"))
            if msg.get("status") == "up" and current:
                await self.ask_snapshot()                 # several bots: wait a random 0-30 s first
        elif kind == "error":
            if msg.get("code") == "snapshot_rate_limited":
                book.snapshot_refused()
            log.warning("server error: %s", msg)
        elif kind == "subscription":
            log.info("filter: %s", msg.get("filter"))
        # keepalive, pong, status and any unknown type: nothing to do

    async def run(self):
        consumer = asyncio.create_task(self.consume())
        loop, delay = asyncio.get_running_loop(), BACKOFF_MIN_S
        try:
            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.read(ws)
                except ConnectionClosed as e:
                    code = e.rcvd.code if e.rcvd else 1006
                    if code in FATAL:
                        log.error("closed with %s (%s): not reconnecting", code, FATAL[code])
                        return 1
                    log.warning("closed with %s", code)
                except (OSError, asyncio.TimeoutError, WebSocketException) as e:
                    log.warning("connection lost: %r", e)     # DNS, TCP, TLS, handshake, silence
                finally:
                    self.ws = None
                if opened is not None and loop.time() - opened >= STABLE_S:
                    delay = BACKOFF_MIN_S                     # reset only after a healthy session
                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 previous delay, capped
        finally:
            consumer.cancel()


def selftest():
    def mk(v, price, status="open", period=0):
        return {"key": f"s;{period};m", "type": "moneyline", "period": period, "status": status,
                "version": v, "prices": [{"designation": "home", "price": price}]}
    b = Book()
    price = lambda: b.events[1]["markets"]["s;0;m"]["prices"][0]["price"]
    b.apply_rec({"id": 1, "version": 5, "markets": [mk(10, -110)]})
    b.apply_rec({"id": 1, "version": 99, "markets": [mk(9, 200)]})   # lower version: skipped
    b.apply_rec({"id": 1, "markets": [mk(10, 300)]})                 # equal version: skipped
    assert price() == -110
    b.apply_rec({"id": 1, "markets": [mk(11, 150)]})                 # higher version: applied
    assert price() == 150
    b.apply_rec({"id": 1, "markets": [mk(12, 150, status="closed")]})
    assert "s;0;m" not in b.events[1]["markets"]                     # not "open": removed
    b.apply_rec({"id": 1, "markets": [mk(13, 120, period=1)]})
    b.apply_rec({"id": 1, "periods": [{"period": 1, "status": "settled"}]})
    assert "s;1;m" not in b.events[1]["markets"]                     # settled period: removed
    b.apply_rec({"id": 1, "markets": [mk(14, 101)]})
    b.apply_rec({"id": 2, "markets": [mk(1, 101)]})
    b.snapshot_asked()
    b.apply_rec({"id": 1, "markets": [mk(14, 101)]})                 # the snapshot mentions event 1 only
    assert b.snapshot_done() == 1 and 2 not in b.events and 1 in b.events
    b.apply_rec({"id": 3, "type": "special", "markets": [{"key": "s;0;m", "version": 1}]})
    b.apply_rec({"id": 4, "markets": []})
    assert 3 not in b.events and 4 not in b.events               # nothing open: no entry
    assert round(american_to_decimal(-238), 2) == 1.42 and round(american_to_decimal(655), 2) == 7.55
    print("selftest ok")


def main():
    if "--selftest" in sys.argv:
        return selftest()
    key = os.environ.get("ODDSPUSH_KEY", "").strip()
    if not (key.startswith("opk_") and len(key) == 47):
        sys.exit("ODDSPUSH_KEY is missing or is not opk_ followed by 43 characters")
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    try:
        return asyncio.run(Client(key).run())
    except KeyboardInterrupt:
        return 0


if __name__ == "__main__":
    sys.exit(main())
```

### 11.2 Node.js (`ws` 8 or newer, Node.js 18 or newer, ES modules)

```js
// OddsPush client: log in, subscribe, keep a correct book, reconnect with backoff.
//
//   npm install ws                       # ws 8 or newer, Node.js 18 or newer
//   export ODDSPUSH_KEY=opk_...          # from the environment; never hard-code or commit it
//   node oddspush_client.mjs             # run
//   node oddspush_client.mjs --selftest  # check the book rules offline
import WebSocket from 'ws';

const URL = 'wss://feed.oddspush.com/v1/stream';
const SPORTS = [1]; // soccer; ids 1-13 are in section 4.3
const SUBSCRIBE = { type: 'subscribe', sports: SPORTS, channels: ['ld', 'both'], snapshot: true };
const PRICE_CHANNELS = new Set(['ld', 'both']); // the displayed price; dz is not requested
const FATAL = new Map([[4101, 'unauthorized'], [4103, 'subscription_expired'], [4107, 'replaced'], [4109, 'stream_not_in_plan']]);
const SILENCE_MS = 40_000; // no message at all for this long: the connection is dead
const BACKOFF_MIN_MS = 1_000, BACKOFF_MAX_MS = 30_000, STABLE_MS = 60_000;
const LOCAL_QUEUE_MAX = 5000; // past this, pause reading: a slow consumer shows up as `missed`

const americanToDecimal = (a) => (a > 0 ? 1 + a / 100 : 1 + 100 / -a);
const channelOf = (topic) => String(topic ?? '').split('/').pop(); // ld, both, dz, pre; else "other"

class Book { // events by rec.id, markets by key, ordered by markets[i].version (never rec.version)
  constructor() {
    this.events = new Map(); // id -> { rec, markets: Map(key -> market), seen: Map(key -> tick), tick }
    this.tick = 0;           // bumped on every record applied
    this.sweepFrom = null;   // tick when the full snapshot being served was asked for
    this.sweepNext = null;   // the same, for one asked while another is in flight
  }

  applyRec(rec) { // merge one event record (odds or snapshot); returns the market keys that changed
    this.tick += 1;
    let ev = this.events.get(rec.id);
    if (!ev) {
      // No entry for a record with nothing to hold: most snapshot events and every special
      // arrive without an open price market.
      if (!(rec.markets || []).some((m) => m && m.status === 'open')) return [];
      this.events.set(rec.id, (ev = { rec: {}, markets: new Map(), seen: new Map(), tick: 0 }));
    }
    ev.tick = this.tick;
    const { markets, ...fields } = rec;
    Object.assign(ev.rec, fields);
    const changed = [];
    for (const m of markets || []) {
      if (m.key === undefined) continue;
      ev.seen.set(m.key, this.tick); // mentioned, even if not newer
      const prev = ev.markets.get(m.key);
      if (prev && Number.isInteger(m.version) && Number.isInteger(prev.version) && m.version <= prev.version) continue; // reordered
      if (m.status === 'open') { ev.markets.set(m.key, m); changed.push(m.key); }
      else if (ev.markets.delete(m.key)) changed.push(m.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')) continue;
      for (const [key, m] of ev.markets) {
        if (String(m.period) === String(p.period)) { ev.markets.delete(key); changed.push(key); } // closed period
      }
    }
    return changed;
  }

  delete(id) { return this.events.delete(id); }

  snapshotAsked() { // on asking for a snapshot of everything you follow
    if (this.sweepFrom === null) this.sweepFrom = this.tick;
    else if (this.sweepNext === null) this.sweepNext = this.tick;
  }

  snapshotRefused() { // on snapshot_rate_limited: it refuses the latest request
    if (this.sweepNext !== null) this.sweepNext = null;
    else this.sweepFrom = null;
  }

  snapshotDone() { // on the final part: drop what nobody mentioned since the ask
    const since = this.sweepFrom;
    this.sweepFrom = this.sweepNext;
    this.sweepNext = null;
    if (since === null) return 0;
    let removed = 0;
    for (const [id, ev] of this.events) {
      if (ev.tick <= since) { this.events.delete(id); removed += 1; continue; }
      for (const key of [...ev.markets.keys()]) {
        if ((ev.seen.get(key) ?? 0) <= since) { ev.markets.delete(key); ev.seen.delete(key); removed += 1; }
      }
    }
    return removed;
  }

  newConnection() { this.sweepFrom = this.sweepNext = null; } // a snapshot in flight dies with its socket
}

function onChange(book, eventId, keys) { // replace with what the user wants; runs in the consumer
  const ev = book.events.get(eventId);
  if (!ev) return console.info('event', eventId, 'removed');
  for (const key of keys) {
    const m = ev.markets.get(key);
    if (!m) { console.info(eventId, key, 'closed'); continue; }
    const quotes = (m.prices || []).filter((p) => typeof p.price === 'number' && p.price !== 0)
      .map((p) => `${p.designation ?? p.participantId}=${americanToDecimal(p.price).toFixed(3)}`);
    console.info(eventId, key, `v${m.version}`, quotes.join(' '));
  }
}

const book = new Book();
const queue = [];
let draining = false, ws = null, conn = 0; // conn: number of the current connection
let delay = BACKOFF_MIN_MS, retryAfterMs = 0, stopped = false;

function enqueue(c, msg) {
  queue.push([c, msg]);
  if (queue.length > LOCAL_QUEUE_MAX && ws && typeof ws.pause === 'function') ws.pause();
  if (!draining) { draining = true; setImmediate(drain); }
}

function drain() { // the consumer: one batch per turn, so the socket keeps being read in between
  for (const [c, msg] of queue.splice(0, 500)) {
    try { handle(c === conn && ws !== null, msg); } catch (err) { console.error('handling', msg.type, 'failed:', err); }
  }
  if (queue.length < LOCAL_QUEUE_MAX / 2 && ws && typeof ws.resume === 'function') ws.resume();
  if (queue.length) setImmediate(drain);
  else draining = false;
}

function askSnapshot() {
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
  ws.send(JSON.stringify({ type: 'snapshot' })); // everything you follow
  book.snapshotAsked();
}

function handle(current, msg) {
  switch (msg.type) {
    case '_connected':
      book.newConnection();
      book.snapshotAsked(); // the subscribe brings one
      break;
    case 'welcome':
      console.info('logged in: plan', msg.account?.plan, 'expires_at', msg.account?.expires_at, 'feed', msg.feed);
      if (msg.feed?.ready === false) book.newConnection(); // snapshot may be incomplete: merge, do not sweep
      break;
    case 'odds': {
      const data = msg.data || {};
      const rec = data.rec || {};
      if (data.type === 'live' && rec.id !== undefined) {
        if (data.op === 'del') { if (book.delete(rec.id)) onChange(book, rec.id, []); }
        else if (PRICE_CHANNELS.has(channelOf(data.topic))) {
          const keys = book.applyRec(rec);
          if (keys.length) onChange(book, rec.id, keys);
        }
      } else if (data.type === 'snapshot') { // resent by our source: merge, never sweep
        for (const r of data.events || []) if (r.id !== undefined) book.applyRec(r);
      }
      break;
    }
    case 'snapshot':
      for (const rec of msg.events || []) {
        const keys = rec.id === undefined ? [] : book.applyRec(rec);
        if (keys.length) onChange(book, rec.id, keys);
      }
      if (msg.final) console.info('snapshot final part:', book.events.size, 'events,', book.snapshotDone(), 'swept');
      break;
    case 'missed':
      console.warn('missed', msg.count, 'updates');
      if (current) askSnapshot();
      break;
    case 'feed':
      console.warn('feed', msg.status);
      if (msg.status === 'up' && current) askSnapshot(); // several bots: wait a random 0-30 s first
      break;
    case 'error':
      if (msg.code === 'snapshot_rate_limited') book.snapshotRefused();
      console.warn('server error:', msg);
      break;
    case 'subscription':
      console.info('filter:', JSON.stringify(msg.filter));
      break;
    default: // keepalive, pong, status and any unknown type: nothing to do
  }
}

function connect(key) {
  let openedAt = 0, silence;
  const sock = new WebSocket(URL, { perMessageDeflate: false, maxPayload: 16 * 1024 * 1024, handshakeTimeout: 10_000 });
  const alive = () => { clearTimeout(silence); silence = setTimeout(() => sock.terminate(), SILENCE_MS); };

  sock.on('open', () => {
    openedAt = Date.now();
    conn += 1;
    ws = sock;
    enqueue(conn, { type: '_connected' });
    sock.send(JSON.stringify({ type: 'login', token: key }));
    sock.send(JSON.stringify(SUBSCRIBE)); // no need to wait for welcome
    alive();
  });

  sock.on('message', (raw) => { // the read loop: parse and queue, nothing else
    alive();
    let msg;
    try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
    if (msg.type === 'error' && msg.code === 'reconnect_rate_limited') retryAfterMs = (msg.retry_after_s || 0) * 1000;
    enqueue(conn, msg);
  });

  sock.on('error', (err) => console.warn('connection error:', err.message)); // 'close' follows

  sock.on('close', (code) => {
    clearTimeout(silence);
    if (ws === sock) ws = null;
    if (stopped) return;
    if (FATAL.has(code)) {
      console.error('closed with', code, `(${FATAL.get(code)}): not reconnecting`);
      stopped = true;
      process.exitCode = 1;
      return;
    }
    if (openedAt && Date.now() - openedAt >= STABLE_MS) delay = BACKOFF_MIN_MS; // only after a healthy session
    const wait = Math.max(delay / 2 + Math.random() * (delay / 2), retryAfterMs);
    retryAfterMs = 0;
    delay = Math.min(delay * 2, BACKOFF_MAX_MS); // grow from the previous delay, capped
    console.info('closed with', code, '- reconnecting in', Math.round(wait), 'ms');
    setTimeout(() => connect(key), wait);
  });
}

function selftest() {
  const assert = (ok, what) => { if (!ok) throw new Error(`selftest failed: ${what}`); };
  const mk = (v, price, status = 'open', period = 0) =>
    ({ key: `s;${period};m`, type: 'moneyline', period, status, version: v, prices: [{ designation: 'home', price }] });
  const b = new Book();
  const price = () => b.events.get(1).markets.get('s;0;m').prices[0].price;
  b.applyRec({ id: 1, version: 5, markets: [mk(10, -110)] });
  b.applyRec({ id: 1, version: 99, markets: [mk(9, 200)] }); // lower version: skipped
  b.applyRec({ id: 1, markets: [mk(10, 300)] });             // equal version: skipped
  assert(price() === -110, 'stale update skipped');
  b.applyRec({ id: 1, markets: [mk(11, 150)] });
  assert(price() === 150, 'newer update applied');
  b.applyRec({ id: 1, markets: [mk(12, 150, 'closed')] });
  assert(!b.events.get(1).markets.has('s;0;m'), 'closed market removed');
  b.applyRec({ id: 1, markets: [mk(13, 120, 'open', 1)] });
  b.applyRec({ id: 1, periods: [{ period: 1, status: 'settled' }] });
  assert(!b.events.get(1).markets.has('s;1;m'), 'settled period removed');
  b.applyRec({ id: 1, markets: [mk(14, 101)] });
  b.applyRec({ id: 2, markets: [mk(1, 101)] });
  b.snapshotAsked();
  b.applyRec({ id: 1, markets: [mk(14, 101)] }); // the snapshot mentions event 1 only
  assert(b.snapshotDone() === 1 && !b.events.has(2) && b.events.has(1), 'sweep');
  b.applyRec({ id: 3, type: 'special', markets: [{ key: 's;0;m', version: 1 }] });
  b.applyRec({ id: 4, markets: [] });
  assert(!b.events.has(3) && !b.events.has(4), 'nothing open: no entry');
  assert(americanToDecimal(-238).toFixed(2) === '1.42' && americanToDecimal(655).toFixed(2) === '7.55', 'odds');
  console.log('selftest ok');
}

if (process.argv.includes('--selftest')) {
  selftest();
} else {
  const key = (process.env.ODDSPUSH_KEY || '').trim();
  if (!(key.startsWith('opk_') && key.length === 47)) {
    console.error('ODDSPUSH_KEY is missing or is not opk_ followed by 43 characters');
    process.exit(2);
  }
  process.on('SIGINT', () => { stopped = true; if (ws) ws.close(1000); else process.exit(0); });
  connect(key);
}
```

---

## 12. Acceptance checklist

Run these before you tell the user the client is done. Report each result to the user.

1. **Self-test passes.** `python oddspush_client.py --selftest` or
   `node oddspush_client.mjs --selftest` prints `selftest ok` (or the equivalent tests in
   the user's language and test framework).
2. **Stale update is ignored.** A unit test applies a market with version 10, then the same
   key with version 9 and with version 10 and different prices: the book still holds the
   first price. Then version 11: the new price is applied.
3. **Close rules work.** A test with `status` other than `"open"` removes the market; a
   period with `"settled"` removes its markets; `op: "del"` removes the event.
4. **Sweep works.** A test: hold events A and B, ask for a snapshot, deliver a final part
   mentioning only A: B is gone, A stays.
5. **`welcome` received.** Run the client against the real stream. If you get an `error`
   instead, stop and tell the user what it means (section 7): `unauthorized` (key or
   trial), `replaced` (another instance of the client, here or on another machine),
   `subscription_expired` (plan).
6. **`subscription` echoed** with the user's sports and `["both", "ld"]`.
7. **Snapshot final part received** (`"final": true`), and the book holds events
   afterwards.
8. **`odds` updates applied.** Within a minute or two, at least one update changes the book
   (on a quiet sport or hour this can take longer; say so if nothing arrives).
9. **Reconnect works.** Kill the socket from inside the process (in Python `websockets`, `ws.transport.abort()`; in Node.js `ws`, `ws.terminate()`), or close it from the client
   with a non-fatal code: the client waits about 0.5–1 s, logs in again, receives
   `welcome`, the snapshot and its final part, and the book is kept, not emptied.
10. **Fatal codes stop.** A test (a local fake server, or a mocked close) closing with 4107
    makes the client exit without reconnecting. Same for 4101, 4103 and 4109.
11. **The key is safe.** `grep -rlF "$ODDSPUSH_KEY" .` finds no file with the real key (a plain `grep "opk_"` also matches this guide and the prefix check) in the source, config, tests
    or logs; the key is read only from `ODDSPUSH_KEY`; `.env` (if any) is git-ignored; no
    log line contains the `login` message.
12. **Optional: one instance per connection slot.** Running two copies on a one-connection
    plan makes the older one close with `replaced` (4107) and stop: tell the user.

---

## 13. Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Ordering by `rec.version` | It freezes once the game is in play: every in-play update looks stale, or reordered ones win. | Order by `markets[i].version` per `key`. |
| Accepting an equal version | A reordered duplicate overwrites the current price. | Apply only strictly higher versions. |
| Clearing the book on each snapshot | Prices vanish until the snapshot ends, and updates that arrived between parts are lost. | Merge each part; sweep only on the final part. |
| Never sweeping | Markets closed while disconnected stay in the book forever. | Rule 7. |
| Sweeping after a subset snapshot, or after `data.type` `"snapshot"` | Live markets outside the subset are deleted. | Sweep only after a snapshot of everything the book follows. |
| Treating a missing market in an update as closed | Markets not listed are unchanged. | Remove only on non-`open` status, closed or settled period, `op: "del"`, or the sweep. |
| Mixing `dz` into the price book | Spike-and-revert artefacts that look like price moves. | Do not request `dz`, or keep it in its own book. |
| Reading only `ld` | Whole matches that are only on `both` are missing. | Subscribe to `["ld", "both"]` and merge by version. |
| Omitting `channels` | Every channel arrives, `dz` included. | Always send `channels`. |
| Applying every `odds` message to the price book | `other` updates pass any filter and are not prices. | Check the channel of `data.topic`. |
| Using the sport id inside `topic` | It is Pinnacle's own id and does not match the sports table. | Use `data.sport_id`. |
| Several `subscribe` messages on connect, each with a snapshot | Each snapshot is a subset (no sweep possible) and each one spends the hourly budget. | One `subscribe` with the whole filter. |
| Heavy work in the read loop | `missed`, then `too_slow` (4108). | Read loop parses and queues; a consumer does the rest. |
| Reconnecting immediately or in a loop | `reconnect_rate_limited` (4106) locks you out. | Backoff with jitter, reset only after 60 s connected. |
| Retrying 4101, 4103, 4107 or 4109 | Burns the reconnect budget; with 4107, two instances throw each other out. | Stop and tell the user. |
| Key in the code, the URL or a log | Leaked key. | Environment variable only; never log `login`. |
| `2 ** attempt` backoff on an unbounded counter | Overflows after enough failures and the reconnect loop dies. | `delay = min(delay * 2, 30)`. |

---

## 14. Support

Paid plans and renewals: https://oddspush.com/account, paid in crypto. To activate the
trial, or for a new key, message @oddspush on Telegram (https://t.me/oddspush) with the
email of your account. Any question about this guide: support@oddspush.com. Human documentation: https://oddspush.com/docs.
