Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.oddspapi.io/llms.txt

Use this file to discover all available pages before exploring further.

Beta — rolling out per region. zstd is being enabled gradually. If a gateway has it disabled, your receiveType: "zstd" connection is gracefully downgraded to JSON — the server tells you the negotiated mode in login_ok.receiveType. Always trust that echo to choose your decoder; never assume the mode you requested.

Why zstd

odds is the highest-volume channel. zstd with a trained dictionary shrinks the data you receive ~7× (other channels similarly) — less bandwidth on the wire, so high-volume subscriptions are easier to keep up with and you’re less likely to hit backpressure disconnects. The format is uniform and self-describing:
  • Every data frame on a zstd connection is a binary zstd frame — on every channel.
  • Each frame embeds the id of the dictionary it was compressed with (dictId). Your client reads that id, picks the matching dictionary (or none), and decompresses.
  • No per-channel branching, ever. When we add a dictionary to a new channel later, your code does not change.
This mirrors how json (everything is text) and binary (everything is MessagePack) already work: one format for the whole connection.

Enabling it (login)

Set receiveType: "zstd". Optionally tell the server which dictionaries you already cached from a previous connection so it can skip re-sending them.
{
  "type": "login",
  "apiKey": "YOUR_API_KEY",
  "channels": ["odds", "fixtures"],
  "receiveType": "zstd",
  "dicts": { "odds": "odds-v1", "fixtures": "fixtures-v1" }
}
FieldTypeDescription
receiveTypestring"json" (default) | "binary" (MessagePack) | "zstd" (compressed JSON). Mutually exclusive whole-connection modes.
dictsobjectOptional { "<channel>": "<dictVersion>" } of dictionaries you already hold. The server skips re-sending those. Omit on a first connection.
There is intentionally no compressed-MessagePack mode — compressing JSON beats compressing MessagePack at every level.

What you get back (login_ok)

{
  "type": "login_ok",
  "receiveType": "zstd",
  "channels": ["odds", "fixtures"],
  "access": { "live": true, "pregame": false },
  "resume": { "serverEpoch": "…", "resumeWindowMs": 60000 }
}
login_ok.receiveType is the negotiated mode. If it comes back "json", the gateway has zstd disabled and you must decode plain JSON text frames — do not try to decompress.

Dictionary delivery (server → client)

Right after login_ok (and before any data frame), for each subscribed channel that has a trained dictionary you did not already list in dicts, the server pushes one control frame:
{
  "type": "dict",
  "channel": "odds",
  "dictVersion": "odds-v1",
  "dictId": 740826216,
  "encoding": "base64",
  "data": "<base64 of the ~32 KB zstd dictionary>"
}
What to do with it:
  1. Base64-decode data to get the raw dictionary bytes.
  2. Store it keyed by dictId (build a reusable zstd decoder from it).
  3. Keep channeldictVersion in your own cache so you can send the dicts map on the next connection and skip the re-download.
Decoding is driven by the dictId embedded in each data frame, not by channel. The channel/dictVersion fields on the dict frame are only for cache bookkeeping.
Channels without a trained dictionary (e.g. scores, clocks) send no dict frame — their data frames carry dictId = 0 and decode without a dictionary.

Data frames

  • Every data frame is a WebSocket Binary frame containing one standalone zstd frame (magic 28 B5 2F FD, with an embedded dictId).
  • Read dictId from the frame header, look it up in your store:
    • found → decompress with that dictionary,
    • 0 or unknown → decompress dictless.
  • Decompress, then JSON.parse, then route by .channel — exactly as in JSON mode.
Control frames (login_ok, dict, error, snapshot_required, resume_complete) remain JSON text frames, even on a zstd connection. The rule is fixed:
Text frame → control (JSON). Binary frame → data (zstd).

Decode (pseudocode)

on text frame   → JSON.parse → control (login_ok, dict, error, …)
on binary frame → id   = zstd.getDictID(frame)        // 0 ⇒ no dict
                  dict = store[id]                     // undefined ⇒ dictless
                  msg  = JSON.parse(zstd.decompress(frame, dict))
                  route by msg.channel
No channel ever appears in the decode decision.

🐍 Python example

Requires pip install zstandard.
import asyncio, json, base64, websockets
import zstandard as zstd

WS_URL = "wss://v5.oddspapi.io/ws"
API_KEY = "YOUR_API_KEY"

# dictId -> ZstdDecompressor (built once per dictionary)
decoders: dict[int, zstd.ZstdDecompressor] = {}
dictless = zstd.ZstdDecompressor()
MAX_OUT = 64 * 1024 * 1024  # frames carry no content size — pass an upper bound

def decode_frame(frame: bytes) -> dict:
    dict_id = zstd.get_frame_parameters(frame).dict_id  # 0 if no dictionary
    dctx = decoders.get(dict_id, dictless)
    # max_output_size is required: gateway frames don't embed the decompressed size
    return json.loads(dctx.decompress(frame, max_output_size=MAX_OUT))

async def main():
    async with websockets.connect(WS_URL, max_size=4194304) as ws:
        await ws.send(json.dumps({
            "type": "login",
            "apiKey": API_KEY,
            "channels": ["odds", "fixtures"],
            "receiveType": "zstd",
            # "dicts": {"odds": "odds-v1"},  # send versions you already cached
        }))

        async for raw in ws:
            # Text frame = control (JSON); binary frame = data (zstd)
            if isinstance(raw, str):
                msg = json.loads(raw)
                t = msg.get("type")

                if t == "login_ok":
                    if msg.get("receiveType") != "zstd":
                        # Gateway downgraded us — decode plain JSON instead.
                        print("zstd disabled here, negotiated:", msg.get("receiveType"))
                    continue

                if t == "dict":
                    raw_dict = base64.b64decode(msg["data"])
                    d = zstd.ZstdCompressionDict(raw_dict)
                    # key by the dictId embedded in the dict bytes
                    decoders[msg["dictId"]] = zstd.ZstdDecompressor(dict_data=d)
                    continue

                continue

            data = decode_frame(raw)
            print("DATA:", data.get("channel"), data.get("entryId"))

asyncio.run(main())

Caching dictionaries across reconnects

Dictionaries are ~32 KB each and rarely change. To avoid re-downloading them every reconnect:
  1. Persist the channel → dictVersion map you received via dict frames.
  2. On the next login, send it as dicts.
  3. The server only re-sends a dictionary whose version you do not list.
Keep your in-memory store keyed by dictId (not channel) — a dictionary rotation ships a new dictId, and a stale decoder simply won’t match (it errors rather than mis-decodes), which is the safety net.

See also