Line Shopping With an Odds API: Best Prices Across Every Book
How to find the best available price for every outcome across sportsbooks and exchanges in code. Matching outcomes, comparing prices and numbers, /v1/best-lines versus computing it yourself, freshness and credit budgets.
Line shopping is the practice of comparing the price of one outcome across every book that offers it and noting where it is best. For a person with a few apps open, it is tedious. For a developer, it is a data problem with a precise shape: match the same outcome across sources, compare the prices on a common scale, handle ties, numbers that differ and stale quotes, and keep the answer fresh without burning through a rate limit.
This post works through that problem with the PreciseAPI feed. It covers what "the same outcome" means in the data, how to compare prices correctly, when to call /v1/best-lines and when to compute the answer yourself from /v1/odds, and what a refresh loop costs in credits. It is about building comparison products such as odds screens, media widgets and research tools. It is not advice about what to bet.
Why prices differ across books
Every book sets its own prices, and they differ in three ways that matter to a comparison product:
- Margin. Each book builds a different amount of vig into its prices, and that amount varies by market. The sportsbook hold post shows how to measure it per book and market.
- Speed. A sharp book tends to react quickly to new information. A soft book can lag, so for a while its price sits away from the rest of the market.
- Shading. Books adjust prices on popular sides and on their own exposure, so two books with the same margin can still disagree on one outcome.
The line shopping glossary entry has the short definition; the rest of this post is about the code.
What "the same outcome" means
You can only compare prices for outcomes that settle identically. Four things have to match:
| Dimension | Field | Example of a mismatch |
|---|---|---|
| Event | event id | Two games between the same teams in a series |
| Market | market key | total and team_total |
| Period | market period | full_game and 1h |
| Side and number | outcome side and point | Home -3 and home -3.5 |
Player props add the player, and team totals add the team. Getting this right by string matching across 49 sources is the hard part of building a comparison product, which is why the feed normalizes it for you. Every outcome carries a selection id built from the event, market key, period, subject (player or team), variant, side and point, but not from the book. The same outcome at the same number therefore has the same selection id at every source that prices it, and grouping by outcome.id gives you exactly the comparable set. The normalization post explains how those ids are built.
Comparing prices on a common scale
American odds are not a linear scale. You cannot subtract -110 from +105 and get anything meaningful, and the jump from -100 to +100 is a single step in price. Compare in decimal odds, where higher is always better for the person holding the ticket, and report the difference in implied probability, which reads naturally as "how much cheaper is this price":
| Price | Decimal | Implied probability |
|---|---|---|
| -110 | 1.909 | 52.38% |
| -105 | 1.952 | 51.22% |
| +100 | 2.000 | 50.00% |
Moving from -110 to -105 lowers the implied probability by 1.16 percentage points, and from -110 to +100 by 2.38 points. Those are the numbers a comparison table can show next to each price. The implied probability guide covers the formulas and the format=probability option in depth.
A worked example
Here is an illustrative moneyline on one game, priced by four books:
| Book | Home | Away | Sum of implied |
|---|---|---|---|
pinnacle | -142 | +130 | 1.0216 |
draftkings | -150 | +125 | 1.0444 |
fanduel | -145 | +122 | 1.0423 |
circa | -140 | +128 | 1.0219 |
| Best | -140 (circa) | +130 (pinnacle) | 1.0181 |
The best home price is -140 (decimal 1.714) and the best away price is +130 (decimal 2.300). Each book's own prices sum to between 1.0216 and 1.0444, while the best price on each side sums to 1.0181. That combined figure is a useful single metric for how competitive a market is across the books you track: the lower it is, the tighter the board. The best home price is also 1.67 points of implied probability cheaper than the worst one (60.00% at -150 versus 58.33% at -140).
Option 1: let the API compute it
/v1/best-lines returns, for every comparable market, the best price per outcome and the books offering it. It costs 3 credits per request.
curl "https://api.preciseapi.com/v1/best-lines?league=nfl&markets=moneyline,spread,total&periods=full_game&books=pinnacle,draftkings,fanduel,circa,betmgm" \
-H "Authorization: Bearer $PRECISEAPI_KEY"
books= limits the comparison to the sources your users can actually access; bookmakers= is accepted as an alias. Without markets=, the endpoint uses the main lines (moneyline, spread and total). A trimmed event from the response looks like this:
{
"id": "evt_0uuc6p2dsqa",
"league": "nfl",
"start_time": "2026-10-18T17:00:00.000Z",
"status": "scheduled",
"home": { "id": "nfl_kc", "name": "Kansas City Chiefs", "abbr": "KC" },
"away": { "id": "nfl_buf", "name": "Buffalo Bills", "abbr": "BUF" },
"markets": [
{
"key": "moneyline",
"period": "full_game",
"books_quoting": 4,
"outcomes": [
{ "id": "sel_0k4vq8t2mxe", "name": "Kansas City Chiefs", "side": "home", "best_price": -140, "best_books": ["circa"] },
{ "id": "sel_1r7bn3wq5zd", "name": "Buffalo Bills", "side": "away", "best_price": 130, "best_books": ["pinnacle"] }
]
}
]
}
The rules it applies are the same decisions you would otherwise make yourself:
- Grouped by number. Books are compared only when they quote the same market, period, subject and line. If two books hang home -3 and three hang -3.5, you get two spread entries, each with its own
books_quoting. - Two or more quotes. A group appears only when at least two books quote it. A line that a single book offers has nothing to be compared against.
- Ties are kept.
best_bookslists every book at the best price. - Suspended markets are skipped. A price a book has pulled is not a price anyone can get.
- DFS apps are excluded. Pick'em apps pay fixed multipliers rather than per-selection prices (see the pick'em post).
- Alternate ladders are excluded.
alternate_spreadandalternate_totalare not grouped; compare those yourself (below). - Same format as everywhere else.
format=decimalorformat=probabilitychangesbest_price; the default is American.
Player props work too: pass player_* keys in markets= and each player's line is compared across sportsbooks.
Option 2: compute it from /v1/odds
If you already pull the full board for display, /v1/odds (2 credits) gives you everything needed to compute best prices in the same pass. Grouping by selection id does most of the work:
const toDecimal = (a) => (a > 0 ? 1 + a / 100 : 1 + 100 / -a);
// event: one element of the /v1/odds `data` array, prices in American format.
export function bestPrices(event, { maxAgeMs = 60_000, now = Date.now() } = {}) {
const best = new Map(); // selection id -> best quote
for (const book of event.bookmakers) {
if (book.type === 'dfs') continue;
for (const m of book.markets) {
if (m.suspended || now - Date.parse(m.updated_at) > maxAgeMs) continue;
for (const o of m.outcomes) {
const dec = toDecimal(o.price);
const cur = best.get(o.id);
if (!cur || dec > cur.decimal) {
best.set(o.id, { key: m.key, period: m.period, side: o.side, point: o.point, name: o.name, price: o.price, decimal: dec, books: [book.id], quotes: 1 });
} else {
cur.quotes += 1;
if (dec === cur.decimal) cur.books.push(book.id);
}
}
}
}
return [...best.values()].filter((b) => b.quotes >= 2);
}
Because the selection id includes the market key and the point, alternate rungs group correctly with the same rung at other books, which covers the case /v1/best-lines leaves out. The age filter matters too: a stale quote displayed as "best" is the most common bug in comparison tables. The odds screen tutorial shows the same logic in a React component.
Which option to use
| You need | Use | Credits |
|---|---|---|
| Only the best price per outcome | /v1/best-lines | 3 |
| The full board plus best prices | /v1/odds and compute locally | 2 |
| Best prices on alternate ladders | /v1/odds with alternate_* keys | 2 |
| Live best prices with no polling | the stream, recomputed on each delta | 1 per connection-minute |
When the numbers differ
Moneylines have no number, so "best" is just the highest decimal price. Spreads and totals are harder, because books often disagree on the line itself. Home -3 at -120 and home -3.5 at +100 are different bets, and no formula makes them directly comparable without a model of how likely the game is to land exactly on 3.
The usual product answer is to show both dimensions instead of collapsing them:
- Best number. For the home side of a spread, a larger
pointis better (-3 beats -3.5, +7.5 beats +7). For an over, a lower total is better; for an under, a higher one. - Best price at the main number. Pick a reference line, usually the most quoted one or a sharp book's, and find the best price among books at that number.
// Best number for one side of a spread or total across books.
const better = { home: (a, b) => a > b, away: (a, b) => a > b, over: (a, b) => a < b, under: (a, b) => a > b };
export function bestNumber(quotes, side) {
// quotes: [{ book, point, price }] for one side of one market and period
return quotes.reduce((acc, q) => {
if (!acc || better[side](q.point, acc.point)) return q;
if (q.point === acc.point && toDecimal(q.price) > toDecimal(acc.price)) return q;
return acc;
}, null);
}
The away side uses the same rule because its point is the away team's handicap: +7.5 beats +7 and -2.5 beats -3. The point spread data guide and the totals guide go deeper on lines, ladders and key numbers.
Exchanges and liquidity
Exchange and prediction market outcomes carry a liquidity field. A price is only as useful as the size available at it, so show liquidity next to exchange prices or filter out quotes below a threshold you choose. The prediction markets comparison explains why exchange prices often sit close to fair, and the Novig and Kalshi pages describe two of those sources.
Choosing the book set
The best price is only meaningful relative to the books a user can access. /v1/sportsbooks (free) returns each source with its type and tags (regulated, offshore, sharp, social, sweepstakes, exchange, prediction and so on). A common pattern:
- Fetch
/v1/sportsbooksonce a day and cache it. - Map each user's region to the list of books available there.
- Pass that list as
books=on every request, or filter locally if you pull everything.
Keep a sharp source such as Pinnacle or Circa in the request even when users cannot access it. It is the reference you use to judge whether a "best" price is genuinely good or just an outlier from a slow book, and it is the input for no-vig fair odds.
Keeping it fresh
A request costs the same regardless of how many books or markets it returns, and league= accepts several leagues at once, so polling cost depends only on how often you call.
| Refresh plan | Credits per minute | Per 30-day month |
|---|---|---|
/v1/best-lines, one call every 30 s | 6 | 259,200 |
/v1/odds, one call every 30 s | 4 | 172,800 |
| One WebSocket connection | 1 | 43,200 |
Polling best lines every 30 seconds fits the Starter plan's 300,000 monthly credits. The stream is cheaper and fresher, but it is included on the Sandbox, Pro and Enterprise plans rather than Starter; see pricing for the full matrix. With the stream, load a snapshot, keep a local copy of every market, and rerun the bestPrices function above for the event that a delta touched. The streaming docs cover the protocol, the Node.js WebSocket tutorial shows a reconnecting client, and the polling versus streaming post works through the break-even in more detail.
Presenting the result
Show every tied book, show the age of each quote (grey out anything past your threshold), never highlight -3.5 +100 as "better" than -3 -120 without saying the numbers differ, and label exchange quotes with their liquidity. Present prices as information and follow the advertising rules where you publish.
The best lines API page summarizes the endpoint, the odds screen use case covers the full product pattern, and the media widget post shows an embeddable version with server-side caching. The quickstart gets a key working in a few minutes.
Frequently asked questions
Does /v1/best-lines include DFS pick'em apps?
No. DFS apps are always excluded from /v1/best-lines, because pick'em entries pay fixed multipliers across several selections rather than a price per outcome. You can still pull DFS lines from /v1/odds or /v1/props and compare the lines themselves.
Why does a spread appear more than once in the best-lines response?
Books are grouped by line. If some books quote home -3 and others home -3.5, each number is a separate comparable market with its own best prices and books_quoting count. Pick a reference number, or show both.
Can I line shop alternate spreads and totals?
Yes, but compute it yourself. /v1/best-lines skips alternate_spread and alternate_total. Request those keys from /v1/odds and group outcomes by selection id, which already includes the point, so each rung is compared only with the same rung at other books.
How often should I refresh best prices?
It depends on the product. Pre-game comparison tables are usually fine at 30 to 60 seconds, which costs 3 credits per /v1/best-lines call. Live products should use the WebSocket stream at 1 credit per connection-minute and recompute best prices locally on each delta.