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
ApiResponseenvelope (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 changev2, send the wholev2object.event_idslimitsodds-updateto the events with thoseidvalues (sent aseventIds).exclude_exchanges=Truedrops Kalshi, Polymarket and Novig from the books.connect(subscription=...)takes the same fields as a dict. Snake_case namesesports_realtime,one_x_bet_soccerandevent_idsare sent under their server names; every other key is sent exactly as given (exclude_exchangesis already the server's name).- A connection that never subscribes receives the default: all sports, all books.
subscribedcan 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,disconnectandconnect_errorincluded. A laterconnect()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]}?}
deltais alwaystrue: a frame holds only some events. Merge each event into your board byid, replacing the whole event, then delete the ids inremoved[<sport>].resync: truemarks a frame from the periodic resend, which also carries events that did not change.streamis{"sport", "epoch", "seq", "checkpoint"?}. Within oneepoch,seqgoes up by 1 per frame for that sport.streamandremovedare optional: handle frames with and without them.pinnacle-realtime-syncis 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. Trackstreamand applyremovedexactly as onpinnacle-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:
- Keep your current board. Buffer that sport's frames from both
pinnacle-realtimeandpinnacle-realtime-sync. - Wait up to 25 seconds for a frame with
stream.checkpointset. If the sport's frames carry nostream, load at once. After a reconnect, also wait a random 0 to 10 seconds, so that clients reconnecting together (for example afterSERVER_RESTART) do not all load at the same moment. - Call
resp = client.rest.get_realtime(sport)and readstream = 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=Truekeeps only the fields the response carried, as a frame does. Prices come back as floats (-120.0where a frame has-120).None: replace your board withresp.data, merge every frame with noseqcheck, 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 noremoved.- its
epochdiffers from the checkpoint's, or itsseqis lower than the checkpoint's: load again at the next checkpoint. - otherwise: replace your board with
resp.data, discard buffered frames with aseqat or belowstream["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 byid. 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.seqis 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_RESTARTis 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 receivebook,best_bid_ask,last_trade_priceandtick_size_change.market_resolvedis 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 fromsportsMarketTypeinget_polymarket_v2data.- Options need at least one sport key beside them.
update_subscription(v2=...)replaces the wholev2object, so send the sport keys again with the options. - The
subscribedacknowledgement carriesv2OptionsAdvisorywhen 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.
hashequals theetagin the body of the league'sget_pinnacle_v2response (theETagheader without its quotes) whenever that response is not partial. If it equals theetagyou 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 inmatchupIds, so heartbeat membership and the REST board stay authoritative. - When a league empties, its final heartbeat can carry
matchupIds: []withhashequal toPINNACLE_V2_EMPTY_LEAGUE_HASH. After that the league sends nothing until it has matchups again, and its REST board has noetag.
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)
| File | Size | Uploaded | |
|---|---|---|---|
| owls_insight-0.27.0.tar.gz | 130.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|