Skip to main content

Owls Insight Python SDK

Official Python SDK for the Owls Insight real-time sports betting odds API. Sync and async clients, fully typed with Pydantic v2, plus a Socket.io WebSocket client for live streams.

pip install owls-insight

Requires Python 3.9+. Get an API key at owlsinsight.com.

Quick start (sync)

from owls_insight import OwlsInsight

client = OwlsInsight(api_key="owlsinsight_...")

# REST: current NBA odds. `data` is keyed by sportsbook -> list of events.
# `market="h2h"` narrows to one market; `event.home_team_id` is a stable id
# for the team, the same across every book, so you can join on it.
odds = client.rest.get_odds("nba", books=["pinnacle", "fanduel"], market="h2h")
for book, events in odds.data.items():
    for event in events:
        print(book, event.home_team_id, "vs", event.away_team_id)

# A filter the endpoint did not honour is reported, never an error.
if odds.meta and odds.meta.ignored_params:
    print("ignored:", odds.meta.ignored_params, odds.meta.ignored_reasons)

# WebSocket: stream live updates. The subscription is sent again on every
# reconnect, so it holds for the life of the client.
client.ws.on("odds-update", lambda data: print("live:", data))
client.ws.connect(subscription={"sports": ["nba"], "books": ["pinnacle"]})

# or block for the next event
update = client.ws.wait_for("odds-update", timeout=15)

client.destroy()

Quick start (async)

import asyncio
from owls_insight import AsyncOwlsInsight

async def main():
    async with AsyncOwlsInsight(api_key="owlsinsight_...") as client:
        odds = await client.rest.get_odds("nba", books=["pinnacle"])
        print(sum(len(events) for events in odds.data.values()), "games")

        await client.ws.connect(subscription={"sports": ["nba"], "books": ["pinnacle"]})
        update = await client.ws.wait_for("pinnacle-realtime", timeout=30)
        print("realtime:", update)

asyncio.run(main())

Authentication

Pass your API key to the constructor. REST uses the Authorization: Bearer header; the WebSocket uses the ?apiKey= query string. Both are handled for you.

import os
client = OwlsInsight(api_key=os.environ["OWLS_INSIGHT_API_KEY"])

REST

Method names are snake_case (the JS SDK's getOdds is get_odds here). Every method returns a typed Pydantic model; unknown fields from the evolving API are preserved (model_extra), never dropped.

Full parity with the TypeScript SDK — every endpoint is available on both client.rest (sync) and the async client.

Area Methods
Odds get_odds, get_moneyline, get_spreads, get_totals, get_realtime, get_ps3838_realtime, get_esports_realtime, get_ev, list_events, get_one_x_bet_soccer, get_prophetx_odds
Props get_props, get_book_props, get_props_history, get_props_stats, get_book_props_stats
Prop results & trends get_prop_results (one game, or date= to list a day's graded games), get_prop_trends (hit rate / average / streak vs a line, or line="closing" + book)
Same-Game Parlay get_sgp_events, build_sgp (FanDuel bet-slip price; Rookie/MVP/HoF)
Scores / schedule get_scores, get_schedule, get_results, get_splits, normalize, normalize_batch
Stats get_stats, get_match_stats, get_h2h, get_player_averages
Line history get_odds_history, get_moneyline_history, get_spread_history, get_totals_history
Historical get_history_games, get_history_odds, get_history_props, iter_history_odds / iter_history_props (page a whole game, retries 429/503), get_history_stats, get_history_tennis_stats, get_game_stats_detail, get_closing_odds, get_historical_player_props, get_public_betting, get_cs2_matches, get_cs2_match, get_cs2_players
v2 Source API get_hard_rock_events, get_hard_rock_leagues, get_ballybet_v2/_leagues, get_bet365_v2/_leagues, get_betonline_v2/_leagues, get_betrivers_v2/_leagues, get_betus_v2/_leagues, get_bookmaker_v2/_leagues, get_bovada_v2/_leagues, get_draftkings_v2/_leagues, get_fanaticsmarkets_v2/_leagues, get_fanduel_v2/_leagues, get_kalshi_v2/_leagues, get_lowvig_v2/_leagues, get_mybookie_v2, get_pinnacle_v2/_leagues, get_polymarket_v2/_leagues, get_stake_v2, get_thescore_v2/_leagues, get_thunderpick_v2, get_tippmixpro_v2/_leagues, get_underdog_v2, get_versus_v2/_leagues, plus get_hard_rock_ladder for Hard Rock's rootIdx decode

Methods whose response shape isn't individually modelled yet return a permissive ApiResponse envelope (success/data/meta, all fields preserved). Deep per-endpoint Pydantic typing is the remaining follow-on; the method surface is complete.

ProphetX order book

Each ProphetX markets entry (keyed "{market_id}:{line}") parses as models.ProphetXMarket. The book is a list of sides in outcomes order, each side its price levels best-first or None when nothing is resting. On spread and total deltas the book sits under marketLines[0] rather than the top level, so use the helpers:

from owls_insight import OwlsInsight, prophetx_book, prophetx_outcomes, prophetx_best

px = OwlsInsight(api_key="...").rest.get_prophetx_odds("baseball")
for event in px.data.sports["Baseball"]:
    for key, market in event.markets.items():
        outcomes = prophetx_outcomes(market)        # labels, one per side
        for i, side in enumerate(prophetx_book(market)):
            best = prophetx_best(market, i)
            if best is None:                        # None side = nothing resting
                continue
            # best.value = amount you can bet now; best.odds = American; payout from those two, not from stake
            label = outcomes[i].name if i < len(outcomes) else None
            print(key, label, best.odds, best.value)

Hard Rock odds decoding

Hard Rock v2 selections carry a rootIdx, not inline odds. Decode it client-side:

from owls_insight import root_idx_to_american_odds
root_idx_to_american_odds(42)  # -325
root_idx_to_american_odds(72)  #  100  (even)

WebSocket

Connecting needs WebSocket access: Hall of Fame, or any paid plan with the WebSocket add-on.

Subscriptions

A subscription belongs to one connection, and the server forgets it when the connection ends. The client remembers the last one you sent, plus every props subscription, and sends them again on every connect and reconnect: with the connection request, and as subscribe / subscribe-*-props events right after it, before your own connect listeners run. You do not need to re-subscribe after a reconnect.

client.ws.connect(subscription={"sports": ["nba", "nhl"], "books": ["pinnacle", "fanduel"]})
client.ws.subscribe(sports=["nba"], event_ids=["<event id>"])   # REPLACE the whole subscription
client.ws.update_subscription(prophetx=True)                   # MERGE: change one field, keep the rest
client.ws.subscribe_props(book="fanduel")                      # props stream, also re-sent on reconnect
client.ws.on("props-update", handle_props)
print(client.ws.subscription)                                  # what is sent on every connect
  • subscribe() replaces the subscription: a field you leave out is cleared. update_subscription() changes only the fields you pass; each one is replaced whole, so to change v2, send the whole v2 object.
  • event_ids limits odds-update to the events with those id values (sent as eventIds). exclude_exchanges=True drops Kalshi, Polymarket and Novig from the books.
  • connect(subscription=...) takes the same fields as a dict. Snake_case names esports_realtime, one_x_bet_soccer and event_ids are sent under their server names; every other key is sent exactly as given (exclude_exchanges is already the server's name).
  • A connection that never subscribes receives the default: all sports, all books.
  • subscribed can arrive more than once after a connect. Each one carries the subscription in force.
  • disconnect() closes the connection, stops a reconnect in progress, and forgets the subscription and your listeners. You can call it from any listener, disconnect and connect_error included. A later connect() starts over, and the client again reconnects on its own after a drop.

Events: odds-update, props-update (and per-book {book}-props-update), pinnacle-realtime, pinnacle-realtime-sync, ps3838-realtime, esports-update, prophetx-update, the v2 {book}-v2-update deltas, and on every connection server-heartbeat and server-notice.

odds-update: after a subscribe the first frames are a snapshot of your subscription; later frames carry only the games that changed, so merge them into your board by event id.

Realtime: pinnacle-realtime

Sent to eligible connections (WebSocket access plus MVP or above) for the sports in your subscription, or every sport if you never subscribe. The books filter does not apply to realtime.

Each frame carries one sport and at least one event:

{"timestamp": ..., "<sport>": [events], "delta": true, "resync": true?, "stream": {...}?, "removed": {"<sport>": [ids]}?}
  • delta is always true: a frame holds only some events. Merge each event into your board by id, replacing the whole event, then delete the ids in removed[<sport>].
  • resync: true marks a frame from the periodic resend, which also carries events that did not change.
  • stream is {"sport", "epoch", "seq", "checkpoint"?}. Within one epoch, seq goes up by 1 per frame for that sport. stream and removed are optional: handle frames with and without them.
  • pinnacle-realtime-sync is its companion, on the same connections and for the same sports: {"timestamp", "sport", "stream"?, "removed"?}, a stream position or removals for a sport with no events to send. Track stream and apply removed exactly as on pinnacle-realtime; it never carries events.

Each event has the same fields as get_realtime data (typed there on models.OddsEvent): status, isLive, phase when known (scheduled, live, suspended, closed; new values can appear, so treat an unknown one like a missing field, and read phase first whenever it is present), lastUpdate and seenAt (epoch ms) and freshness. Markets carry last_update, and where present last_seen (when the feed last confirmed the price) and suspended: true.

Loading a board without gaps. Do this for each sport on every connect and reconnect, and whenever the frame checks below say so:

  1. Keep your current board. Buffer that sport's frames from both pinnacle-realtime and pinnacle-realtime-sync.
  2. Wait up to 25 seconds for a frame with stream.checkpoint set. If the sport's frames carry no stream, load at once. After a reconnect, also wait a random 0 to 10 seconds, so that clients reconnecting together (for example after SERVER_RESTART) do not all load at the same moment.
  3. Call resp = client.rest.get_realtime(sport) and read stream = getattr(resp.meta, "stream", None). To hold the board as the same kind of dicts the frames carry, use {e.id: e.model_dump(exclude_unset=True) for e in resp.data}: exclude_unset=True keeps only the fields the response carried, as a frame does. Prices come back as floats (-120.0 where a frame has -120).
    • None: replace your board with resp.data, merge every frame with no seq check, and load again after 10, 20, 40 seconds and so on, up to every 5 minutes. Replacing the board is also what drops ended events while frames carry no removed.
    • its epoch differs from the checkpoint's, or its seq is lower than the checkpoint's: load again at the next checkpoint.
    • otherwise: replace your board with resp.data, discard buffered frames with a seq at or below stream["seq"], and apply the rest in order.

Each load counts against your request quota. Then apply frames like this:

board = {}        # event id -> event, for one sport
position = None   # (epoch, seq) once a load aligned with the stream, else None

def frame_sport(frame):
    """The one sport a pinnacle-realtime or pinnacle-realtime-sync frame is about."""
    if frame.get("stream"):
        return frame["stream"]["sport"]
    if isinstance(frame.get("sport"), str):
        return frame["sport"]
    return next(key for key, value in frame.items() if isinstance(value, list))

def apply(frame, sport):
    for event in frame.get(sport) or []:
        board[event["id"]] = event                    # replace the whole event
    for event_id in (frame.get("removed") or {}).get(sport, []):
        board.pop(event_id, None)

def on_frame(frame, sport):
    """Apply one frame. False means: load the board again (keep this one meanwhile)."""
    global position
    if position is None:                              # not aligned: merge everything
        apply(frame, sport)
        return True
    stream = frame.get("stream")
    if stream is None or stream["epoch"] != position[0]:
        return False                                  # stream stopped, or a new epoch
    if stream["seq"] <= position[1]:
        return True                                   # a duplicate: ignore it
    if stream["seq"] != position[1] + 1:
        return False                                  # a frame was missed
    position = (stream["epoch"], stream["seq"])
    apply(frame, sport)
    return True

Leagues. Frames carry every league of the sport. If you load with get_realtime(sport, league=...), apply the same filter to frames before merging them: the REST filter keeps the events whose league contains your text, ignoring case.

Stalls. Once a sport's frames carry stream, more than 30 seconds without a frame for that sport while you are connected means its stream has stopped or the sport has no events left. Load it again as above, and while it stays silent, keep loading after 10, 20, 40 seconds and so on, up to every 5 minutes. resp.meta.feed says how long ago data last arrived for the sport.

On get_realtime, meta.stream is {"epoch", "seq"} (the stream position the response matches) or None, and meta.feed carries lastDataAgeSeconds, upstreamAgeSeconds (seconds since Pinnacle last sent anything for the sport) and resubscribing. The last two are None when unknown. These are extra fields on resp.meta: read them as getattr(resp.meta, "stream", None) and resp.meta.feed["upstreamAgeSeconds"].

Realtime: ps3838-realtime

Same eligibility and sport filter as pinnacle-realtime. Each frame carries one sport and can be several MB.

  • delta: true: changed or added events. Merge them into that sport's board by id. Deltas carry no removals.
  • snapshot: true: the sport's full board. Replace your board for that sport. Sent about every 20 seconds, and early when events are removed.
  • neither marker: a full board for the sport. Replace.

Load client.rest.get_ps3838_realtime(sport) on every connect and reconnect, then apply frames. No frame is sent for a sport with no events, so when a sport's board empties, its last events stay on your board: load a sport again when it has sent no frame for a minute or more. Each load counts against your request quota.

Connection health

  • server-heartbeat: {"seq", "ts"} every 10 seconds on every connection, whatever odds traffic there is, so silence is detectable. seq is counted by the server, not per connection: after a reconnect it can jump ahead or start again from 1. Compare it only within one connection, where a gap means messages were missed.
  • server-notice: why the server closed your previous connection, sent once on the next connect: {"code", "message", "at", "socketId", "details", "hint"}. SERVER_RESTART is instead sent to open connections just before a restart; the client reconnects and re-subscribes on its own.
code Meaning
BACKLOG_CAP The connection fell too far behind: more data was waiting for it than the per-connection limit allows. Keep handlers fast and subscribe only to what you need.
AGGREGATE_SHED The connection was among those furthest behind when the server had to shed load. Same advice.
GHOST Data sent to the connection had not been read for over a minute.
SLOT_EVICTED The connection had stopped responding and was closed to admit a new one with the same API key.
CLIENT_FRAME_LIMIT Your client closed the connection because a message was larger than its maximum message size (close code 1009).
SERVER_RESTART The server is restarting.

SERVER_NOTICE_CODES lists them; the list can grow.

Message size. A single realtime frame can be several MB, so allow at least 32 MB per message. The async client accepts up to 64 MiB by default: AsyncWebSocketClient(..., max_message_size=...) or AsyncOwlsInsight(..., ws_max_message_size=...), where 0 means no limit and None keeps aiohttp's own 4 MiB. The sync client has no limit.

Refused connections. A failed connect() raises OwlsInsightConnectionError (a subclass of socketio.exceptions.ConnectionError, so existing except clauses still work):

from owls_insight import OwlsInsightConnectionError

try:
    client.ws.connect(subscription={"sports": ["nba"]})
except OwlsInsightConnectionError as e:
    print(e.code, e.retryable, e.retry_after_ms, e.max_connections, e.server_message)
code retryable
MISSING_API_KEY, INVALID_API_KEY, API_KEY_DEACTIVATED, TIER_NO_WS, SUBSCRIPTION_INACTIVE, PAYMENT_OVERDUE, TRIAL_EXPIRED, TRIAL_UNVERIFIABLE no
SUBSCRIPTION_CHECK_FAILED, INTERNAL, CLIENT_CLOSED yes
IP_BLOCKED (retry_after_ms is how long the block lasts) yes
CONNECTION_LIMIT (every connection slot of the plan is in use; max_connections is how many the plan has, retry_after_ms when to try again) yes

code is None when the server sent none (for example the address could not be reached); retryable then comes from the refusal message, or is None. After a connection drops, the client reconnects on its own, waiting about 1 second and doubling up to 30 seconds between attempts, and it keeps trying after refusals too. A connect_error listener receives the server's {"message", "data"} for every refused attempt; call disconnect() there on a code that will not clear by itself.

Polymarket v2 options

v2.polymarket takes two options next to its sport keys:

client.ws.connect(subscription={
    "v2": {"polymarket": {
        "mlb": "*",
        "eventTypes": ["best_bid_ask", "last_trade_price", "market_resolved"],
        "marketTypes": ["moneyline"],
    }},
})
  • eventTypes: "*" or a list of message types. Without it you receive book, best_bid_ask, last_trade_price and tick_size_change. market_resolved is sent only to subscriptions that name it or pass "*".
  • marketTypes: "*" or up to 50 market types. Without it every market type is sent. Values are not checked when you subscribe: take them from sportsMarketType in get_polymarket_v2 data.
  • Options need at least one sport key beside them. update_subscription(v2=...) replaces the whole v2 object, so send the sport keys again with the options.
  • The subscribed acknowledgement carries v2OptionsAdvisory when an option was dropped, widened, or asks for a type this connection will not receive: a list of {"book", "field", "sport"?, "outcome", "accepted", "rejectedCount", "rejectedSample"?, "message"}.

Each polymarket-v2-update carries sport, league (the first entry of leagues), leagues (every league the market is tagged with), marketId, eventSlug, marketType, assetId, timestamp and raw, the Polymarket message itself (raw["event_type"] is its type).

v2: removals, hashes and 304s

pinnacle-v2-update carries changed matchupIds plus the league hash, and every 30 seconds a heartbeat per league (heartbeat: true) whose matchupIds is the league's full membership.

  • hash equals the etag in the body of the league's get_pinnacle_v2 response (the ETag header without its quotes) whenever that response is not partial. If it equals the etag you hold, you are current and need no refetch. It is an opaque SHA-1 hex string.
  • A delta or a heartbeat may carry removedMatchupIds: matchups the league dropped, which you delete. The list is not exhaustive, and an id can come back later in matchupIds, so heartbeat membership and the REST board stay authoritative.
  • When a league empties, its final heartbeat can carry matchupIds: [] with hash equal to PINNACLE_V2_EMPTY_LEAGUE_HASH. After that the league sends nothing until it has matchups again, and its REST board has no etag.

The SDK's v2 methods do not send If-None-Match. If you make conditional requests yourself, send the ETag response header back exactly as received (quoted; a leading W/ is fine): the body etag has no quotes and never matches. A 304 on /api/v2 does not count toward your monthly quota; it still counts toward your per-minute limit and appears in your usage log. A key that has used up its monthly quota gets 429 even when the request would have been a 304. A 429 for the per-minute limit does not count toward the monthly quota.

Errors

from owls_insight import AuthenticationError, RateLimitError, ServiceBusyError, OwlsInsightError

try:
    client.rest.get_odds("nba")
except AuthenticationError:
    ...  # 401
except RateLimitError as e:
    # e.code is "HISTORY_CONCURRENCY" when too many history requests were in flight
    print("retry after", e.retry_after_ms, "ms", e.code)
except ServiceBusyError as e:
    # 503: too expensive to serve in one request, or a transient outage
    print("busy, retry after", e.retry_after_ms, "ms or narrow the request")
except OwlsInsightError as e:
    print(e.status, e.message)

Retries are opt-in for direct calls: OwlsInsight(api_key, max_retries=3) retries 429 and 503 honouring Retry-After (else exponential backoff). iter_history_odds / iter_history_props retry by default. history_concurrency (default 3, the MVP plan's per-key history allowance; Hall of Fame allows 4) caps the history requests a client keeps in flight; requests beyond it wait client-side instead of being refused.

License

MIT

Release files for owls-insight 0.27.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for owls-insight 0.27.0
File Size Uploaded
owls_insight-0.27.0.tar.gz 130.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for owls-insight 0.27.0
File Interpreter ABI Platform
owls_insight-0.27.0-py3-none-any.whl Python 3 none any Details

Total release size: 237.1 kB

Release files / owls_insight-0.27.0.tar.gz

Download URL owls_insight-0.27.0.tar.gz
Size 130.3 kB
Tags Source
SHA-256 checksum
How to use checksums
34aaa06f28fc45acb7c1af4a0bd763410152f1a749864d2478e0047b379f63dc
BLAKE2b-256 checksum
How to use checksums
8b4a33926c84d161037b74895ecbfde10004843c7419115847c690f34ded7272
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.7

Release files / owls_insight-0.27.0-py3-none-any.whl

Download URL owls_insight-0.27.0-py3-none-any.whl
Size 106.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
045e56cde036bca8aac2e92dc97d93776fa344b5f36d705c697df2349aa7d4f8
BLAKE2b-256 checksum
How to use checksums
ae16367112e1a405cb28f8a812ee3c7cc0563f98d7fcfa799bec80a06f5a7df3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.7

Release history Release notifications | RSS feed

This release

0.27.0 This release

2 release files

0.25.0

2 release files

0.24.0

2 release files

0.22.0

2 release files

0.21.0

2 release files

0.20.0

2 release files

0.19.0

2 release files

0.18.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page