NHL Odds API: 95 Books Price the First Period Before Puck Drop
The NHL season opens on 29 September 2026, and Carolina Hurricanes v Florida Panthers carries 225 bookmakers eleven days out. Most developers pull the moneyline, the puck line and the total off that board and stop. They walk past 95 market IDs.
Those 95 IDs settle on a single period. First Period Result, Second Period Total, Third Period Handicap: a separate board with its own bookmakers, its own margins and its own clock. Every number below comes from a live call made on 18 September 2026 at 13:19 UTC.
The Period Board Is a Third of the Catalogue
One /v4/odds call on the opener returns 270 market IDs. Split them on the period field from /v4/markets and the board separates into five stacks.
period |
Settles on | Market IDs | Bookmakers | Prices | Live |
|---|---|---|---|---|---|
fulltime |
the score after 60 minutes | 97 | 209 | 9,642 | 65.9% |
result |
the final score, overtime and shootout included | 77 | 207 | 4,902 | 45.3% |
p1 |
the first period alone | 35 | 95 | 1,516 | 93.6% |
p2 |
the second period alone | 30 | 55 | 816 | 99.1% |
p3 |
the third period alone | 30 | 55 | 736 | 99.7% |
Read the Live column again. The two full-game stacks run 66% and 45% live. The three period stacks run 94%, 99% and 100%. The full-game side is mostly alternate-line ladder, 31 handicap rungs and 25 total rungs deep, and a book posts the whole ladder while quoting two or three rungs of it. The period board carries no deep ladder, so almost every rung on it is a price you can act on.
One more ID sits outside the five stacks. 151276 Will There Be Overtime ships with period: null, quoted by 60 books at 98.3% live. Guard your period lookup against a null.
Old Way vs OddsPapi
| Job | Scraping or a single-book feed | OddsPapi |
|---|---|---|
| Find every first-period market | Read one sportsbook’s markup and guess the rest | Filter 270 IDs on period == "p1" |
| Rank the first-period price | 2 or 3 books | 95 books on one fixture |
| Know which clock a market settles on | Parse the market’s display name | Read the period field |
| Grade it after the final horn | Scrape a scoreboard | /v4/settlements returns WIN, LOSE or PUSH per outcome |
| Price | Proxies, breakage, legal risk | Free tier, 250 requests a month, no card |
Step 1: Find the Fixtures
Ice hockey is sportId 15 and the NHL is tournamentId 234. Filter on both. The catalogue holds two extraliga tournaments, and /v4/fixtures returns HTTP 400 on a date range wider than ten days when sportId is your only filter.
import os, requests
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
r = requests.get(f"{BASE}/fixtures", params={
"apiKey": API_KEY,
"sportId": 15, # Ice Hockey
"tournamentId": 234, # NHL
"from": "2026-09-29",
"to": "2026-10-05",
})
r.raise_for_status()
fixtures = r.json()
for f in sorted(fixtures, key=lambda x: x["startTime"])[:5]:
print(f["fixtureId"], f["startTime"],
f["participant1Name"], "v", f["participant2Name"])
print(f"{len(fixtures)} fixtures, {sum(1 for f in fixtures if f['hasOdds'])} priced")
Opening week returns 38 fixtures and all 38 are priced:
id1500023472886402 2026-09-29T21:00:00.000Z Carolina Hurricanes v Florida Panthers
id1500023472886404 2026-09-29T23:00:00.000Z Toronto Maple Leafs v Montreal Canadiens
id1500023472886406 2026-09-30T00:00:00.000Z Boston Bruins v New York Rangers
id1500023472886408 2026-09-30T02:00:00.000Z Edmonton Oilers v Vancouver Canucks
id1500023472886410 2026-09-30T02:30:00.000Z Vegas Golden Knights v Chicago Blackhawks
38 fixtures, 38 priced
Step 2: Split the Board on the Period Field
/v4/markets ignores sportId and returns all 33,115 definitions, so pull it once and index it in memory. Check the status code before you read the payload. A 429 body is valid JSON, so a check on the payload alone reads a rate limit as an empty board. Give the helper a plain exponential backoff on 5xx too, and the same function serves every endpoint in this post.
import os, time, requests
from collections import defaultdict
API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
FIXTURE = "id1500023472886402" # Carolina Hurricanes v Florida Panthers
def call(path, **params):
"""Retry a 429 on the body's retryMs, and back off on a transient 5xx."""
params["apiKey"] = API_KEY
for attempt in range(5):
r = requests.get(f"{BASE}/{path}", params=params, timeout=300)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 0.5)
continue
if r.status_code >= 500:
time.sleep(2 ** attempt)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"{path} did not answer")
# /markets ignores sportId and returns the whole catalogue, so index it once.
MARKET = {str(m["marketId"]): m for m in call("markets")}
board = call("odds", fixtureId=FIXTURE)
stats = defaultdict(lambda: {"ids": set(), "books": set(), "prices": 0, "live": 0})
for slug, book in board["bookmakerOdds"].items():
for market_id, market in book["markets"].items():
row = stats[MARKET.get(market_id, {}).get("period")]
row["ids"].add(market_id)
row["books"].add(slug)
for outcome in market["outcomes"].values():
for price in outcome["players"].values():
row["prices"] += 1
row["live"] += bool(price["active"])
print(f"{len(board['bookmakerOdds'])} bookmakers on the fixture")
print(f"{'period':10} {'ids':>5} {'books':>6} {'prices':>7} {'live':>7}")
for period, row in sorted(stats.items(), key=lambda x: -x[1]["prices"]):
print(f"{str(period):10} {len(row['ids']):5} {len(row['books']):6} "
f"{row['prices']:7} {row['live'] / row['prices']:6.1%}")
That prints the table above. Do not resolve a period market by its name. First Period Total and Total both read as a totals market, they both carry a handicap of 1.5 or 6.5, and only the period field separates them. You hit the same problem on CS2, where Maps Handicap is 15 market IDs sharing one name.
Step 3: See Who Quotes Each Period
The three periods do not draw the same bookmakers. On the opener, 95 books quote at least one first-period market, 55 quote all three periods, and 40 quote the first period and nothing after it.
| Population | Books |
|---|---|
| Quote the first period | 95 |
| Quote all three periods | 55 |
| Quote the first period only | 40 |
| On the fixture at all | 225 |
Those 40 first-period-only books include bovada.lv, bodog.eu, hardrockbet, playnow, bet99, betmgm.bet.br and the Betano regional feeds. Build your bookmaker list per period, not per fixture. A list built off the moneyline board will send you looking for a third-period price that 170 of those books never posted.
Step 4: Rank the First-Period Board
Counting legs is the guard that matters here. A book that posts two legs of a three-way first-period market computes as a cheap quote and floats to the top of any margin ranking. Read marketLength off /v4/markets and drop anything that does not match.
legs_expected = MARKET["151278"]["marketLength"] # First Period Result, 3 legs
quotes = []
for slug, book in board["bookmakerOdds"].items():
market = book["markets"].get("151278")
if not market:
continue
legs = {}
for outcome_id, outcome in market["outcomes"].items():
for price in outcome["players"].values():
if not price["active"] or not price["price"]:
legs = {}
break
legs[outcome_id] = price["price"]
# A partial quote is not a cheap quote. Count the legs before you rank.
if len(legs) == legs_expected:
quotes.append((slug, sum(1 / p for p in legs.values()) - 1,
tuple(sorted(legs.items()))))
quotes.sort(key=lambda q: q[1])
tuples = {q[2] for q in quotes}
print(f"{len(quotes)} complete quotes, {len(tuples)} distinct price tuples "
f"({1 - len(tuples) / len(quotes):.1%} collapse)")
print(f"median margin {statistics.median(q[1] for q in quotes):.2%}")
for slug, margin, _ in quotes[:5]:
print(f" {slug:16} {margin:.2%}")
57 complete quotes, 15 distinct price tuples (73.7% collapse)
median margin 9.68%
inbet 4.76%
20bet 7.30%
ivibet 7.30%
stake 7.30%
tonybet 7.30%
Fifty-seven books, fifteen opinions. The dedupe collapse on First Period Result is 73.7%, and on First Period Total 1.5 it reaches 80.8%. Distinct slugs quote byte-identical prices, and cloneOf does not flag them, so collapse on the price tuple before you average anything. The same discipline runs through building a consensus line from 350+ bookmakers.
The period board costs more than the full game, and the gap is consistent across the three periods:
| Market | period |
Complete quotes | Median margin | Tightest |
|---|---|---|---|---|
| 151 Winner (incl. overtime and penalties) | result |
188 | 5.49% | duel 2.89% |
| 153 Regular Time Result | fulltime |
178 | 7.72% | 1xbet 3.12% |
| 151287 First Period Total 1.5 | p1 |
78 | 8.82% | caesars 3.95% |
| 151278 First Period Result | p1 |
57 | 9.68% | inbet 4.76% |
| 151644 Second Period Result | p2 |
44 | 9.87% | inbet 4.07% |
| 152010 Third Period Result | p3 |
44 | 9.95% | inbet 4.15% |
A first-period three-way costs about two points of margin over the same fixture’s regular-time three-way and four over the two-way. The spread inside each market is wider still: caesars and the two William Hill feeds quote First Period Total at 3.95% against a 8.82% board median. Shop it. Our line-shopping walkthrough runs the same comparison across the full catalogue.
Step 5: Read the Clock Right
/v4/scores returns a periods object, and the keys inside it do not all mean the same kind of number. p1, p2 and p3 are per-period deltas. fulltime, overtime and result are running totals.
def line(periods, key):
p = periods.get(key)
return None if p is None else (p["participant1Score"], p["participant2Score"])
played = call("fixtures", sportId=15, tournamentId=234,
**{"from": "2026-04-05", "to": "2026-04-13"})
regulation = overtime_goal = shootout = mismatch = counted = 0
for fixture in played:
periods = call("scores", fixtureId=fixture["fixtureId"])["scores"]["periods"]
time.sleep(1.15)
if not all(k in periods for k in ("p1", "p2", "p3", "fulltime", "result")):
continue
counted += 1
# p1, p2 and p3 are per-period deltas. fulltime is the running regulation score.
summed = tuple(sum(periods[k][f"participant{n}Score"] for k in ("p1", "p2", "p3"))
for n in (1, 2))
if summed != line(periods, "fulltime"):
mismatch += 1
if line(periods, "result") == line(periods, "fulltime"):
regulation += 1
elif line(periods, "overtime") == line(periods, "result"):
overtime_goal += 1 # someone scored in the extra period
else:
shootout += 1 # overtime stayed level, the shootout decided it
print(f"{counted} played fixtures, {mismatch} where p1+p2+p3 != fulltime")
print(f"regulation {regulation}, overtime goal {overtime_goal}, shootout {shootout}")
print(f"past regulation {(overtime_goal + shootout) / counted:.1%}")
63 played fixtures, 0 where p1+p2+p3 != fulltime
regulation 51, overtime goal 7, shootout 5
past regulation 19.0%
Across 63 played NHL fixtures from 5 to 12 April 2026 the three period deltas summed to the regulation score every single time. That gives you two free consistency checks. Sum the periods and compare against fulltime before you trust a scoreline. Then diff result against overtime: when they match, somebody scored in the extra period; when overtime still sits level and result moved, the shootout decided it. Twelve of the 63 went past regulation, seven on an overtime goal and five in a shootout.
Step 6: Prove the Grading
Take a played fixture and read the period scores next to the settled grid. This is id1500023462079028 from 5 April 2026.
MARKET = {str(m["marketId"]): m for m in call("markets")}
periods = call("scores", fixtureId="id1500023462079028")["scores"]["periods"]
for key in ("p1", "p2", "p3", "fulltime", "result"):
score = periods[key]
print(f" {key:9} {score['participant1Score']}-{score['participant2Score']}")
graded = call("settlements", fixtureId="id1500023462079028")["markets"]
for market_id in ("151278", "151287", "151644", "152010", "153", "151"):
meta = MARKET[market_id]
results = {
out["outcomeName"]:
list(graded[market_id]["outcomes"][str(out["outcomeId"])]["players"].values())[0]["result"]
for out in meta["outcomes"]
}
print(f"{market_id:8} {meta['period']:9} {meta['marketName'][:22]:22} {results}")
p1 1-1
p2 0-3
p3 2-1
fulltime 3-5
result 3-5
151278 p1 First Period Result {'2': 'LOSE', 'X': 'WIN', '1': 'LOSE'}
151287 p1 First Period Total {'Over': 'WIN', 'Under': 'LOSE'}
151644 p2 Second Period Result {'X': 'LOSE', '1': 'LOSE', '2': 'WIN'}
152010 p3 Third Period Result {'X': 'LOSE', '2': 'LOSE', '1': 'WIN'}
153 fulltime Regular Time Result {'1': 'LOSE', '2': 'WIN', 'X': 'LOSE'}
151 result Winner (incl. overtime {'1': 'LOSE', '2': 'WIN'}
Every grade lands on its own period. The first period finished 1-1, so First Period Result pays the draw and First Period Total 1.5 pays the over on two goals. The second period went 0-3, so the away leg wins there, while the home leg wins the third on 2-1. Regular Time Result reads the 3-5 aggregate and Winner reads the same. Five clocks on one fixture, and four of them disagree with the scoreline you would print on a scoreboard.
The grid holds 634 markets and returns 995 decided outcomes on this fixture. The period markets account for 540 of them, 180 on each of the three periods, against 228 on fulltime and 227 on result. More than half the graded surface of an NHL fixture sits on the period board.
Where the Sharp Anchor Is
Eleven days out, pinnacle and kalshi carry no price on the opener. Pass bookmakers=pinnacle on its own and you get HTTP 200 with the slug missing from bookmakerOdds, which means unpriced rather than gated; a slug that does not exist returns HTTP 400 instead. betfair-ex and polymarket are on the board but quote full-game markets only, and neither posts a period market. draftkings is the one major that does.
So de-vig the deduped consensus rather than leaning on a sharp reference that has not opened yet. Fifteen distinct tuples across 57 books is a thin sample, and it gets thicker every day as the season approaches. Re-measure on the night.
Where This Sits Next to the Other Two NHL Traps
Hockey holds three market-ID traps. The period board is one. The 118 twin markets are the second: every full-game handicap and total ships twice, once settling on regulation and once on the final score, with the same handicap value on both. For the third, learn the menu from the puck lines and totals guide.
Read together, the rule is one line: in ice hockey, period is the primary key and marketName is a label. Basketball splits its result the same way across two market IDs, and you guard it with the same flags you use on the NBA opening-night board.
Going Live
Poll /v4/odds at 10 requests a second on the odds endpoints and 200 a minute elsewhere, or take the WebSocket feed and stop polling. Between periods the first-period board closes and the second-period board opens, so a scheduler that refreshes on a fixed timer will hold a dead price for a full intermission. Track changedAt per price and treat a market whose changedAt has fallen behind the book’s newest stamp on the fixture as stale.
One warning for anyone storing this. A book that withdraws a market keeps its last price with active: true. The flag tells you the book has not switched the rung off, not that the number is current. Compare timestamps.
Get Your Key
The free tier runs 250 requests a month across every sport and every bookmaker, with no card and no sales call. That covers a whole opening week of NHL period boards. Sign up, export the key, and run Step 1 above.
Historical odds come free as well, so the 63-fixture clock census in Step 5 costs nothing to reproduce on last season’s schedule. If you want the board pushed rather than polled, the WebSocket feed carries the same 225-book payload with resume cursors. The free-tier walkthrough has the setup.
FAQ
How do I tell a first-period market from a full-game market?
Read the period field on /v4/markets. It returns p1, p2 or p3 for a single period, fulltime for the 60-minute score and result for the final score. The market name will not tell you, because First Period Total and Total both read as a totals market and can carry the same handicap.
How many bookmakers price NHL period markets?
On Carolina v Florida, measured 18 September 2026 at T-11d, 95 of the 225 bookmakers on the fixture quoted at least one first-period market and 55 quoted all three periods. Forty of them quoted the first period and no other, including bovada.lv, hardrockbet and bet99.
Do the period scores add up to the full-time score?
Yes. Across 63 played NHL fixtures from 5 to 12 April 2026, p1 plus p2 plus p3 equalled the fulltime line on all 63. The period keys are deltas; fulltime, overtime and result are running totals.
How do I detect a shootout from the API?
Diff result against overtime in the /v4/scores periods object. If they match, a goal in the extra period settled it. If overtime still equals fulltime while result differs, the shootout decided it. Twelve of 63 April fixtures went past regulation, split seven and five.
What does a first-period market cost in margin?
On the opening-night board, First Period Result ran a 9.68% median margin across 57 complete quotes against 7.72% on Regular Time Result and 5.49% on the two-way Winner. Inside the first-period board the spread is wide: caesars quoted First Period Total 1.5 at 3.95% against a board median of 8.82%.
Is the NHL period board available on the free tier?
Yes. Every market ID on a fixture comes back in one /v4/odds call, period markets included, and the free tier allows 250 requests a month with no card. Historical odds and settlements are on the same key.