NHL Odds API: 95 Books Price the First Period Before Puck Drop

NHL Odds API - first period markets from 95 bookmakers - OddsPapi
How To Guides September 29, 2026

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.