API reference
UnerringAPI
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://unerring-api.liam-banks2004.workers.dev
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.
UnerringAPI does not take bets. Accounts and keys are issued by the operator. 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://unerring-api.liam-banks2004.workers.dev/v1/status" \
-H "Authorization: Bearer $UNERRING_KEY"
# Main markets for the NBA, in decimal odds
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/odds?league=nba&format=decimal" \
-H "Authorization: Bearer $UNERRING_KEY"
# Prices at least 2% above fair value
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/ev?league=nba&min_ev=2" \
-H "Authorization: Bearer $UNERRING_KEY"
Authentication
Send your key in a header, either form. Keys start with ua_live_.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/usage" \
-H "Authorization: Bearer ua_live_..."
curl -s "https://unerring-api.liam-banks2004.workers.dev/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
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
}
}
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.
Plans
- 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.
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.
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.
BASE="https://unerring-api.liam-banks2004.workers.dev"
cursor=""
while :; do
page=$(curl -s "$BASE/v1/events?league=nba&limit=100${cursor:+&cursor=$cursor}" \
-H "Authorization: Bearer $UNERRING_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.
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.
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.
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.
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
A league id being accepted doesn't mean it has events. /v1/leagues gives the current count for each.
Ids
Treat ids as opaque strings. None of them contains a comma, so they are safe in list parameters.
Account and catalog
GET /v1/status
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://unerring-api.liam-banks2004.workers.dev/v1/status" \
-H "Authorization: Bearer $UNERRING_KEY"
Example data{
"status": "ok",
"time": "2026-11-06T18:00:00.000Z",
"provider": "UnerringAPI",
"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
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
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
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
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.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/sportsbooks?type=exchange" \
-H "Authorization: Bearer $UNERRING_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:
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
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
Teams on the current board. This isn't a complete league directory: a team with nothing on the board isn't listed.
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
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.
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
Events, soonest first.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/events?league=nba,nhl&limit=50" \
-H "Authorization: Bearer $UNERRING_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}
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
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
Events with each book's prices. league or event_ids is required.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/odds?league=nba&markets=spread,total&format=decimal" \
-H "Authorization: Bearer $UNERRING_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
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
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://unerring-api.liam-banks2004.workers.dev/v1/events/evt_0h7k2m9q4x1tzb/odds?markets=all" \
-H "Authorization: Bearer $UNERRING_KEY"
GET /v1/props
Player markets, from sportsbooks and pick'em apps together. Only events with at least one matching market are returned.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/props?league=nba&markets=player_points&player=example" \
-H "Authorization: Bearer $UNERRING_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
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
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.
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://unerring-api.liam-banks2004.workers.dev/v1/prediction-contracts?source=polymarket_us&kind=contract,bbo&limit=100" \
-H "Authorization: Bearer $UNERRING_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.
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://unerring-api.liam-banks2004.workers.dev/v1/prediction-contracts?source=polymarket_us&export=ndjson" \
-H "Authorization: Bearer $UNERRING_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
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://unerring-api.liam-banks2004.workers.dev/v1/best-lines?league=nhl&markets=moneyline" \
-H "Authorization: Bearer $UNERRING_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
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.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/history?event_id=evt_0h7k2m9q4x1tzb&book=prophetx&market=total" \
-H "Authorization: Bearer $UNERRING_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
Prices above fair value, best first. Only rows that pass the plausibility checks are returned, unless you ask for scored=true.
curl -s "https://unerring-api.liam-banks2004.workers.dev/v1/ev?league=nba&min_ev=2&limit=50" \
-H "Authorization: Bearer $UNERRING_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
}
GET /v1/arbitrage
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://unerring-api.liam-banks2004.workers.dev/v1/arbitrage?limit=25" \
-H "Authorization: Bearer $UNERRING_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
}
GET /v1/middles
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
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://unerring-api.liam-banks2004.workers.dev/v1/holds?books=bovada,prophetx" \
-H "Authorization: Bearer $UNERRING_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
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.
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
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
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.
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
}
No key needed
GET /public/coverage
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://unerring-api.liam-banks2004.workers.dev/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
}
]
}
GET /healthz
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://unerring-api.liam-banks2004.workers.dev/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://unerring-api.liam-banks2004.workers.dev/v1/stream", {
headers: { Authorization: `Bearer ${process.env.UNERRING_KEY}` },
});
// Browser: authenticate in the first message
const ws = new WebSocket("wss://unerring-api.liam-banks2004.workers.dev/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
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
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.
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.
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.
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.
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
- 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.
- For each price being scored, take the qualifying references outside its book and its family. If there are none, that price isn't scored.
- 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.
- 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.
- 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.
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 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%.
- 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.
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.