On this page

API reference

ZippyOddsAPI

A read-only HTTP API and a WebSocket stream for sports odds. It returns sportsbook, exchange and pick'em prices in one schema, plus +EV, arbitrage, middles and low holds worked out from them.

Base URL https://zippyoddsapi.com

Values in the examples are illustrative. The field names, types and rules are the service's own, but the numbers, teams and books are not live data, and a book appearing in an example doesn't mean it is feeding. The coverage page shows which books are feeding now.

ZippyOddsAPI does not take bets. Accounts and keys are issued by the operator. To ask for a key, write to support@vyrexcode.com. When payment checkout is enabled, a signed-in account can buy 30 days of paid access; payments do not renew automatically.

Three calls to start

# Is the key good, and what is feeding?
curl -s "https://zippyoddsapi.com/v1/status" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

# Main markets for the NBA, in decimal odds
curl -s "https://zippyoddsapi.com/v1/odds?league=nba&format=decimal" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

# Prices at least 2% above fair value
curl -s "https://zippyoddsapi.com/v1/ev?league=nba&min_ev=2" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

Authentication

Send your key in a header, either form. Keys start with ua_live_.

curl -s "https://zippyoddsapi.com/v1/usage" \
  -H "Authorization: Bearer ua_live_..."

curl -s "https://zippyoddsapi.com/v1/usage" \
  -H "X-API-Key: ua_live_..."

A key in the URL is refused with 400 api_key_in_url, whether as api_key, apikey or key, because URLs end up in logs. The secret is shown once when the key is issued and is stored only as a keyed hash, so a lost key has to be rotated.

The API is read-only. Only GET and HEAD are accepted; any other method gets 404 endpoint_not_found. A HEAD request is charged like a GET.

What a key can be limited to

SettingEffectRefused with
Scopes* for everything, or a list of the scope names in the credits table. Scopes apply to REST, not to the stream.403 key_scope
IP allowlistAddresses or CIDR ranges the key can be used from. Empty means any address.403 ip_not_allowed
Monthly credit capA ceiling for this key in each UTC calendar month. It counts credit costs even on an unlimited plan.402 key_credit_cap
ExpiryAfter this time the key stops working.401 key_expired

The order of checks

Method, then key (present, valid, not revoked, not expired, account active, address allowed), then rate limit, then the path, then the scope, then the plan's features and the parameters, then the credit charge. A request refused at any step costs no credits from your balance.

Responses

Every success is { data, meta }. Every failure is { error, meta }.

{
  "data": [],
  "meta": {
    "request_id": "req_7f3a9c21d4e84b60a1c2",
    "count": 0,
    "credits_used": 1,
    "credits_remaining": 149811,
    "generated_at": "2026-11-06T18:00:00.000Z",
    "total": 0,
    "has_more": false
  }
}
Field in metaMeaning
request_idAlso in the X-Request-Id header. Quote it when reporting a problem.
countRows in data, or 1 when data is a single object.
credits_usedWhat this request cost.
credits_remainingBalance after this request, or null on an unlimited plan.
unlimitedPresent and true only on an unlimited plan.
generated_atWhen the answer was built, as an ISO-8601 time.
total, has_more, next_cursorOn paged endpoints.

Some endpoints add their own fields to meta; those are noted under each one. Responses are Cache-Control: private, no-store and vary on the key headers, so a CDN can't replay one caller's answer to another.

Credits

The cost is fixed per request, whatever the size of the answer. Errors are never charged.

EndpointCreditsScope namePlan feature needed
/v1/status0statusNone
/v1/usage0usageNone
/v1/leagues0leaguesNone
/v1/sportsbooks0sportsbooksNone
/v1/markets0marketsNone
/v1/coverage0coverageNone
/v1/teams1teamsNone
/v1/players1playersNone
/v1/events1eventsNone
/v1/events/{id}1eventNone; live if the event is live
/v1/scores1scoresNone; live to see live games
/v1/odds2oddsNone; live to see live events
/v1/events/{id}/odds2event_oddsNone; live if the event is live
/v1/futures2futuresfutures
/v1/prediction-contracts2futuresfutures
/v1/props3propsprops
/v1/best-lines3best_linesNone; live to see live events
/v1/history3historyhistory
/v1/ev3evev
/v1/arbitrage3arbitrageev
/v1/middles3middlesev
/v1/holds3holdsev
/v1/smart-money3smart_moneyev
/v1/hedges3hedgesev
/v1/dfs3dfsdfs
/v1/stream1, then 1 a minuteNot scopedstream

Plans

PlanPer monthCreditsRequests a minuteStream socketsFeatures
Sandbox$050, one time200rest, props, history
Edge$49.99150,0003000rest, props, futures, history, ev, dfs
Sharp$199.99Unlimited1,2000Same as Edge
Syndicate$249.99Unlimited3,00010Edge, plus live, stream, webhooks
EnterpriseNot listedUnlimited6,00050Same as Syndicate
  • Edge's allowance runs for 30 days from the account's first request after the plan was set, not by calendar month, and does not roll over.
  • Sandbox's 50 credits are a single grant and do not refill.
  • Credits the operator adds on top are kept when an allowance resets, but changing an account's plan clears the whole balance.
  • On an unlimited plan credits_remaining is null and your balance doesn't move. A key's monthly cap still counts.
  • The webhooks feature is in the plan definitions, but no webhook endpoint exists yet.

Rate limits and headers

The limit is per account, and all of an account's keys share it. Each request counts, including free endpoints. The window is fixed: it starts with your first request and lasts 60 seconds. Over the limit you get 429 rate_limited with a Retry-After header, and no charge.

HeaderSent onMeaning
X-Request-IdEvery responseThe same id as meta.request_id.
X-RateLimit-LimitAfter the rate check passesYour plan's requests a minute.
X-RateLimit-RemainingAfter the rate check passesRequests left in this window.
X-RateLimit-ResetAfter the rate check passesSeconds until this window ends. It is a count, not a timestamp.
Retry-After429 onlySeconds to wait. A 429 carries this and not the X-RateLimit-* headers.
X-Credits-UsedSuccessWhat this request cost.
X-Credits-RemainingSuccess, on a plan with a balanceBalance after this request. Absent when unlimited.

Errors

{
  "error": {
    "code": "invalid_parameter",
    "message": "limit must be a number between 1 and 200",
    "param": "limit"
  },
  "meta": { "request_id": "req_a81c4e07b39f42d6b0e5" }
}

code is stable, so branch on it. message is for people. param names the parameter at fault and is an empty string when none is.

CodeStatusWhen
invalid_parameter400A parameter is missing or has a value the endpoint can't use.
api_key_in_url400The key was sent in the URL. Move it to a header.
missing_api_key401No key was sent.
invalid_api_key401The key does not exist.
key_revoked401The key has been revoked.
key_expired401The key has passed its expiry.
insufficient_credits402The balance can't cover this request.
key_credit_cap402This key has reached its monthly cap.
plan_feature_unavailable403The plan doesn't include this feature, such as ev on Sandbox, or status=live below Syndicate.
account_suspended403The account is suspended.
key_scope403This key isn't scoped for this endpoint.
ip_not_allowed403The key can't be used from this address.
endpoint_not_found404The path does not exist, or the method isn't GET or HEAD.
event_not_found404No event has that id on the board now. A finished game is no longer on it.
history_not_found404No history for that market. History covers main full-game markets.
rate_limited429Over the plan's limit. Wait for Retry-After seconds.
internal_error500Something went wrong on our side. It has not been charged.
market_not_found404Reserved. Nothing returns this today.
maintenance503Reserved. Nothing returns this today.

A plain HTTP request to /v1/stream gets status 426 with the code invalid_parameter, because that path only takes WebSocket connections.

Paging

A paged answer always says whether there is more, so a short answer is never mistaken for a complete one. A limit above the maximum is refused rather than quietly lowered.

Parameter or fieldMeaning
limitRows to return. A whole number from 1 to the endpoint's maximum.
cursorWhere to start: pass the last answer's next_cursor.
meta.totalRows that match, across all pages.
meta.has_moretrue if rows remain after this page.
meta.next_cursorA string, present only when has_more is true.
BASE="https://zippyoddsapi.com"
cursor=""
while :; do
  page=$(curl -s "$BASE/v1/events?league=nba&limit=100${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $ZIPPYODDS_KEY")
  echo "$page" | jq -c '.data[]'
  cursor=$(echo "$page" | jq -r '.meta.next_cursor // empty')
  [ -z "$cursor" ] && break
done

Pass cursors unchanged, with the same filters. They bind pages to a revision and as_of time; an expired cursor or changed revision returns 409 cursor_expired. Restart from the first page when that happens. Paging stored records does not establish complete upstream venue coverage.

EndpointDefault limitMaximum
/v1/events100500
/v1/odds, game markets only200200
/v1/odds, with player markets, markets=all or include_dfs=true2525
/v1/props2525
/v1/futures, /v1/prediction-contracts100200
/v1/best-lines200200
/v1/ev, /v1/arbitrage, /v1/middles, /v1/holds, /v1/smart-money, /v1/hedges, /v1/dfs100500

Everything else returns its full list and has no paging fields, including /v1/scores, /v1/teams and /v1/players.

Common parameters

These mean the same wherever they appear, except where an endpoint says otherwise: on /v1/best-lines, include_stale only lets stale prices compete. Lists are comma-separated and case doesn't matter, except for the exact native filters on /v1/prediction-contracts. That endpoint also refuses unsupported or repeated parameters.

ParameterValuesNotes
leagueList of league idsAn unknown league is 400 invalid_parameter.
event_idsList of event idsAlias event_id. Where the docs say "league or event_ids is required", the alias doesn't satisfy that check.
statusList of scheduled, live, final, cancelledAsking for live below Syndicate is 403 plan_feature_unavailable. If you don't ask, live events are left out without an error. A game whose status is stale (status_stale: true) is left out too, unless you pass include_stale=true or name it in event_ids (on /v1/best-lines, only naming it does): a live label no source has re-observed for an hour, or a scheduled label more than an hour past a firm start with nothing current on the game (see /v1/events).
include_staletrue or falseDefault false. On /v1/best-lines it lets stale prices compete for the best line, and does nothing else. Everywhere else it lists games whose status is stale: labelled live by an observation over an hour old, or still scheduled more than an hour past a firm start with no sportsbook or exchange read since its start_time within the last hour that has anything priced (any outcome with a price or line, suspended ones included) and no live call within the last hour (see /v1/events). Anything else is 400 invalid_parameter.
date_from, date_toISO-8601 timesInclusive, against the event's start time.
booksList of book ids, names or aliasesAlias bookmakers. A name no book claims matches nothing, without an error.
marketsList of market keys, or main, props, all, player_*The default is main (moneyline, spread, total), except on /v1/props.
periodsList of periodsThe default is full_game. Not checked against the list: an unknown period matches nothing.
formatamerican, decimal, probabilityThe default is american. Decimal has 4 places and probability has 6.
include_suspendedtrueSuspended markets are left out unless this is set.
include_dfstruePick'em apps are left out unless this is set. On /v1/props they are in unless it is false.

Periods

A period is what the source said it was. It is never assumed, so a part-game line always says so. /v1/leagues lists the periods on the board for each league right now, and /v1/markets returns this full list in meta.periods.

KeysMeaning
full_gameThe whole game. The default.
1h, 2hFirst and second half
1q, 2q, 3q, 4qQuarters
1p, 2p, 3pHockey periods
f5First five innings
set1, set2, set3Tennis sets
map1, map2, map3, maps1_2Esports maps, and maps one and two together
r1, r2, r3, r4, r5Rounds

Market keys

This is the catalog from /v1/markets. A key being listed doesn't mean any book is pricing it now: /v1/leagues shows the keys on the board for each league.

KeyNameGroup
moneylineMoneylinemain
spreadSpreadmain
totalTotalmain
three_wayThree-way resultmain
team_totalTeam totalalternates
alternate_spreadAlternate spreadalternates
alternate_totalAlternate totalalternates
player_pass_ydsPassing yardsplayer_props
player_pass_tdsPassing touchdownsplayer_props
player_rush_ydsRushing yardsplayer_props
player_rec_ydsReceiving yardsplayer_props
player_receptionsReceptionsplayer_props
player_pointsPointsplayer_props
player_reboundsReboundsplayer_props
player_assistsAssistsplayer_props
player_threesThrees madeplayer_props
player_praPoints + rebounds + assistsplayer_props
player_strikeoutsPitcher strikeoutsplayer_props
player_hitsHitsplayer_props
player_total_basesTotal basesplayer_props
player_shots_on_goalShots on goalplayer_props

Shortcuts in the markets parameter: main is moneyline, spread and total. props and player_* are every key starting player_. all is every key, including three_way and the alternates, which main doesn't include.

An outcome's side is one of home, away, draw, over, under. Its point is the side's own handicap on a spread and the line on a total or prop.

A team_total market's player is the team it is about, named as that book writes it. When the board can place the team on a side of the game from that book's own home and away names, player.side is home or away, and player.id and every selection id in the market are keyed by that side of the game rather than by the spelling. So "NY Islanders" at one book and "New York Islanders" at another, for the same period and line, have the same selection ids. When the board can't place the team for certain (a name that fits neither side or both, or a listing the board matched the other way round), player.side is left out and the ids stay keyed by the book's spelling. Market ids are per book and didn't change.

Team-total selection ids and player.id changed once, when side keying was released in November 2026. An id saved for a team total before then isn't found afterwards. In the stream, each team total that moved to a side was sent once, as its book was next read, with the same prices and its new selection ids.

Leagues

SportLeague ids
footballnfl, ncaaf, cfl
basketballnba, wnba, ncaab
baseballmlb
hockeynhl
soccersoccer
tennistennis
golfpga
mmaufc
esportslol, cs2, valorant, dota2, cod

A league id being accepted doesn't mean it has events. /v1/leagues gives the current count for each.

Ids

PrefixWhat it names
evt_An event: one real game, joined across sources.
mkt_One book's market. The line is part of the id, so a market whose line moves gets a new id, and /v1/history for a market_id follows that one line.
sel_An outcome. The same side and point in the same market has the same id at every book.
plr_A player, from the league and the player's name.
{league}_{word}A team, from the league and the last word of its name, such as nba_celtics.

Treat ids as opaque strings. None of them contains a comma, so they are safe in list parameters.

Account and catalog

GET /v1/status

  • 0 credits
  • Any plan

Whether the service is up, your plan and balance, the size of the board, and the state of each collector. Takes no parameters.

curl -s "https://zippyoddsapi.com/v1/status" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example data
{
  "status": "ok",
  "time": "2026-11-06T18:00:00.000Z",
  "provider": "ZippyOddsAPI",
  "account": { "plan": "edge", "credits": 149812 },
  "feed": {
    "events": 412,
    "live": 9,
    "markets": 18640,
    "updates_total": 902211,
    "updates_per_minute": 6,
    "uptime_seconds": 86400
  },
  "sources": [
    { "id": "prophetx", "name": "ProphetX", "type": "direct", "status": "operational", "last_ok": "2026-11-06T17:59:40.000Z" }
  ]
}

account.credits is null on an unlimited plan. updates_per_minute counts collector reads in the last minute, and updates_total adds up the prices in reads since the service last restarted. A source's status is operational, waiting (no good read yet), failing, blocked or off.

GET /v1/usage

  • 0 credits
  • Any plan

Your plan, limits, features and recent use. Takes no parameters.

Example data
{
  "plan": "edge",
  "plan_name": "Edge",
  "credits_remaining": 149812,
  "unlimited": false,
  "monthly_credits": 150000,
  "credits_reset_at": "2026-12-06T18:00:00.000Z",
  "rate_limit_per_minute": 300,
  "max_stream_connections": 0,
  "features": ["rest", "props", "futures", "history", "ev", "dfs"],
  "requests_24h": 94,
  "credits_used_24h": 188,
  "requests_30d": 94,
  "credits_used_30d": 188,
  "key": { "id": "key_4c1f0a9e7b2d45c8a1e3", "name": "Default key", "env": "live" }
}

Use is stored by UTC day, so requests_24h covers today and all of yesterday, and the 30-day figures work the same way. credits_reset_at is null on Sandbox and on unlimited plans.

GET /v1/leagues

  • 0 credits
  • Any plan

All 17 leagues, always, with what is on the board for each right now. Takes no parameters.

Example row
{
  "id": "nba",
  "name": "NBA",
  "full_name": "nba",
  "sport": "basketball",
  "region": "global",
  "periods": ["full_game", "1h"],
  "has_player_props": true,
  "has_futures": false,
  "markets": ["moneyline", "spread", "total", "player_points"],
  "events": { "upcoming": 11, "live": 0 }
}

GET /v1/markets

  • 0 credits
  • Any plan

The market catalog. Takes no parameters. meta adds periods and shortcuts.

Example response
{
  "data": [
    { "key": "moneyline", "name": "Moneyline", "group": "main", "description": "Moneyline" }
  ],
  "meta": {
    "request_id": "req_11b2c3d4e5f6a7b8c9d0",
    "count": 21,
    "credits_used": 0,
    "credits_remaining": 149812,
    "generated_at": "2026-11-06T18:00:00.000Z",
    "periods": ["full_game", "1h", "2h"],
    "shortcuts": { "main": ["moneyline", "spread", "total"], "props": "player_*", "all": "*" }
  }
}

GET /v1/sportsbooks

  • 0 credits
  • Any plan

Every book the service expects prices from, with its live state. Retail venues, closed apps and salary-cap contests aren't in this list; /v1/coverage has them.

ParameterValuesNotes
typesportsbook, exchange, dfsexchange includes prediction markets.
curl -s "https://zippyoddsapi.com/v1/sportsbooks?type=exchange" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row
{
  "id": "prophetx",
  "name": "ProphetX",
  "type": "exchange",
  "tags": ["sharp_reference", "us"],
  "status": "operational",
  "leagues": ["nba", "nhl"],
  "ev_role": "reference",
  "ev_weight": 50,
  "scoring_eligible": true,
  "effective_scoring_eligible": true,
  "effective_scoring_state": "eligible",
  "effective_ev_role": "reference",
  "effective_ev_weight": 50,
  "effective_scoring_reason": null,
  "last_seen": "2026-11-06T17:59:40.000Z"
}

status is operational only when the book's coverage state is feeding. Otherwise it is stale or no_data. It says prices are arriving, not that they are scored. ev_role is explained under References and weights.

ev_role, ev_weight, tags and scoring_eligible are the registry's settings: what the book does when an eligible copy of its prices is on the board. What it does now depends on the copies actually there:

FieldMeaning
effective_scoring_stateeligible: every copy on the board can be scored. mixed: eligible and raw-only copies are both there, and the eligible ones are scored. raw_only: every copy is raw only, such as a publisher's snapshot of the book's board, so its prices are shown but never scored or used as a reference. unknown: no copy on the board. effective_scoring_eligible and raw_only say the same as booleans, null when unknown.
effective_ev_role, effective_ev_weightThe role and weight the book has now. The registry's ev_role and ev_weight when eligible or mixed; none and 0 when raw only; null when unknown.
effective_scoring_reasonWhy the book is not scored now, from its sources' own reasons, for example "Every copy on the board is raw only: Publisher Nevada board snapshot (…)". null when it is scored or unknown.
source_provenanceOne entry per source copy on the board: source_id, scoring_eligible, raw_only, scoring_reason, provenance and its counts.

The polymarket_us row also reports raw_prediction_contracts, raw_prediction_bbos and raw_prediction_counts_as_of; see native prediction counts. These separate stored records do not make its canonical game-price status operational.

GET /v1/coverage

  • 0 credits
  • Any plan

The same report as /public/coverage, inside the usual envelope, for every book in the registry. Takes no parameters. The one difference is that reason here leaves out the per-book collection notes that the public report includes.

GET /v1/teams

  • 1 credit
  • Any plan

Teams on the current board. This isn't a complete league directory: a team with nothing on the board isn't listed.

ParameterValuesNotes
leagueLeague idsRequired.
Example row
{ "id": "nba_celtics", "league": "nba", "name": "Boston Celtics", "abbr": "BC" }

abbr is built from the first letters of the name's words, up to three, so it isn't always the league's own abbreviation.

GET /v1/players

  • 1 credit
  • Any plan

Players named in a market on the current board. Team totals aren't listed: their subject is one of the game's teams, which /v1/teams lists.

ParameterValuesNotes
leagueLeague idsRequired.
teamTextMatches the team text a source gave for the player, in full, ignoring case.
Example row
{ "id": "plr_0c9x3m1q7a5tkd", "name": "J. Example", "position": "F", "team_id": null, "team": "Boston Celtics", "league": "nba" }

team_id is always null today. position and team are empty strings when the source didn't give them.

Events and odds

GET /v1/events

  • 1 credit
  • Any plan; live events need Syndicate
  • Paged: default 100, maximum 500

Events, soonest first.

ParameterDefaultNotes
leagueAll—
event_idsAllAlias event_id.
statusscheduled,live—
date_from, date_toNone—
limit, cursor100—
curl -s "https://zippyoddsapi.com/v1/events?league=nba,nhl&limit=50" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row
{
  "id": "evt_0h7k2m9q4x1tzb",
  "league": "nba",
  "sport": "basketball",
  "competition": "",
  "start_time": "2026-11-07T00:30:00.000Z",
  "start_time_tbc": false,
  "start_time_spread_minutes": 0,
  "start_time_outliers": [],
  "status": "scheduled",
  "status_observed_at": null,
  "status_stale": false,
  "home": { "id": "nba_celtics", "name": "Boston Celtics", "abbr": "BC" },
  "away": { "id": "nba_knicks", "name": "New York Knicks", "abbr": "NYK" },
  "score": null,
  "clock": null
}

score is { "home": 0, "away": 0 } once a source reports one. clock is always null today. A game leaves the board when a venue reports it finished or cancelled, or withdraws it, so asking for status=final returns nothing.

Each source gives its own start time. start_time is the time most of them give, or the earliest of equally common times, so it doesn't depend on which venue was read first. In football, basketball, baseball, hockey and soccer, which have one fixed kickoff, sources that copy one feed (the Kambi sportsbooks, the Polymarket readers, a book's own brands) count once, and a feed whose copies disagree evenly doesn't count. When three or more feeds give a time and most of them are within 5 minutes of the middle one (15 minutes in basketball and hockey, where some sources give tip-off or puck drop), only those count, so one source an hour out can't move start_time; when no time has most feeds behind it, the earliest start of the largest group of feeds within 3 hours of one another is served, as no quote read after it is taken for a pre-match one, and a stale or placeholder listing hours before that group never is. With three or more feeds, each source further than that from start_time is listed in start_time_outliers with its own time and minutes_from_start, and that source's books carry quality.start_time_offset_minutes. start_time_spread_minutes is how far apart the sources' times are, and /v1/events/{id} lists each one in start_times. Sources whose times are more than 3 hours apart are joined into one game only in football and soccer, where a pairing plays once a slate: the same two teams the same way round within 36 hours are one game, whichever source has the time wrong (never the reverse fixture, nor a women's, youth or reserve side, whether the name or the competition says so, nor a pairing one source lists twice). In soccer that also needs both listings to name their competition and the very same team names, so a B side (Castilla, Atlètic, II) never joins its first team's game, and eSoccer and virtual games, where a pairing replays all day, are never joined this way. A North American game listed at exactly midnight Eastern (a game in Hawaii excepted) or at 11:59 PM Eastern is a venue's placeholder, not a kickoff, and in soccer a time of exactly midnight UTC is a date without a time only when more feeds give one and the same time of day (within 15 minutes) 3 to 24 hours later than give midnight or a time within 3 hours of it. Midnight UTC is a real evening kickoff in the Americas, so otherwise it stands, and a source minutes or hours later is the outlier. When no source gives a real time, start_time_tbc is true, and nothing treats the placeholder as the start: prices aren't marked as read before kickoff because of it.

Many venues simply stop listing a game when it ends, without saying it finished. Its prices stay on the board at their true age, and its live label is kept too, with its own clock. status_observed_at is when a source last called the game live (null unless status is live). The hour is the time since a source last listed the game as live, not the game's length. Once it passes, status_stale is true. A source that cycles slowly through many live games, as for tennis and golf, can leave a game that is still being played marked stale until it comes round again. Lists then leave the game out unless you pass include_stale=true or ask for it by id. /v1/events/{id} always returns it.

A game still scheduled is stale too once all of these hold: the latest firm start any source gives is more than an hour ago; no sportsbook or exchange has read the game since its start within the last hour (a tipster or publisher page doesn't count, since it can show a fresh time on a finished game); and no source has called it live within the last hour. Lists leave it out in the same way, and /v1/events/{id} returns it. Tennis and golf are exempt, since their times are an order of play or a first tee time, and so is a start still to be confirmed (start_time_tbc) while no source calls the game live, up to a day past it. If the live label goes again within that day, the exemption comes back. The last live call is kept in memory, so for up to an hour after a restart a game whose live source dropped it can leave lists sooner. Nothing is removed. One narrow case is hidden too soon: a game in play that no source calls live, whose books in the last hour all came back with nothing priced at once, leaves lists until the next read with a price.

GET /v1/events/{id}

  • 1 credit
  • Any plan; a live event needs Syndicate

One event. It has the fields above, plus start_times (each source's own start time), sources (the collectors that read it) and markets (the market keys any book has for it). An id not on the board is 404 event_not_found.

Additional fields
{
  "sources": ["prophetx"],
  "markets": ["moneyline", "spread", "total"]
}

GET /v1/scores

  • 1 credit
  • Any plan; live games need Syndicate
  • Not paged

Events that have a score, in the same row shape as /v1/events. It takes league, event_ids, status (default live,final), date_from and date_to.

Live games only, today A game leaves the board as soon as a source reports it final, so this has no finished games to return. In practice it lists games in progress, and below Syndicate it returns an empty list.

GET /v1/odds

  • 2 credits
  • Any plan; live events need Syndicate
  • Paged: 200, or 25 with player markets

Events with each book's prices. league or event_ids is required.

ParameterDefaultNotes
league—Required unless event_ids is sent.
event_ids—Required unless league is sent.
booksAllAlias bookmakers.
marketsmainAsking for player markets, all, or include_dfs=true lowers the page size to 25.
periodsfull_game—
statusscheduled,live—
date_from, date_toNone—
formatamerican—
include_suspendedOff—
include_dfsOff—
limit, cursor200 or 25—
curl -s "https://zippyoddsapi.com/v1/odds?league=nba&markets=spread,total&format=decimal" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row, with the extras in meta
{
  "data": [
    {
      "id": "evt_0h7k2m9q4x1tzb",
      "league": "nba",
      "sport": "basketball",
      "competition": "",
      "start_time": "2026-11-07T00:30:00.000Z",
      "start_time_tbc": false,
      "start_time_spread_minutes": 0,
      "start_time_outliers": [],
      "status": "scheduled",
      "status_observed_at": null,
      "status_stale": false,
      "home": { "id": "nba_celtics", "name": "Boston Celtics", "abbr": "BC" },
      "away": { "id": "nba_knicks", "name": "New York Knicks", "abbr": "NYK" },
      "score": null,
      "clock": null,
      "bookmakers": [
        {
          "id": "prophetx",
          "name": "ProphetX",
          "type": "exchange",
          "updated_at": "2026-11-06T17:59:40.000Z",
          "time_basis": "read",
          "markets": [
            {
              "id": "mkt_0k2v9qa1x7m3pc",
              "key": "total",
              "period": "full_game",
              "line": 224.5,
              "outcomes": [
                { "id": "sel_1f8s2ld0q9w4ze", "name": "Over", "side": "over", "point": 224.5, "price": 1.9259, "liquidity": 840 },
                { "id": "sel_0p3n7ty5h2c6vb", "name": "Under", "side": "under", "point": 224.5, "price": 1.8929, "liquidity": 1120 }
              ],
              "suspended": false
            }
          ]
        }
      ]
    }
  ],
  "meta": {
    "request_id": "req_2c7e1d0a9b8f4c3e6a51",
    "count": 1,
    "credits_used": 2,
    "credits_remaining": 149810,
    "generated_at": "2026-11-06T18:00:00.000Z",
    "total": 1,
    "has_more": false,
    "markets": ["spread", "total"],
    "periods": ["full_game"],
    "format": "decimal"
  }
}

Fields that are only sent when they apply

FieldSent when
market.lineThe market has a line.
market.playerA player market: { id, name, team, position }.
market.variantPick'em apps only: standard, goblin, demon, or alternate when the app's own data marks the line as something other than its standard line, with no finer type we can verify (DraftKings Pick6 ladder and one-sided offers, Sleeper line types other than normal). Each variant is its own market, even at the same line.
market.scoring_eligible, market.outcome_membership_verified, market.required_outcome_sidesSeparately collected outcomes have an explicit contract and membership check. All required sides must have actual prices before financial scoring is available. A three-way market with two received prices remains raw data; it does not become a two-way market.
outcome.observed_at, outcome.received_at, outcome.qualityWhen a selection has its own copy clock. The ISO observation time includes required native identity and cache proofs; receipt is when that response arrived. Neither claims a venue price-change time. Market observation time is the oldest retained selection's observation.
outcome.copy_observed_atWhen the source separately recorded the quote copy's as-of bound. It orders older and newer copies without advancing the effective observed_at used for financial freshness. It is not a venue price-change time.
quality.start_time_offset_minutesOn a book whose source puts the game more than 5 minutes from start_time, 15 in basketball and hockey (see start_time_outliers): how many minutes from it, negative when earlier. Its prices may have been made for another start. Book level only, and absent when the source agrees.
quality.observed_before_startThe game's start time has passed. true means the price was read before kickoff: it is a pre-match price, not a current one, so quality.stale is true however recent it is. Once the start has passed, every price on the game is judged by the 90-second live age limit, whatever its status says. The flag is on every quality object (book, market, outcome, and rows from /v1/ev, /v1/dfs and /v1/best-lines).
outcome.pointThe outcome has a handicap or line.
outcome.liquidityThe venue publishes dollars available at this price.
outcome.multiplierA pick'em app publishes a per-pick payout.

outcome.price is always sent and is null on a pick'em line with no price. A copy is clocked at the earliest of our receipt, the response's HTTP Date, receipt minus its Age, and any read time the venue states for its own lines. Every collector clocks its copies that way, and a copy a collector sends again from its own cache keeps the clock of the response it came in, so it never looks fresher than it is. Kambi's books, Hard Rock, Betway, LetsBetMD, BetUS, PointsBet CA, Betinia, Monopoly, Dabble and Sleeper, whose venues' headers we haven't recorded, never refuse a copy over its headers: a Date ahead of our clock is clamped to receipt, and a missing or malformed Date, or an Age that isn't whole seconds, is left out with a warning and the copy is clocked at receipt. Fanatics Markets and Covers ignore an Age they can't read and clamp a Date up to 120 s ahead to receipt, refusing only a malformed Date or one further ahead. The other collectors that read Date and Age refuse a copy whose Date or Age can't be read, and send none of its prices. bookmaker.observed_at (and updated_at) is the oldest clock among the markets and outcomes this response returns for that book, and bookmaker.quality is judged on it, so a bookmaker is never fresher than what it wraps: asked for props, it is as old as the props, even when its main lines were read seconds ago. bookmaker.book_observed_at is the book's newest copy among everything it holds, not only what this response returns: the book's latest whole read, or, for a book whose prices carry their own copy clocks (exchanges, for one), the newest of those. received_at and time_basis describe that same copy: snapshot when its clock is earlier than our receipt, read when it is as of our receipt. In stream snapshots sent in fragments, every fragment's bookmaker carries the clocks of the whole snapshot. bookmaker.venue_updated_at, where present, is when the venue says it last changed this book's prices on the game: older than observed_at means unchanged since, and still the venue's price as of observed_at, so it never makes a price stale by itself. It is the venue's own stamp, and where we've found that a venue's stamp doesn't move when its prices do, it isn't sent. Bookmaker type is sportsbook, exchange (including prediction markets) or dfs. An event can come back with an empty bookmakers list when no book has anything matching your filters.

A partial outcome update preserves unvisited selections at their own ages. Only explicit native withdrawals or a completed native membership check remove older selections; an older check cannot remove a newer price. An incomplete required outcome set makes that event's bookmaker financially ineligible while its raw prices remain available. Honor scoring_eligible: false and effective_scoring_eligible: false, including on any quote_observations used in a calculation.

GET /v1/events/{id}/odds

  • 2 credits
  • Any plan; a live event needs Syndicate

One event in the same shape as a /v1/odds row, with data as a single object. It takes books, markets, periods, format, include_suspended and include_dfs.

curl -s "https://zippyoddsapi.com/v1/events/evt_0h7k2m9q4x1tzb/odds?markets=all" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

GET /v1/props

  • 3 credits
  • Any plan
  • Paged: default and maximum 25

Player markets, from sportsbooks and pick'em apps together. Only events with at least one matching market are returned.

ParameterDefaultNotes
league—Required.
playerAllPart of a name, ignoring case.
marketsplayer_*—
booksAll—
include_dfsOnSet to false to leave pick'em apps out.
event_idsAllAlias event_id.
statusscheduled—
date_from, date_to, periods, format, include_suspended—As usual.
limit, cursor25—
curl -s "https://zippyoddsapi.com/v1/props?league=nba&markets=player_points&player=example" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example bookmaker in a row: a pick'em app
{
  "id": "dabble",
  "name": "Dabble",
  "type": "dfs",
  "updated_at": "2026-11-06T17:58:10.000Z",
  "time_basis": "read",
  "markets": [
    {
      "id": "mkt_1r8d0s4n6y2gha",
      "key": "player_points",
      "period": "full_game",
      "line": 27.5,
      "player": { "id": "plr_0c9x3m1q7a5tkd", "name": "J. Example", "team": "Boston Celtics", "position": "F" },
      "variant": "standard",
      "outcomes": [
        { "id": "sel_2m5w9e3r7t1yuj", "name": "Over", "side": "over", "point": 27.5, "price": null },
        { "id": "sel_1q6k4z8x2c0vbn", "name": "Under", "side": "under", "point": 27.5, "price": null }
      ],
      "suspended": false
    }
  ]
}

GET /v1/futures

  • 2 credits
  • Edge and above
  • Paged: default 100, maximum 200

Stored native sportsbook futures, when available, with native matchup and participant ids, selection prices and separate observation and receipt times. Rows have scoring_eligible: false. Optional filters are source, league and format; league is not required. Use limit and cursor for JSON pages, or export=ndjson for a complete stored-record walk.

Prediction contracts and BBO observations are a separate channel: use /v1/prediction-contracts.

GET /v1/prediction-contracts

  • 2 credits
  • Edge and above
  • Paged: default 100, maximum 200

Native Polymarket US contract catalog rows and best-bid/offer (BBO) observations, stored independently. Authenticate with Authorization: Bearer or X-API-Key; the key needs the futures scope (or *) and the plan needs the futures feature. Usage is recorded as prediction_contracts. JSON pages and NDJSON exports each cost 2 credits per request under the usual rate and credit limits.

This raw channel does not create canonical game prices, numeric sportsbook futures, best lines or financial EV. Every row has raw_only: true, scoring_eligible: false, execution_eligible: false, and payout: null, ev: null.

ParameterValuesNotes
sourcepolymarket_usOptional; the only supported source.
bookspolymarket_usOptional; the only supported book.
kindcontract, bboComma-separated; omit for both.
native_idsExact native market id stringsComma-separated. Matches contracts; BBO records have no market id.
slugsExact native market slugsComma-separated; matches contracts and BBO records.
limit, cursor100 by default; maximum 200JSON pages. Copy meta.next_cursor unchanged with the same filters.
exportndjsonComplete stored-record export described below.

Only these parameters are accepted. Unknown or repeated keys, including league and format, return 400 invalid_parameter. Lists accept at most 256 nonempty exact values, without surrounding whitespace; ids and slugs are case-sensitive. limit must be a positive integer from 1 to 200.

curl -s "https://zippyoddsapi.com/v1/prediction-contracts?source=polymarket_us&kind=contract,bbo&limit=100" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

JSON uses the usual { data, meta } envelope, including total, has_more, next_cursor when needed, as_of and revision. A changed revision or expired cursor requires restarting from the first page.

Row fieldMeaning
record_type, source_id, bookcontract or bbo; source and book are polymarket_us.
native_key, native_market_id, market_slug, native_uuid, titleNative identities and descriptive fields. BBO records use their native slug; their market id, UUID and title are null. These are not canonical game ids.
sidesContracts preserve native side ids, market ids, description, long, optional identifier and native object. BBO records have null. Do not infer a home/away or executable selection mapping.
price_factsNative values with role, field and fact_kind. Best bid/ask, last trade, sample, current and settlement facts stay distinct; values and currency are preserved when provided. A last trade or sample is not a current offer.
native_quote_updated_at, quote_clock_basisCurrently null and not_provided. No quote-update timestamp is inferred from other clocks.
received_at, ingested_at, local_capture_sequenceHTTP receipt as ISO time or null; local storage time as ISO; and a local ordering token. None is a native quote-update time.
clock_facts, http_copyNative clock fields retain field, native and parsed unix_ms (or null). HTTP copy metadata includes request URL, status, response Date/Age/Last-Modified/cache fields, and body size/hash when recorded. Its received_at is numeric Unix milliseconds or null, unlike the top-level ISO field.
provenance, nativeOperator source and request URL, plus the original native contract/BBO object. Native ids, decimal strings and nulls remain native facts.

Catalog and BBO copies have independent clocks and are partial upserts. Omission from a later copy is not a withdrawal. Native creation/update/sample times and HTTP cache headers retain their own meanings; an unavailable historical receipt stays null. Response as_of and ingested_at do not make an old observation fresh.

Complete stored-record export

curl -sN "https://zippyoddsapi.com/v1/prediction-contracts?source=polymarket_us&export=ndjson" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"

Alternatively send Accept: application/x-ndjson with the same endpoint and filters. The export validates limit but applies no page limit or overall row cap. Omit cursor (or use 0); JSON cursors cannot resume an export.

Read newline-delimited start, data and end frames. Data frames contain the same row DTO. Only an end frame with complete: true confirms a full traversal of matching stored records, including an empty result. An interruption or error frame is incomplete. The walk declares consistency: live_walk_non_atomic, reports start/end revisions, and ends with upstream_scope: returned_rows_only: a complete export does not prove a complete upstream market catalog or executable prices.

GET /v1/best-lines

  • 3 credits
  • Any plan; live events need Syndicate
  • Paged: default and maximum 200

For each event and market, the best fresh price on each side and who has it: a price past its age limit (see Gates) never sets a best line, whatever it pays. So in a game that is listed, a side or a whole market with no fresh price is left out: a two-way market can come back with one side, and books_quoting counts only books with a fresh price. include_stale=true lets stale prices compete too, and each of observations carries stale. Here it does nothing else: a game whose status is stale is listed only when you name it in event_ids. Pick'em apps are always left out. league or event_ids is required. It also takes books, markets (default main), periods, status, date_from, date_to, format, include_suspended, limit and cursor.

curl -s "https://zippyoddsapi.com/v1/best-lines?league=nhl&markets=moneyline" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example of the markets field, added to the event's fields
{
  "markets": [
    {
      "key": "moneyline",
      "period": "full_game",
      "books_quoting": 3,
      "outcomes": [
        { "id": "sel_3a1s5d9f2g6hjk", "name": "Home", "side": "home", "best_price": -118, "best_books": ["bovada"] },
        { "id": "sel_0z4x8c2v6b1nml", "name": "Away", "side": "away", "best_price": 112, "best_books": ["prophetx", "smarkets"] }
      ]
    }
  ]
}

Markets group by key, period, subject and line. A player market or team total also carries its player, so two players, or a game's two team totals, at the same line are separate groups, and one team's total from different books is one group.

GET /v1/history

  • 3 credits
  • Any plan

A market's prices over time. A point is stored each time the market changes, and points are kept for 30 days. Only full-game moneyline, spread and total markets have history, so anything else is 404 history_not_found.

ParameterDefaultNotes
market_id—Either this, or event_id and book.
event_id——
book—A book id, name or alias.
marketmoneylineWith event_id and book.
periodfull_gameWith event_id and book.
formatamerican—
curl -s "https://zippyoddsapi.com/v1/history?event_id=evt_0h7k2m9q4x1tzb&book=prophetx&market=total" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example data
{
  "market_id": "mkt_0k2v9qa1x7m3pc",
  "open": {
    "t": "2026-11-06T12:00:00.000Z",
    "line": 224.5,
    "prices": [
      { "side": "over", "point": 224.5, "price": -110 },
      { "side": "under", "point": 224.5, "price": -110 }
    ]
  },
  "current": {
    "t": "2026-11-06T17:59:40.000Z",
    "line": 224.5,
    "prices": [
      { "side": "over", "point": 224.5, "price": -108 },
      { "side": "under", "point": 224.5, "price": -112 }
    ]
  },
  "points": []
}

points holds every stored point in time order, each in the same shape as open and current; it is shortened in this example. The line is part of a market's id, so this follows one line. With event_id and book, you get the market that changed most recently. If a total moved from 224.5 to 225.5, the older line is a different market_id.

Computed

These come from the current board after the gates. The method is under How EV is worked out.

Two conventions to watch

/v1/ev rows use snake_case names and percent figures. The rows from /v1/arbitrage, /v1/middles, /v1/holds, /v1/smart-money and /v1/dfs use camelCase names and fractions, so a margin of 0.0123 is 1.23%. Every row also names its game with event_name, home, away and player_name, and rows other than /v1/ev carry start_time and status. All of these except /v1/dfs take a league filter the same way, and /v1/dfs has its own.

In those five, sport holds the league id, such as nba. They always use American odds, so format doesn't apply, and their numbers aren't rounded: expect more decimal places than these examples show.

GET /v1/ev

  • 3 credits
  • Edge and above; live rows need Syndicate
  • Paged: default 100, maximum 500

Prices above fair value, best first. Only rows that pass the plausibility checks are returned, unless you ask for scored=true.

ParameterDefaultNotes
leagueAll—
booksAllAlias bookmakers. Limits the books being scored, not the references.
min_ev0In percent: 2 means at least 2%. A row must be above it.
scoredOfftrue returns every scored row, in no set order, including negative and implausible ones. min_ev is then ignored.
limit, cursor100—
curl -s "https://zippyoddsapi.com/v1/ev?league=nba&min_ev=2&limit=50" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row
{
  "book": "draftkings",
  "book_name": "DraftKings",
  "event_id": "evt_0h7k2m9q4x1tzb",
  "league": "nba",
  "market": "total",
  "period": "full_game",
  "line": 224.5,
  "side": "over",
  "player_id": null,
  "price": -105,
  "decimal": 1.9524,
  "fair_probability": 0.5312,
  "fair_price": -113,
  "fair_decimal": 1.8825,
  "ev_percent": 3.71,
  "ev_worst_case_percent": 3.12,
  "edge_points": 1.9,
  "kelly_fraction": 0.039,
  "references": ["pinnacle", "prophetx"],
  "reference_families": 2,
  "devig_method": "shin",
  "plausible": true,
  "stale": false,
  "stale_reason": null,
  "conditional_on_no_push": false,
  "liquidity": null,
  "observed_at": "2026-11-06T17:59:40.000Z",
  "reference_observed_at": "2026-11-06T17:58:05.000Z",
  "reference_age_seconds": 135,
  "start_time": "2026-11-07T00:30:00.000Z",
  "live": false
}
FieldMeaning
price, decimalThe book's price, in American and decimal odds.
fair_probabilityThe consensus chance of this side, margin removed.
ev_percent(fair probability × decimal_after_fees − 1), in percent.
decimal_after_fees, fee_verifiedWhat a winning bet pays per unit staked once an exchange's published fee is taken; at a sportsbook, the same as decimal. EV, edge and Kelly use it. fee_verified is false at an exchange whose fee schedule isn't on file or isn't modelled for this league: there the EV is before any fee, and the row never counts as +EV. See Exchange fees.
ev_worst_case_percentThe EV if each reference's margin were removed in whichever way is least favourable to this side: multiplicative, additive, power or probit. It is never above ev_percent, and a positive value is an edge that holds under every method. It is null unless the row is on the +EV board: plausible, with ev_percent above zero. It doesn't change which rows are listed.
edge_pointsFair probability minus the price's implied probability, in percentage points.
kelly_fractionThe full Kelly fraction of bankroll, from 0 to 1. No multiplier is applied.
references, reference_familiesThe books that set the fair price, and how many separate families they make up.
devig_methodHow the references' margins came off. shin is normal. multiplicative means every reference sat at or under 100%, as an exchange can, so Shin had no margin to remove. mixed means the references differed.
plausibleWhether it passed the plausibility checks. Always true unless scored=true.
stale, stale_reasonstale is true when the price is past its age limit, or no reference within its limit could price it (see Gates). A stale row is never plausible, and appears only with scored=true, at its real age. stale_reason says why: price_older_than_limit, price_read_before_start, reference_older_than_limit, reference_read_before_start or start_time_to_be_confirmed (the game's start time is a placeholder). It is null when the row isn't stale.
observed_at, reference_observed_at, reference_age_secondsWhen this price was observed, when the oldest reference behind its fair price was observed, and how old that reference was at meta.as_of. A reference may be up to 30 minutes old before the game (see Gates), so its observation can show quality.stale: true on a row that still counts.
conditional_on_no_pushA whole-number line on a spread, total or player market, where a push is possible. The EV assumes no push.
player_idThe player's plr_ id on a player market, or on a team total the id of the team's side of the game (see Market keys), and then player_name is the event's name for that team. Otherwise null.

GET /v1/arbitrage

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

Two-way arbitrage across different price families. It takes limit, cursor and include_stale (see Leg ages). There is no league filter, and rows can include games in progress. Rows flagged for review sort last, and within that the largest margin comes first.

curl -s "https://zippyoddsapi.com/v1/arbitrage?limit=25" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row
{
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "market": "spread",
  "period": "full_game",
  "player": null,
  "margin": 0.0123,
  "legs": [
    { "book": "bovada", "side": "home", "line": -3.5, "american": 105, "stakeFraction": 0.4938 },
    { "book": "prophetx", "side": "away", "line": 3.5, "american": 100, "stakeFraction": 0.5062 }
  ],
  "pushPossible": false,
  "legGapSeconds": 12,
  "stale": false,
  "review": false
}
FieldMeaning
marginReturn on the total stake if both legs fill at these prices, as a fraction, after each exchange leg's published fee. Rows above 0.15 are dropped.
decimalAfterFees, feeVerified, feeUnverifiedPer leg, what a winning bet pays per unit staked after the exchange's published fee (the price itself at a sportsbook), and whether that fee is on file (see Exchange fees). A leg at an exchange without a verified fee holds the row back. feeUnverified marks such rows, which appear only with include_stale=true. Middles carry the same fields, and their cost is after fees too.
stakeFractionThis leg's share of the total stake.
pushPossibleThe line is a whole number, so the result can land on it.
legGapSecondsSeconds between the times the two legs were read.
staleThe legs were read more than 30 s apart, or 5 s on a live game. They may not have existed at once.
reviewA main-market margin above 5%: too good to trust without checking.
legsStale; per leg observedAt, ageSeconds, staleReasonEach leg's observation time (epoch milliseconds), its age at meta.as_of, and why it is too old, if it is. legsStale is true when any leg is, and such rows appear only with include_stale=true. Middles, holds (best) and hedges (promo, hedges) carry the same fields; smart money carries exchangeAge and sportsbookAge. See Leg ages.

GET /v1/middles

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

Two legs from different price families that both win if the result lands in a window. It takes limit, cursor and include_stale (see Leg ages), and rows can include games in progress. Cheapest first, then widest.

Example row
{
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "market": "total",
  "period": "full_game",
  "player": null,
  "low": 224.5,
  "high": 226.5,
  "width": 2,
  "cost": 0.0455,
  "legs": [
    { "book": "bovada", "side": "over", "line": 224.5, "american": -110 },
    { "book": "prophetx", "side": "under", "line": 226.5, "american": -110 }
  ]
}

cost is the share of the stake lost when the middle misses, as a fraction. A negative cost means the pair also holds an arbitrage.

GET /v1/holds

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

The lowest combined margin on each two-way market, taking each side's best price, lowest first. It takes books (only those books are considered), limit, cursor and include_stale (see Leg ages). The bookmakers alias doesn't apply here. Rows can include games in progress.

curl -s "https://zippyoddsapi.com/v1/holds?books=bovada,prophetx" \
  -H "Authorization: Bearer $ZIPPYODDS_KEY"
Example row
{
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "market": "moneyline",
  "period": "full_game",
  "line": null,
  "player": null,
  "hold": 0.0148,
  "best": [
    { "book": "bovada", "side": "home", "american": -102, "fairProbability": 0.4976 },
    { "book": "prophetx", "side": "away", "american": -104, "fairProbability": 0.5024 }
  ]
}

hold is a fraction, and a negative one means the best prices form an arbitrage.

GET /v1/smart-money

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

An exchange price with known depth, beside the best sportsbook price on the other side, deepest first. This reports depth at a price, not trades or money flow. Games in progress appear only on plans that include live. Both prices must be current: see Leg ages and include_stale.

ParameterDefaultNotes
min_liquidity0Dollars at the price. A value that isn't a number counts as 0.
limit, cursor100—
Example row
{
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "market": "moneyline",
  "period": "full_game",
  "line": null,
  "exchange": "prophetx",
  "side": "home",
  "american": -108,
  "liquidity": 1250,
  "sportsbook": "bovada",
  "sportsbookAmerican": 105,
  "improvement": 0.021
}

improvement is how much better the sportsbook's price is than the best exchange price on the side the sportsbook is quoting, as a fraction, or null when no exchange quotes that side.

GET /v1/hedges

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

Where to hedge a promo or bonus bet. For each sportsbook price on a two-way market, up to three prices on the other side at different price families, best first. The promo arithmetic is yours to do with your own stake and boost, because live promo offers aren't part of this feed. The promo price and every hedge must be current: see Leg ages and include_stale.

Example row
{
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "market": "total",
  "period": "full_game",
  "line": 224.5,
  "player": null,
  "promo": { "book": "bovada", "side": "over", "line": 224.5, "american": -110, "decimal": 1.9091 },
  "hedges": [
    { "book": "pinnacle", "side": "under", "line": 224.5, "american": -104, "decimal": 1.9615, "impliedSum": 1.0336 }
  ],
  "event_name": "Boston Celtics @ Charlotte Hornets",
  "home": "Charlotte Hornets",
  "away": "Boston Celtics",
  "start_time": "2026-11-07T00:30:00.000Z",
  "status": "scheduled",
  "player_name": null
}

GET /v1/dfs

  • 3 credits
  • Edge and above
  • Paged: default 100, maximum 500

Each full-game over and under on a pick'em app, against a fair chance from the sharp books. Method: Pick'em pricing. Rows can include games in progress.

ParameterDefaultNotes
leagueAll—
booksAllAlias bookmakers.
pricedOfftrue returns only rows that have a fair chance.
limit, cursor100—
Example row
{
  "book": "prizepicks",
  "bookName": "PrizePicks",
  "eventId": "evt_0h7k2m9q4x1tzb",
  "sport": "nba",
  "player": "J. Example",
  "team": "Boston Celtics",
  "market": "player_points",
  "line": 27.5,
  "variant": "standard",
  "side": "over",
  "multiplier": null,
  "fairProbability": 0.5612,
  "edge": 0.0146,
  "threshold": 0.5466,
  "basis": "power_table",
  "references": ["pinnacle", "fanduel"],
  "reason": null,
  "observedAt": 1793987890000
}
FieldMeaning
variantThe app's line type, as market.variant on /v1/odds. An alternate line is priced on its own multiplier where the app publishes one; the fair chance comes from the same sharp books as any line at that value.
fairProbabilityThe fair chance of this side, or null when it couldn't be priced. See reason.
thresholdThe chance this pick must hit to break even on this app, or null if unknown.
edgeFair chance minus threshold, as a fraction. null if either is missing.
basisWhere the threshold came from: power_table, per_pick_multiplier or none.
reasonWhy there is no fair chance: no_sharp_books, low_confidence or books_disagree. Otherwise null.
plausiblefalse when either side of this line on this app has an edge too large to believe (the +EV board's caps: 25%, or 10% on one family's price). Both sides are held together, and so are lines of a different variant at the same value, so a held row can show a negative edge and reason: null. Held rows sort after believable ones. A stale line is never plausible.
stale, staleReasonThe +EV board's age rule (see Gates), with the app's own line as the priced quote and the sharp lines as references. The app's line has 15 minutes before the game. A sharp line has 30 minutes, and old ones give way to fresh ones. Once the game starts, anything read before kickoff is stale and anything after has 90 seconds. staleReason is price_older_than_limit, price_read_before_start, reference_older_than_limit, reference_read_before_start, start_time_to_be_confirmed or null. A stale line keeps its fair chance at its real age but never counts.
observedAtEpoch milliseconds, not an ISO string.

No key needed

GET /public/coverage

  • Free
  • No key
  • Cached for 30 seconds

Per book: is it really feeding, right now? Only prices actually seen count, and nothing is implied by a book being in the registry. This is what the coverage page shows. It is the report itself, without the { data, meta } envelope.

For polymarket_us, raw_prediction_contracts and raw_prediction_bbos count currently stored SQL records in the separate native prediction channel. raw_prediction_counts_as_of is when those counts were reported, not when quotes changed or became fresh. The fields also appear in /v1/coverage and /v1/sportsbooks; other books report 0 counts and a null count time.

NFL and NBA game winners from PMUS also reach the board as gross top-of-book asks under their own market key, polymarket_us_game_winner. Ask for it with markets=polymarket_us_game_winner; the default main markets leave it out. Each ask is converted to American odds rounded against the bettor, so it is never better than the venue's, and an ask beyond ±10000 is not shown. The asks count toward the book's events, prices and feeding state, but the book stays raw only: they exclude the venue's fee, an NFL tie settles at $0.50, and a game moved more than two days settles at the last fair price, so they never enter EV, arbitrage, middles, holds or hedges. Refresh rate: the game asks come from the venue's own moneyline listing, one 20-contract page a minute, priced from each listed contract's own best bid and ask, so a game's ask is refreshed once per pass over that listing (a few minutes when it holds a few pages). The full sports catalog, more than 14,000 contracts, is read separately for the native records at 20 requests a minute, which with the listing page keeps PMUS at its 21 requests a minute. A game's last ask before kickoff stays at its true age until the game is withdrawn. When the venue shows a priced game halted or suspended, the market is marked suspended with no ask (see it with include_suspended=true); when only one side of the book is left, that side keeps its ask and the other is shown unavailable. A game is withdrawn when a complete pass over the moneyline listing, from its first page to its explicit empty page, no longer lists it and it started at least 6 hours before that pass. A pass that was interrupted, or had a page the venue sent without proof, withdraws nothing. A refusal or Retry-After on either PMUS read holds both. Every other contract stays a native record, and raw native counts on their own establish neither financial eligibility nor quote freshness.

curl -s "https://zippyoddsapi.com/public/coverage"
Example, one book shown
{
  "generated_at": "2026-11-06T18:00:00.000Z",
  "totals": {
    "books": 100,
    "expected": 80,
    "feeding": 3,
    "stale": 1,
    "no_data": 76,
    "not_expected": 20,
    "with_ev_rows": 2
  },
  "unresolved_books": {},
  "books": [
    {
      "id": "prophetx",
      "name": "ProphetX",
      "kind": "exchange",
      "status": "active",
      "state": "feeding",
      "ev_role": "reference",
      "ev_weight": 50,
      "family": null,
      "source": "prophetx",
      "last_seen": "2026-11-06T17:59:40.000Z",
      "events": 58,
      "markets": 312,
      "prices": 624,
      "leagues": ["nba", "nhl"],
      "ev_rows": 118,
      "positive_ev_rows": 6,
      "reason": null,
      "note": null
    }
  ]
}
FieldMeaning
stateCanonical game-price coverage: feeding means on the current board and seen within 15 minutes, or within three of its source's read intervals if that is longer. stale: seen before, but not that. no_data: no canonical price coverage recorded; separate raw native records may exist. not_expected: no online prices to collect.
kindsportsbook, exchange, prediction, dfs, contest, retail or reference.
statusWhat the book is: active, closed, retail_only or not_a_book. It doesn't say whether prices are arriving; state does.
ev_role, ev_weight, familyHow the EV system treats it. See References and weights.
sourceThe collector that last carried it, or the collector set up for it.
events, markets, pricesWhat it has on the board. All 0 unless state is feeding.
raw_prediction_contracts, raw_prediction_bbos, raw_prediction_counts_as_ofSeparate native record counts and their count-report time; see native prediction counts. Not canonical prices, financial EV or quote freshness.
ev_rows, positive_ev_rowsScored rows for this book, including pick'em rows, and how many are plausible with EV above zero.
reasonWhy a book isn't feeding, where known.
unresolved_booksBook names a source sent that the registry can't place, with counts.

GET /healthz

  • Free
  • No key

Returns { "status": "ok" } when the Worker answers. It doesn't check the board or the collectors; use /v1/status for that.

Stream: connecting

URL wss://zippyoddsapi.com/v1/stream

The stream needs the stream feature: Syndicate (10 sockets per account) or Enterprise (50). A connection costs 1 credit, then 1 credit for each minute it stays open. On an unlimited plan that doesn't touch your balance, but a key's monthly cap still counts it.

Deltas go out when a collector's read changes something, so their rate follows how often each venue is read, not a fixed tick.

From a server, send the key as a header. A browser can't set a header on a WebSocket, so it sends the key in its first message. A key in the URL is refused with 400 api_key_in_url.

// Node, with the ws package
import WebSocket from "ws";

const ws = new WebSocket("wss://zippyoddsapi.com/v1/stream", {
  headers: { Authorization: `Bearer ${process.env.ZIPPYODDS_KEY}` },
});
// Browser: authenticate in the first message
const ws = new WebSocket("wss://zippyoddsapi.com/v1/stream");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", api_key: key }));

A key with an IP allowlist has to use the header. The first-message route has no address to check against the list, so it fails with close code 4001.

An open connection is checked again each billing minute, so a revoked key or a changed plan takes effect without a reconnect.

A client that handles gaps

let lastSeq = 0;

ws.onmessage = ({ data }) => {
  const message = JSON.parse(data);
  switch (message.type) {
    case "welcome":
      ws.send(JSON.stringify({ type: "subscribe", leagues: ["nba", "nhl"], markets: ["main"] }));
      break;
    case "snapshot":
    case "delta":
    case "event":
    case "clocks":
      if (lastSeq && message.seq !== lastSeq + 1) ws.send(JSON.stringify({ type: "resync" }));
      lastSeq = message.seq;
      apply(message);
      break;
    case "heartbeat":
      if (message.seq > lastSeq) ws.send(JSON.stringify({ type: "resync" }));
      break;
  }
};

Stream: messages

From you

TypeFieldsWhat happens
authapi_keyMust be the first message if no key was sent in a header.
subscribeleagues, event_ids, books, markets, periods, capabilitiesReplaces any earlier subscription, then you get subscribed and a fresh snapshot. All fields are optional lists of up to 64 items. Put clocks_v1 in capabilities to also get clocks messages.
resync—Sends a fresh snapshot for the current subscription.
unsubscribe—Stops updates. There is no reply. Heartbeats and billing continue.
ping—Answered with pong.

You can send at most one subscribe or resync a second. A faster one gets an error with code rate_limited and is not acted on.

Subscribing with no leagues and no event_ids means the whole board. markets defaults to main and periods to full_game. Use props, player_* or all to get player markets.

From the server

TypeHas seqMeaning
welcomeNoConnected and authenticated. Has connection_id, plan, heartbeat_ms (15000), credits_remaining and capabilities: what you can ask for on subscribe, now including clocks_v1. Listing one turns nothing on.
subscribedNoRepeats the subscription as it was accepted.
snapshotYes, one per partdata is a list of events in the /v1/odds row shape, always in American odds, including suspended markets and pick'em apps that match. A large snapshot arrives in parts: see part and parts.
deltaYests, changes (whole changed markets) and removed (market ids).
eventYesA status or score changed: event_id, status, score.
clocksYesOnly if you subscribed with clocks_v1. Markets a book read again and found unchanged, so they got no delta, with the clock they are now as of: ts and books. See below.
heartbeatRepeats the latestSent about every 15 seconds, and sometimes sooner.
pongNoAnswer to ping.
errorNocode and message. Codes: invalid_json, rate_limited, unknown_message. The connection stays open.
welcome
{ "type": "welcome", "connection_id": "con_5e2c9a7f1b3d4860", "plan": "syndicate", "heartbeat_ms": 15000, "credits_remaining": null, "capabilities": ["snapshot_fragments_v1", "subscription_resume_v1", "clocks_v1"] }
subscribed
{ "type": "subscribed", "leagues": ["nba"], "markets": ["main"] }
snapshot
{ "type": "snapshot", "data": [], "part": 1, "parts": 1, "seq": 1 }
delta
{
  "type": "delta",
  "ts": "2026-11-06T18:00:05.000Z",
  "changes": [
    {
      "event_id": "evt_0h7k2m9q4x1tzb",
      "book": "prophetx",
      "id": "mkt_0k2v9qa1x7m3pc",
      "key": "total",
      "period": "full_game",
      "line": 224.5,
      "player": null,
      "suspended": false,
      "outcomes": [
        { "id": "sel_1f8s2ld0q9w4ze", "side": "over", "name": "Over", "point": 224.5, "price": -108, "liquidity": 840, "multiplier": null },
        { "id": "sel_0p3n7ty5h2c6vb", "side": "under", "name": "Under", "point": 224.5, "price": -112, "liquidity": 1120, "multiplier": null }
      ]
    }
  ],
  "removed": ["mkt_2w7e1q9r5t3yui"],
  "seq": 2
}
event
{ "type": "event", "event_id": "evt_0h7k2m9q4x1tzb", "status": "live", "score": { "home": 12, "away": 9 }, "seq": 3 }
heartbeat
{ "type": "heartbeat", "seq": 3 }

A market in changes sends every field, with null where a REST answer would leave the field out, and each one is tagged with event_id and book. removed isn't filtered by your subscription, so ignore ids you don't hold. Any filters you set apply to changes.

Clocks

A delta goes out when something about a market changes. When a book is read again and its price hasn't moved, nothing changes, so nothing is sent, and the observed_at you hold stops moving even though the book is still quoting that price. Judged by that held clock, the price would look stale after 900 seconds (90 once live) when it isn't.

Subscribe with "capabilities": ["clocks_v1"] to hear about those reads. Each clocks message lists, per book, a clock (observed_at) and either event_ids or market_ids:

  • event_ids: every market of that book in those games is at least that new. Move each one you hold there to it.
  • market_ids: only those markets. Used when a book's markets in a game were read at different times.

Move a clock forward only, never back, and leave an outcome's own observed_at as it is. Games and markets are named only if you held them when their clock was queued. One you have dropped since can still appear, so ignore ids you don't hold. You get at most one clocks message every 5 seconds, each at most 64 KiB. What doesn't fit waits its turn, oldest first, and goes at its latest clock. With nothing else to send, a waiting clock goes at the next heartbeat, so it can take about 15 seconds. With a very large subscription, a turn can take longer. They cost nothing extra. Without clocks_v1 you never get one.

Judge a price's age on the server's clock, not your device's: take ts on messages, or as_of on REST answers, as the server's time.

clocks
{
  "type": "clocks",
  "ts": "2026-11-10T12:00:05.000Z",
  "books": {
    "pinnacle": [
      { "observed_at": "2026-11-10T12:00:01.000Z", "event_ids": ["evt_0h7k2m9q4x1tzb", "evt_3n8c5v1x7z2qwe"] }
    ],
    "prophetx": [
      { "observed_at": "2026-11-10T11:59:58.000Z", "market_ids": ["mkt_0k2v9qa1x7m3pc"] }
    ]
  },
  "seq": 4
}

Stream: sequence numbers

snapshot, delta, event and clocks each raise seq by exactly 1 per connection, starting from 1. A heartbeat repeats the latest value without raising it. So a jump, or a heartbeat above the last seq you saw, means you missed something: send resync.

seq starts again on a new connection. If nothing at all arrives for about 45 seconds, treat the connection as dead and reconnect with a backoff, which costs another connect credit.

Stream: close codes

CodeMeaningRetry?
4001Authentication failed: a bad key, or a first message that wasn't auth.No. Fix the key.
4002Out of credits, or the key's monthly cap was reached. Also sent when the per-minute check finds the key revoked or expired, or the account suspended.No, until that is resolved.
4003The plan doesn't include streaming. Also sent for a suspended account using first-message auth.No. Change plan.
4029Too many open sockets for this plan.After closing another socket.
OtherA network drop or a service restart.Yes, with a backoff.

How EV is worked out

In short: each price at each book is compared with a fair probability, taken from what the sharp references say with their margin removed. A book never contributes to its own fair price, and neither does any book sharing its odds platform. EV figures compare prices at the moment they were read. They are estimates, not a promise of return.

These settings are fixed. The API has no parameter to change the devig method, the weights or the gates.

References and weights

Every book has a role, reported as ev_role.

RoleMeaning
referenceWeight above 0: helps set fair prices for other books, and its own prices are scored too.
scoredWeight 0: never sets a fair price, but its prices are scored.
pickemA pick'em app, handled by its own method.
noneNot in the EV system: closed, retail only, a salary-cap contest or not a place to bet.

The references below are rules in the system. Being listed doesn't mean a book is feeding. Check coverage for that: a reference with no prices has no effect.

BookWeightCounts onMinimum depth
Pinnacle100All markets—
Circa Sports100All markets—
FanDuel50Player markets only—
Bookmaker50Main markets only—
BetOnline50Main markets only—
Novig50All markets$300
ProphetX50All markets$100
Sporttrade50All markets$300
4Casters50All markets$300
Smarkets50All markets$300
DraftKings25All markets—
BetMGM25All markets—
Caesars25All markets—
Betano25All markets—

Main markets are moneyline, spread, total and three-way. Player markets are anything with a player. Team totals and alternates fall into neither, so only references marked "All markets" count for them.

To count on a given market line, a reference has to meet all of these:

  • It prices every side of the market.
  • Its margin is no more than 20%.
  • A sportsbook's margin is at least 0.1%, because a sportsbook pair with no margin is suspect.
  • An exchange's sides add up to at least 98%.
  • An exchange has depth on all sides, the deepest side has no more than twice the depth of the thinnest, and the total meets the minimum above.

Families

Books on one odds platform are one opinion. Within a family, the members' no-vig chances are averaged, and the family's weight is its largest member's weight, not the sum. A price is never compared with its own book or with any member of its own family.

FamilyBooks
pinnaclePinnacle, PS3838
bookmakerBookmaker, Betcris, Prime Sports
betonlineBetOnline, SportsBetting.ag, LowVig
betmgmBetMGM, Borgata, Sports Interaction, bwin, PartySports
betriversBetRivers, Betly, SugarHouse
kambibetPARX, Gun Lake, Four Winds, Bally Bet, Desert Diamond Sports, LeoVegas
bovadaBovada, Bodog
mybookieMyBookie, Xbet

Any other book is a family of one.

Changed on 10 Nov 2026: the six books that carry Kambi's shared price stream are one family, kambi. Arbitrage, middle and promo pairs between any two of them are no longer shown, including betPARX, Gun Lake and Four Winds, which were families of one. Bally Bet, Desert Diamond Sports and LeoVegas left betrivers, so their pairs with BetRivers, Betly and SugarHouse are now shown. The family field in the coverage report changes for those six books. Consensus and +EV are unchanged, since none of the six carries weight.

Gates

Before anything is computed, a price is dropped if any of these is true:

  • It isn't valid American odds, or it has no event, market or side.
  • Its market is suspended.
  • Its read time is more than 5 seconds in the future.
  • It is a pre-game price and the start time has passed.

Only the newest price for each book, side and line is kept.

Age is measured from when a price was observed, so a failing source goes stale rather than looking current. Old prices are kept and scored at their real age, but a stale row never counts as +EV.

  • The price being scored is stale when it is more than 15 minutes old before the game, or more than 90 seconds old once the game has started.
  • A reference setting the fair price may be up to 30 minutes old before the game, and 90 seconds once it has started. The 30 minutes is an interim limit, and it will tighten back to 15 once Pinnacle's prices are read more often. Pinnacle's copies currently sit 20 to 30 minutes old. On the live board on 8 November, +EV rows on a current price whose reference was 15 to 30 minutes old still beat Pinnacle's closing line 78% of the time (279 rows, average closing-line value 0.95%). Rows whose reference was older than 30 minutes lost (14 rows, −2.34%). So a +EV row can rest on a reference whose own entry in quote_observations shows quality.stale: true. That flag honestly applies the general 15-minute rule to every observation. Whether the row counts is decided by its own stale and reference_age_seconds.
  • Once a game's start time has passed, a price or reference read before kickoff is stale, whatever the event's status says. Anything read after kickoff gets the 90-second limit, even while the status still says the game hasn't started. A start time that is only a "to be confirmed" placeholder doesn't count as kickoff until a full day after it, since the placeholder names the day. A placeholder can also be a real kickoff, such as midnight Eastern for a game in Hawaii. So every row on such a game is listed but never counted, with stale_reason: start_time_to_be_confirmed.

A fair price is set only by references within their limit whenever any qualify. When none do, the price is scored against the older ones and the row is stale. Each row's stale_reason says why.

A scored row must then pass three plausibility checks to appear on the default /v1/ev board. All of them still appear with scored=true, marked plausible: false.

  • EV is at most 25%, or at most 10% when only one family set the fair price.
  • On a game market, the book also offers every other side, and its sides add up to between 100% and 125%. Player markets skip this check.
  • The row isn't stale.

Consensus and devig

  1. Gather the sides of one market line. Spreads pair at opposite handicaps; everything else pairs at one line. A market with only one side isn't scored.
  2. For each price being scored, take the qualifying references outside its book and its family. If there are none, that price isn't scored.
  3. Remove each reference's own margin from its own prices with Shin's method. On a two-way market that takes half the margin off each side's implied probability. On a three-way market it allows for some bettors knowing more than the book. A wide book's margin can't spill onto the others. Shin was chosen on our own data. Against low-margin exchange prices on the same lines (615 pairs on 8 Nov), it had the smallest error and almost no favourite bias. The multiplicative method, dividing each side by the total, under-rated favourites by 0.35 points. The power method over-corrected.
  4. Average the no-vig chances inside each family, then take a weighted average across families, using each family's weight. That is the fair probability.
  5. Score the price: EV = fair probability × decimal odds − 1. Edge = fair probability − 1 / decimal odds. Kelly = EV / (decimal odds − 1), kept between 0 and 1. At an exchange, the decimal odds are those after its published fee.

Exchange fees

An exchange's posted prices leave out its fee, so EV at an exchange uses the price after the fee the venue itself publishes. The posted prices still set fair prices as they are.

VenueFee on a straight betPrice used
ProphetX2% of net winnings. NHL uses maker/taker fees instead, which aren't modelled, so NHL rows are unadjusted, fee_verified: false and never +EV.1 + (d − 1) × 0.98
4Casters1% of every accepted stake, or 0.75% on MLB moneylinesd / 1.01
Smarkets2% of net winnings, the default rate1 + (d − 1) × 0.98
NovigNone before the game. Live takers pay 0.06 × P × (1 − P) per contract on top of price P, per Novig's official fee schedule, read on 8 November 2026. One FAQ sentence on the same page says 0.03; we use the schedule, which can only understate a live edge.Live: 1 / (P + 0.06 P (1 − P))

Only a published, cited fee counts. An exchange whose fee isn't on file, or a league whose schedule isn't modelled (ProphetX NHL), gets fee_verified: false. Its EV is shown before any fee, and the row is never plausible, so it never counts as +EV. It stays visible with scored=true and its raw prices stay on /v1/odds, the same way Kalshi and Polymarket are held back.

Arbitrage, middles, holds

Arbitrage pairs the two sides of a two-way market across different families. Each book must quote both sides, adding up to between 100% and 125%, and the two legs' start times must agree within 3 hours. Margin is 1 / (1 / decimal A + 1 / decimal B) − 1, using each exchange leg's price after its published fee, and anything above 15% is dropped as not credible. Every leg must be current (see Leg ages).

Middles look for over the lower total and under the higher, or spreads where home −3.5 and away +4.5 both win on a 4-point home win. The legs must be from different families, and the window must contain a result. cost must be at most 10%, and not below −15%.

Holds take the best price on each side among books that quote both sides, adding up to between 100% and 125%. hold is the sum of the implied probabilities minus 1, and fairProbability removes that margin multiplicatively.

Leg ages

Arbitrage, middles, holds, hedges and smart money judge every leg by the age rule a priced quote gets on /v1/ev (see Gates). Every leg is a price someone has to take, so the 30-minute reference allowance never applies. A leg is stale when it is more than 15 minutes old before the game, more than 90 seconds old once the game has started, or was read before a start that has passed. A placeholder start time doesn't count as kickoff for a day, but because it may be a real kickoff, a leg on such a game is never current either. Its staleReason is start_time_to_be_confirmed.

  • By default only rows whose every leg is current are listed. Holds take each side's best current price.
  • Arbitrage and middles also price each exchange leg after its published fee, and hold back rows with a leg at an exchange whose fee isn't verified, as /v1/ev does.
  • include_stale=true also returns the held-back rows, flagged feeUnverified, and rows with an old leg, each leg at its real age with a staleReason of price_older_than_limit or price_read_before_start. On holds it lets an old price be the best price again.
  • Paging pins every page to the first page's as_of, so ages are measured from the same moment throughout one walk.

On the live board at 20:50Z on 8 November, 307 of 332 arbitrage rows, 73% of middles and 42% of holds rested on a leg older than this. Nearly all were soft-book prices read hours earlier, that had since moved.

Pick'em pricing

Pick'em edges require a verified entry payout and a fair probability. Apps with verified per-pick payouts expose that factor as multiplier. Dabble's operator price is also returned in that field, but its relationship to the entry payout is unverified, so its threshold and edge are withheld. /v1/dfs estimates the line's fair probability from sharp books and derives a break-even only when the payout is verified. It uses a different recipe from the +EV board: each book is devigged first, then averaged, using the power method and a maximum margin of 10%.

Pick'em referenceWeight
Pinnacle (with PS3838), Circa Sports2
FanDuel, Bookmaker, BetOnline, DraftKings, Novig, ProphetX, 4Casters, Propbuilder1
  • Books with a price at the exact line are used, and they are never blended with interpolated books.
  • If none has the exact line, each book can interpolate between its own lines on either side, on a log-odds scale. It can reach 0.5 of a stat for shots on goal, and 2.5 for yards, points and points + rebounds + assists. Other stats can reach 1 when the line is under 10, and 2.5 otherwise.
  • Interpolated books must agree within 3 percentage points, or the row reports low_confidence. With no usable book it reports no_sharp_books.
  • Books at the exact line that are 6 or more percentage points apart on the under report books_disagree: one of them is pricing something else or is out of date. On the live board on 8 Nov, same-line sharp books were 1.1 points apart at the median and 4.3 at the 99th percentile.

Break-even comes from the app's payout table, using its cheapest all-must-hit entry: multiplier−1/picks. These tables are the apps' published figures, which can change.

AppReturn per $1 by number of picksCheapest break-even
PrizePicks2: 3, 3: 6, 4: 10, 5: 20, 6: 37.554.66% (6 picks)
Underdog Fantasy2: 3.5, 3: 6.5, 4: 12, 5: 2053.45% (2 picks)
DraftKings Pick62: 3, 3: 6, 4: 1055.03% (3 picks)
Betr Picks2: 3, 3: 6, 4: 10, 5: 20, 6: 35, 7: 50, 8: 10054.93% (5 picks)

On Sleeper Picks, Chalkboard, Boom Fantasy, ParlayPlay and HotStreak, each pick has its own multiplier, so the threshold is 1 / multiplier and basis is per_pick_multiplier. With no published multiplier, edge is null. Other apps have basis: "none". That includes Dabble for now: its picks carry prices that vary pick by pick, and how those prices change a payout isn't verified, so its threshold and edge are null. Its line, multiplier, fairProbability and references are still sent.

Known limits

  • Native prediction contracts and BBO are raw observations with no verified quote-update clock, execution, payout or financial EV model. Their separate counts do not imply canonical game-price coverage.
  • /v1/scores has no finished games, because a game leaves the board when a source reports it final.
  • clock on events and team_id on players are always null.
  • Webhooks exist for line moves on plans with the webhooks feature, at 1 credit a delivery, but are set up by the operator for now. Each POST has X-Unerring-Signature: t=<seconds>,v1=<hex>, the HMAC-SHA256 of <t>.<body> with your signing secret. A target that fails 20 times in a row is switched off.
  • Prices are polled from each venue, so they are minutes old rather than milliseconds. Pinnacle's public data is held by its CDN for up to 15 minutes, so an edge against it can already be gone. updated_at on each book shows when its prices were read, and /v1/ev ages them from when they were really fetched.
  • History covers full-game moneyline, spread and total markets only, for 30 days.
  • /v1/arbitrage, /v1/middles, /v1/holds, /v1/smart-money, /v1/hedges and /v1/dfs use different field naming from the rest of the API, as noted under Computed.
  • Bet links, deep links and per-state pricing are not offered.
  • Coverage changes as collectors are added or a venue blocks reads. Rely on /public/coverage, not on any list in these docs.

Changes

10 Nov 2026: stream clocks

  • The stream can now tell you when a price was read again and found unchanged. Subscribe with "capabilities": ["clocks_v1"] to get clocks messages. Each gives, per book, a clock and the games or markets it applies to. Without them, a price re-read at the same number sends nothing, so the observed_at you hold stops moving. They are opt-in, so a client that doesn't ask sees no new message type. At most one is sent every 5 seconds per connection, up to 64 KiB each. Credits are unchanged. The welcome message's capabilities list now includes clocks_v1.
  • On the site, the odds screen asks for them. The grid now never highlights a stale price as the best, by the same rule as /v1/best-lines, judged on the server's clock, and draws again when the earliest best price would go stale. So the Best view and the grid agree.

10 Nov 2026: best lines are fresh

  • /v1/best-lines takes the best fresh price on each side: a price past its age limit never sets a best line. A game whose only prices are stale isn't listed. In a game that is listed, a side or a whole market with no fresh price is left out, so a two-way market can come back with one side, and books_quoting counts only books with a fresh price. The NDJSON export follows the same rule. Credits are unchanged.
  • include_stale=true on /v1/best-lines lets stale prices compete, and every entry in observations now carries stale. It no longer lists games whose status is stale there: name such a game in event_ids.
  • On the site, the odds screen's Best view follows this rule. The grid's highlighted best price doesn't judge age yet, because the stream doesn't carry a re-read that found the same price. So the two can differ until it does.

10 Nov 2026: bookmaker clocks

  • bookmaker.observed_at, updated_at and quality on /v1/odds, /v1/props, /v1/events/{id}/odds and stream snapshots now come from the oldest clock among the markets and outcomes returned for that book. They used to come from the book's latest read, so a request for props or alternates could get a bookmaker marked fresh around markets hours old. A client that filters bookmakers on quality.stale will see more of them stale where a book's markets were read at different times, and fewer where only markets you didn't ask for were old.
  • New bookmaker.book_observed_at: the book's newest copy. received_at and time_basis now describe that copy. time_basis is snapshot or read; it is never venue.
  • venue_updated_at is no longer sent for Onyx or for Crypto.com (from Covers): their stamps don't move when their prices do. Nor for Fanatics Markets, whose lastSeenAt isn't shown to be a price-change time. History recorded before this change still carries them.
  • Hard Rock is clocked at its copy's own age, and a game it re-sends from its last read keeps that copy's clock.
  • Kambi books (LeoVegas, Gun Lake, BetParx, Four Winds and the rest) are clocked at their CDN copy's own age, from its Date and Age, futures included.
  • Betway, LetsBetMD, BetUS, PointsBet CA, Betinia, Monopoly, Dabble and Sleeper now carry their copy's own clock, from its Date and Age, where they used to be clocked at our read time. The first four keep that clock when they send a copy again from their cache, where they used to send it at the time it was first read.