NFL Team Totals API: Turn Vegas Odds Into Fantasy Projections
Every fantasy projection site sells you the same number: how many points Vegas expects your team to score. It drives start/sit calls, DFS stacks and streaming decisions, and it sits behind a paywall on most sites that publish it.
The number is arithmetic on two lines any sportsbook posts for free. I pulled all 16 NFL Week 1 fixtures from the OddsPapi API on 14 August 2026 and worked it out for all 32 teams. This post shows the code, the board it produces, and a check against the two bookmakers that publish team totals directly.
Only 2 of 18 books post a team total
The obvious route is to read the team total straight off the feed. NFL carries a market family called Over Under Team 1 (incl. overtime), so you would expect to grab it and stop.
It is thin. Across all 16 Week 1 fixtures, 18 bookmakers priced the opener and exactly two of them posted a team total: Pinnacle and SBOBet. Both quote one line per team. Nobody else on the board carries the market at all.
| Market | Books quoting it | Lines per book |
|---|---|---|
| Total (incl. overtime) | 18 of 18 | 1 (retail) to 16.6 (Kalshi) |
| Handicap (incl. overtime) | 12 of 18 | 1 (retail) to 25 (Kalshi) |
| Over Under Team 1 / Team 2 | 2 of 18 | 1 |
Twelve books quote both a spread and a game total. That is your input set, and the conversion takes one line of arithmetic:
home_points = (total - home_spread) / 2
away_points = (total + home_spread) / 2
A game total of 44.5 with the home team laying 3.5 splits into 24.0 and 20.5. The spread tells you the gap, the total tells you the sum, and two equations give you both sides.
The old way versus the API
| Task | Scraping or a projection site | OddsPapi |
|---|---|---|
| Get the spread and total | Scrape one book, break when the DOM changes | One GET per fixture, 350+ bookmakers in the catalogue |
| Cross-check the number | One book’s opinion | 12 independent NFL quotes per game |
| Alternate lines | Usually hidden behind a click | Full ladder, one market ID per rung |
| Watch it move to kickoff | Poll and store it yourself | /historical-odds on the free tier |
| Cost | $10 to $40 a month for projections | Free tier, no card |
Step 1: authenticate and pull Week 1
Auth is a query parameter, not a header. NFL is tournamentId 31 inside sportId 14, and the tournamentId filter works on /fixtures even though the docs do not list it.
import requests, time, statistics
from collections import defaultdict
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
NFL_TOURNAMENT_ID = 31
def api_get(path, **params):
params["apiKey"] = API_KEY
for _ in range(5):
r = requests.get(f"{BASE_URL}/{path}", params=params, timeout=120)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 0.3)
continue
if r.status_code == 404:
return [] # an empty date window 404s, it does not return []
r.raise_for_status()
return r.json()
raise RuntimeError("rate limited")
fixtures = api_get("fixtures", sportId=14, tournamentId=NFL_TOURNAMENT_ID,
**{"from": "2026-09-09", "to": "2026-09-16"})
fixtures = [f for f in fixtures if f["hasOdds"]]
print(len(fixtures), "fixtures with odds")
# 16 fixtures with odds
Two things that bite here
Set to to the day after the last day you want. The window runs from midnight to midnight UTC, so a same-day query returns almost nothing. An empty window answers HTTP 404 with a JSON error body rather than an empty list, which kills a loop that calls raise_for_status(). Both bite hardest on a season-long loop over months that include the off-season.
Team names live on /fixtures, not on /odds. The odds payload carries participant1Id and participant2Id and nothing else, so keep the fixture object around and join on fixtureId. Participant 1 is the home team. Prediction market deep links confirm it: Polymarket’s fixturePath for the opener reads nfl-ne-sea-2026-09-10 and Kalshi’s reads 26sep09nesea, both listing the visitor first, and 31 of 32 deep links across the slate agree.
Step 2: build the market name lookup
NFL spreads and totals have one market ID per line. A 3.5 point spread is 14272, a 4 point spread is 14270, a 44.5 total is 1464. There is no single “spread” ID to hardcode, so resolve by name.
catalog = api_get("markets", sportId=14)
market_info = {m["marketId"]: (m["marketName"], m["handicap"]) for m in catalog}
outcome_names = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in catalog for o in m.get("outcomes", [])}
SPREAD = "Handicap (incl. overtime)"
TOTAL = "Total (incl. overtime)"
print(len(catalog), "rows in the catalogue")
# 32815 rows in the catalogue
The catalogue is global rather than per sport, so sportId on /markets changes nothing about the response. Treat it as a name lookup and read the market IDs a sport actually uses off a live /odds payload.
Step 3: read each book’s ladders
The price sits four levels deep, under players["0"]. The outcome object itself holds one key. Check active on the price object, and skip any bookmaker flagged suspended, which means it pulled the market while still returning stale prices.
def read_ladders(book):
"""Return {market_name: {line: {outcome_name: price}}} for one bookmaker."""
ladders = defaultdict(lambda: defaultdict(dict))
for market_id, market in book["markets"].items():
name, line = market_info.get(int(market_id), (None, None))
if name not in (SPREAD, TOTAL):
continue
for outcome_id, outcome in market["outcomes"].items():
quote = outcome["players"].get("0")
if not isinstance(quote, dict) or not quote["active"]:
continue
label = outcome_names.get((int(market_id), int(outcome_id)), outcome_id)
ladders[name][line][label] = quote["price"]
# keep only two-sided lines
return {n: {l: p for l, p in lines.items() if len(p) == 2}
for n, lines in ladders.items()}
Step 4: find the main line
This is the part that decides whether your projections are right, and it is harder than it looks. Books split into two groups. DraftKings, FanDuel, BetMGM and the rest of US retail quote exactly one spread and one total, so their line is the main line by definition. Kalshi walks 25 spread rungs, Hard Rock 15, Pinnacle 9.
Two traps sit in the ladder books.
The mainLine flag does not point at the main line. Across the 111 cases where a book quoted more than one spread on a Week 1 game, the flagged rung matched the market’s actual number 46 times. Kalshi got it wrong on all 16 fixtures, flagging wings like -10.5 while its balanced quote sat at -3.5. Pinnacle managed 9 of 16.
| Bookmaker | mainLine flag matches |
|---|---|
| sbobet | 3 / 3 |
| pinnacle | 9 / 16 |
| bet365 | 8 / 16 |
| circasports | 5 / 8 |
| caesars | 5 / 10 |
| williamhill | 5 / 10 |
| polymarket | 6 / 16 |
| hardrockbet | 5 / 16 |
| kalshi | 0 / 16 |
| All ladder books | 46 / 111 (41%) |
Picking the line whose two prices sit closest to even money fails too. On the opener, SBOBet quoted totals of 36.5, 37, 43.5, 70.5 and 71.5, and priced every one of them near 1.90 on both sides. Bet365 carried 36.5 and 37 alongside its real 44.5. Stale alternate lines keep even-money prices, so evenness alone will hand you a 71.5 point NFL total.
Anchor on the single-line books instead, then snap the ladder books to the nearest rung:
def main_lines(books):
"""Anchor on books that quote one line, then snap ladder books to it."""
anchors = {}
for market in (SPREAD, TOTAL):
singles = [list(lines)[0] for lines in
(b.get(market, {}) for b in books.values()) if len(lines) == 1]
anchors[market] = statistics.median(singles)
resolved = {}
for slug, ladders in books.items():
picked = {}
for market in (SPREAD, TOTAL):
lines = ladders.get(market)
if lines:
picked[market] = min(lines, key=lambda l: abs(l - anchors[market]))
if len(picked) == 2:
resolved[slug] = picked
return anchors, resolved
The median of the single-line books can land off the half-point grid when they disagree. Denver at Kansas City resolved to -2.75 because the retail books split between -2.5 and -3. Keep it. The off-grid number is the consensus, and rounding it throws away the disagreement you just measured.
Step 5: derive the points
def implied_points(spread, total):
"""spread is the handicap on participant 1. Negative means participant 1 is favoured."""
return (total - spread) / 2, (total + spread) / 2
board = []
for fixture in fixtures:
payload = api_get("odds", fixtureId=fixture["fixtureId"])
books = {slug: read_ladders(b) for slug, b in payload["bookmakerOdds"].items()
if not b["suspended"]}
books = {s: l for s, l in books.items() if l}
anchors, resolved = main_lines(books)
home_pts, away_pts = implied_points(anchors[SPREAD], anchors[TOTAL])
board.append({"home": fixture["participant1Abbr"], "away": fixture["participant2Abbr"],
"spread": anchors[SPREAD], "total": anchors[TOTAL],
"home_pts": home_pts, "away_pts": away_pts, "books": len(resolved)})
time.sleep(1.0)
projections = []
for g in board:
projections.append((g["home"], g["home_pts"], f"vs {g['away']}"))
projections.append((g["away"], g["away_pts"], f"at {g['home']}"))
for team, pts, matchup in sorted(projections, key=lambda x: -x[1]):
print(f"{team:<5} {pts:>5.2f} {matchup}")
Sleep a full second between /odds calls. The free tier rate-limits per endpoint and answers 429 with a retryMs you should honour. Do not run these concurrently. Twelve threaded calls get eleven 429s.
The Week 1 board
Sixteen games, 12 books resolving on every one of them, pulled 14 August 2026.
| Game | Spread | Total | Home pts | Away pts |
|---|---|---|---|---|
| TB @ CIN | -3.5 | 51.5 | 27.50 | 24.00 |
| NO @ DET | -7 | 49.0 | 28.00 | 21.00 |
| SF @ LA | -3.5 | 48.5 | 26.00 | 22.50 |
| BAL @ IND | +3.5 | 48.5 | 22.50 | 26.00 |
| DAL @ NYG | +2.5 | 48.5 | 23.00 | 25.50 |
| WAS @ PHI | -4.5 | 47.0 | 25.75 | 21.25 |
| ARI @ LAC | -10.5 | 46.5 | 28.50 | 18.00 |
| CHI @ CAR | +2.5 | 45.5 | 21.50 | 24.00 |
| GB @ MIN | -1.5 | 45.0 | 23.25 | 21.75 |
| NE @ SEA | -3.5 | 44.5 | 24.00 | 20.50 |
| BUF @ HOU | -1 | 44.5 | 22.75 | 21.75 |
| DEN @ KC | -2.75 | 42.75 | 22.75 | 20.00 |
| ATL @ PIT | -3 | 42.0 | 22.50 | 19.50 |
| CLE @ JAX | -7.5 | 40.5 | 24.00 | 16.50 |
| MIA @ LV | -3.5 | 40.5 | 22.00 | 18.50 |
| NYJ @ TEN | -2.5 | 38.5 | 20.50 | 18.00 |
Sorted by team, that is the draft-week cheat sheet. The Chargers at 28.50 and the Lions at 28.00 sit twelve points clear of Cleveland at 16.50. Cincinnati and Tampa share the highest game total on the slate at 51.5, which is why a Bengals or Buccaneers skill player carries more scoring equity in Week 1 than his season projection alone suggests.
| Rank | Team | Implied pts | Rank | Team | Implied pts |
|---|---|---|---|---|---|
| 1 | LAC | 28.50 | 17 | IND | 22.50 |
| 2 | DET | 28.00 | 18 | PIT | 22.50 |
| 3 | CIN | 27.50 | 19 | LV | 22.00 |
| 4 | LA | 26.00 | 20 | BUF | 21.75 |
| 5 | BAL | 26.00 | 21 | GB | 21.75 |
| 6 | PHI | 25.75 | 22 | CAR | 21.50 |
| 7 | DAL | 25.50 | 23 | WAS | 21.25 |
| 8 | SEA | 24.00 | 24 | NO | 21.00 |
| 9 | JAX | 24.00 | 25 | NE | 20.50 |
| 10 | TB | 24.00 | 26 | TEN | 20.50 |
| 11 | CHI | 24.00 | 27 | DEN | 20.00 |
| 12 | MIN | 23.25 | 28 | ATL | 19.50 |
| 13 | NYG | 23.00 | 29 | MIA | 18.50 |
| 14 | HOU | 22.75 | 30 | NYJ | 18.00 |
| 15 | KC | 22.75 | 31 | ARI | 18.00 |
| 16 | SF | 22.50 | 32 | CLE | 16.50 |
Does the arithmetic match the books that post team totals?
Pinnacle and SBOBet publish their own team totals, which gives a free check on the derivation. Take each book’s own spread and total, derive its two team numbers, then compare against the line that same book posted.
TT1 = "Over Under Team 1 (incl. overtime)"
TT2 = "Over Under Team 2 (incl. overtime)"
def devig_two_way(over, under):
"""Proportional de-vig. Returns P(Over)."""
io, iu = 1 / over, 1 / under
return io / (io + iu)
# derived = implied_points() output for that same bookmaker
posted = min(team_total_lines, key=lambda l: abs(l - derived))
prices = team_total_lines[posted]
p_over = devig_two_way(prices["Over"], prices["Under"])
Across 64 pairs (16 games, two teams, two books) the derived number lands a mean of 0.62 points from the posted line, and never more than 1.75 away. Eleven pairs match exactly.
The gap is not error. It is the half point. Books post team totals on a half-point grid and use the price to say where inside the grid the true number sits, the same mechanism that makes NFL key numbers expensive to cross. Seattle is the clean example:
| Team | Book | Posted line | Over | Under | De-vigged P(Over) | Derived |
|---|---|---|---|---|---|---|
| SEA | pinnacle | 24.5 | 2.140 | 1.746 | 44.9% | 24.00 |
| NE | pinnacle | 19.5 | 1.793 | 2.080 | 53.7% | 20.50 |
| LA | pinnacle | 26.5 | 1.869 | 1.990 | 51.6% | 26.00 |
| SF | pinnacle | 23.5 | 2.110 | 1.769 | 45.6% | 22.50 |
Pinnacle posts Seattle at 24.5 and charges you 2.140 to take the over, so its real number is below 24.5. The derivation says 24.0. On New England it posts 19.5 and prices the over favourite at 1.793, so its real number is above 19.5, and the derivation says 20.5. The price skew leans the same way as the derived number in 48 of 64 pairs (75%). Read the posted line and the price together, or use the derivation and skip the ambiguity.
Does it matter which book you pull from?
I expected it to. It barely does. Deriving the home team’s points separately from each of the 12 books that quote both markets, the full range across books came to 0.5 points at the median and 1.5 points at the widest, on Green Bay at Minnesota.
NFL sides and totals are the most efficiently priced market in American sport, and by Week 1 the books have converged. Pull from whichever book answers, or take the consensus for tidiness. Do not build a book-selection layer for this; spend the effort on line shopping where the price differences are real and worth money.
What implied team points do not tell you
The number is a team scoring estimate, and fantasy scoring happens at player level. Two teams projected for 24 points distribute them differently: one throws three touchdowns, the other runs for two and kicks three field goals. Implied points sets the size of the pie and says nothing about the slices.
The feed has no scores, no snap counts and no depth charts, so it cannot close that gap for you. What it does carry is the other half of the pricing, in the player props markets. Anytime touchdown is market 14388 and first touchdown is 14390, both keyed by player ID rather than by the "0" key that game lines use. A receiving yards line tells you what the book expects from one player; the implied team total tells you the environment he is playing in. Use them together.
Second caveat: overtime. The market names all read “incl. overtime”, so the totals include it and your projections carry a small upward bias against regulation-only fantasy scoring. It is worth a fraction of a point and I would not adjust for it.
Watch the number move
Week 1 lines opened in late July. Every price change since is on the free tier through /historical-odds, which most providers charge for.
history = api_get("historical-odds", fixtureId=fixture_id, bookmakers="pinnacle")
# players["0"] is a LIST here, not a dict. Each entry is one snapshot.
for snap in history["bookmakers"]["pinnacle"]["markets"][market_id]["outcomes"][outcome_id]["players"]["0"]:
print(snap["createdAt"], snap["price"], snap["limit"])
The response shape differs from the live endpoint. The top-level key is bookmakers rather than bookmakerOdds, and players["0"] holds a list of snapshots instead of a single price. Cap the call at three bookmakers, and keep prediction markets out of a bulk pull. One Polymarket fixture returned 23.68 MB against 0.18 MB for the same fixture filtered to Pinnacle.
Rerun the derivation on each snapshot and you get the implied points curve from opening to kickoff. A total climbing two points while the spread holds means both teams gained; a spread moving with a static total means points shifted from one side to the other. That distinction is the one worth acting on in a Thursday lineup.
FAQ
What are implied team totals in fantasy football?
An implied team total is the number of points the betting market expects one team to score. Derive it from the game total and the spread: the favourite gets half the total plus half the spread, the underdog gets half the total minus half the spread. A 44.5 total with a 3.5 point favourite implies 24.0 and 20.5.
Is there a free API for NFL team totals?
Yes. The OddsPapi free tier returns spreads and totals for every priced NFL fixture, and a two line calculation converts them to team totals. Only Pinnacle and SBOBet post the market directly, so deriving it gives you 12 books instead of two.
Which market IDs are NFL spreads and totals?
There is no single ID. Each line has its own market ID, so a 3.5 point spread is 14272, a 4 point spread is 14270 and a 44.5 total is 1464. Resolve by marketName from /v4/markets?sportId=14 and pick the line the most bookmakers quote.
Can I trust the mainLine flag to find the headline number?
No. Across 111 NFL Week 1 cases where a bookmaker quoted more than one spread, the mainLine flag matched the market’s actual number 46 times. Kalshi flagged a wing rung on all 16 fixtures. Anchor on the books that quote a single line and snap the ladder books to that anchor.
How accurate is deriving a team total instead of reading it?
Measured against the two books that publish both, the derived number sat 0.62 points from the posted line on average across 64 pairs, and 11 pairs matched exactly. The remaining gap is the half-point grid, and the price on the posted line leans in the same direction as the derived number 75% of the time.
Do implied team totals work for DFS and start/sit calls?
They set the scoring environment, which is what game stacks and streaming decisions turn on. They say nothing about how a team splits its points between passing and rushing. Pair them with player prop markets for the player level view.
Get your key
Stop paying for a number you can compute. Grab a free OddsPapi key, run the loop above on Week 1, and you have the whole board in under a minute. The same code works on college football, and the NFL odds API guide covers the rest of the market menu.