NHL Odds API: Grade 118 Twin Markets on the 60-Minute Clock

NHL odds API regulation and overtime market IDs
How To Guides September 23, 2026

You asked the NHL odds API for the −1.5 puck line, got 139 bookmakers back, and the book you wanted was missing. It is on the board, under a different market ID.

Ice hockey prices the same bet twice. Once for the 60 minutes of regulation, once for the final score after overtime and the shootout. The two versions carry different market IDs, and the bookmakers split across them. On the 29 September opener between the Carolina Hurricanes and the Florida Panthers, measured 14 September 2026 at 13:24 UTC, 139 books posted the regulation −1.5 and 118 posted the overtime −1.5. Only 76 posted both. Read one ID and you see two thirds of the board.

This post maps the 118 twin markets in the ice-hockey catalogue, shows which flag tells them apart, and proves with settlement data that the two sides of a pair grade to opposite results.

Why One Market ID Per Bet Fails on Hockey

In soccer, the 1X2 is market 101 and that is the end of it. In hockey, the catalogue holds 745 market definitions for sport ID 15, and 118 of them exist as a matched pair. Handicap and Handicap (incl. overtime and penalties). Total and Total (incl. overtime and penalties). Both sides of every pair carry the same handicap value, so grouping on the line number merges two different bets into one.

The period field on /v4/markets separates them:

period Settles on Definitions Example
fulltime the score after 60 minutes 127 153 Regular Time Result
result the final score, overtime and shootout included 137 151 Winner (incl. overtime and penalties)
fulltime+overtime overtime, no shootout 97 152381 Player Goals (incl. overtime)
p1 / p2 / p3 one period 383 151278 First Period Result

All 97 definitions under fulltime+overtime are player props. The game markets use fulltime and result.

The pair at the top of the board shows what this costs you. Market 151 is the two-way moneyline most people mean by “NHL moneyline”, and it settles on the final score. Market 153 is Regular Time Result, a three-way with a live draw leg, and it settles at the end of the third period. On the opener, 198 bookmakers posted 151 and 196 posted 153.

The usual approach OddsPapi
Puck line one endpoint, one line, one book two market IDs, 181 books on the opener
Regulation vs overtime read the book’s rules page period field on every market definition
Grading rebuild it from a score feed /v4/settlements returns WIN, LOSE or PUSH per outcome
History paid add-on free on every plan, both sides of the pair
Catalogue ~40 books 350+ feeds, 359 slugs verified 14 September 2026
Market shapes moneyline, spread, total native handicap and total ladders, both clocks, player props

Step 1: Find the Tournament and the Board

Auth is a query parameter. No headers, no OAuth dance.

import os, time, requests

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE_URL = "https://api.oddspapi.io/v4"


def get(path, **params):
    """GET with a back-off. The odds endpoints allow 10 requests a second."""
    for attempt in range(5):
        r = requests.get(f"{BASE_URL}/{path}", params={"apiKey": API_KEY, **params})
        if r.status_code == 200:
            return r.json()
        time.sleep(1.5 * (attempt + 1))
    r.raise_for_status()


sports = get("sports")
hockey = next(s for s in sports if s["sportName"] == "Ice Hockey")
print("sportId", hockey["sportId"])

tournaments = get("tournaments", sportId=hockey["sportId"])
nhl = next(t for t in tournaments
           if t["tournamentSlug"] == "nhl" and t["categorySlug"] == "usa")
print("tournamentId", nhl["tournamentId"])

fixtures = get("fixtures", sportId=hockey["sportId"],
               tournamentId=nhl["tournamentId"],
               **{"from": "2026-09-14", "to": "2026-10-20"})
priced = [f for f in fixtures if f["hasOdds"]]
print(f"{len(priced)} of {len(fixtures)} fixtures carry a board")
print("board runs", priced[0]["startTime"][:10], "to", priced[-1]["startTime"][:10])
sportId 15
tournamentId 234
82 of 139 fixtures carry a board
board runs 2026-09-29 to 2026-10-11

Ice hockey is sport ID 15 and the NHL is tournament ID 234. Filter on categorySlug as well as the slug: the catalogue holds two tournaments named extraliga, one Czech and one Slovak.

82 of 139 fixtures carried a board on 14 September. The priced window opens on the 29 September season opener and ends 11 October, 27 days out, and the books extend it as the season runs. Check hasOdds before you queue a fixture for pricing, and remember that the flag says a board exists, not how deep it goes.

Step 2: Build the Twin Map

/v4/markets ignores the sportId parameter and returns all 33,115 definitions, so filter client-side.

from collections import defaultdict

SUFFIX = " (incl. overtime and penalties)"

catalogue = [m for m in get("markets") if m["sportId"] == 15]
print(f"{len(catalogue)} ice-hockey market definitions")

pairs = defaultdict(dict)
for m in catalogue:
    name = m["marketName"]
    base = name[:-len(SUFFIX)] if name.endswith(SUFFIX) else name
    clock = "overtime" if name.endswith(SUFFIX) else "regulation"
    pairs[(base, m["handicap"])][clock] = m["marketId"]

twins = {k: v for k, v in pairs.items() if len(v) == 2}
print(f"{len(twins)} markets ship as a regulation / overtime pair")

for key in [("Handicap", -1.5), ("Total", 6.5), ("Team 1 Total", 2.5)]:
    print(f"  {key[0]} {key[1]:>5}  "
          f"regulation={twins[key]['regulation']:<6} overtime={twins[key]['overtime']}")
745 ice-hockey market definitions
118 markets ship as a regulation / overtime pair
  Handicap  -1.5  regulation=1580   overtime=15228
  Total   6.5  regulation=1530   overtime=15178
  Team 1 Total   2.5  regulation=15274  overtime=15342

Eighteen base markets ship in pairs: Handicap, Total, European Handicap, Team 1 Total, Team 2 Total, Exact Goals, Correct Score, Odd Even, Both Teams To Score, First Goal, Last Goal, Winning Margin, the two Win To Nil markets, the two To Score markets and the two Team Exact Goals markets. Cache the map once per season and key your reader on period rather than on the market name, which spells the suffix out in full every time.

Step 3: Read Both Sides of the Puck Line

Count what one ID costs you on the opener between Carolina and Florida.

FIXTURE = "id1500023472886402"          # Carolina Hurricanes v Florida Panthers
PUCK_LINE = {"regulation": "1580", "overtime": "15228"}   # both are -1.5

board = get("odds", fixtureId=FIXTURE)["bookmakerOdds"]
print(f"{len(board)} bookmakers on the board")

posted = {"regulation": set(), "overtime": set()}
for slug, book in board.items():
    if book.get("suspended"):
        continue
    for clock, market_id in PUCK_LINE.items():
        if market_id in book["markets"]:
            posted[clock].add(slug)

union = posted["regulation"] | posted["overtime"]
print(f"regulation -1.5 (market 1580): {len(posted['regulation'])} books")
print(f"overtime   -1.5 (market 15228): {len(posted['overtime'])} books")
print(f"read both and you cover {len(union)} books, "
      f"{len(posted['regulation'] & posted['overtime'])} post both")

for slug in ["draftkings", "fanduel", "betmgm", "caesars", "bovada.lv", "stake"]:
    print(f"  {slug:<12} {[c for c in PUCK_LINE if slug in posted[c]]}")
220 bookmakers on the board
regulation -1.5 (market 1580): 139 books
overtime   -1.5 (market 15228): 118 books
read both and you cover 181 books, 76 post both
  draftkings   ['regulation']
  fanduel      ['regulation']
  betmgm       ['regulation']
  caesars      ['overtime']
  bovada.lv    ['overtime']
  stake        ['overtime']

DraftKings, FanDuel and BetMGM land on the regulation ID. Caesars, Bovada and Stake land on the overtime ID. bet365 and BetRivers post both. The 6.5 total splits the other way: 99 books on the regulation version, 119 on the overtime version, 185 across the two, and 33 posting both.

The prices do not tell you which is which. Among the books that post both sides of the −1.5, most copy one price into both IDs, so a price-level dedupe leaves the pair intact and your reader still has to branch on period. Pair this with the line shopping walkthrough and take the union before you rank anything.

Step 4: Rank the Board With the Liveness Flags On

A board opened a fortnight early carries a lot of parked prices. Three flags gate a quote, and the third one moves the leaderboard: suspended on the bookmaker, active on the price, and marketActive on the market row.

import statistics
from collections import Counter


def quotes(board, market_id, legs, guarded=True):
    """One price tuple per bookmaker, with every liveness flag applied."""
    out = {}
    for slug, book in board.items():
        if guarded and (book.get("suspended") or book.get("bookmakerIsActive") is False):
            continue
        market = book["markets"].get(market_id)
        if not market:
            continue
        if guarded and market.get("marketActive") is False:
            continue
        prices = []
        for outcome in market["outcomes"].values():
            price = outcome["players"].get("0")
            if not price or not price["price"] or (guarded and not price["active"]):
                prices = []
                break
            prices.append(price["price"])
        if len(prices) == legs:
            out[slug] = tuple(sorted(prices))
    return out


def margin(prices):
    return (sum(1 / p for p in prices) - 1) * 100


for market_id, legs, label in [("151", 2, "151 Winner, incl. overtime"),
                               ("153", 3, "153 Regular Time Result")]:
    loose = quotes(board, market_id, legs, guarded=False)
    tight = quotes(board, market_id, legs, guarded=True)
    tuples = Counter(tight.values())
    print(f"\n{label}")
    print(f"  {len(loose)} unguarded quotes, {len(tight)} guarded")
    print(f"  dedupe: {len(tight)} -> {len(tuples)} distinct prices "
          f"({(1 - len(tuples) / len(tight)) * 100:.1f}% collapse)")
    print(f"  median margin {statistics.median(map(margin, loose.values())):.2f}% unguarded, "
          f"{statistics.median(map(margin, tight.values())):.2f}% guarded")
    top = sorted(tight.items(), key=lambda kv: margin(kv[1]))[:3]
    print("  tightest:", [(s, f"{margin(p):.2f}%") for s, p in top])
151 Winner, incl. overtime
  198 unguarded quotes, 123 guarded
  dedupe: 123 -> 29 distinct prices (76.4% collapse)
  median margin 5.56% unguarded, 6.75% guarded
  tightest: [('duel', '2.89%'), ('betmgm', '4.12%'), ('borgata', '4.12%')]

153 Regular Time Result
  180 unguarded quotes, 86 guarded
  dedupe: 86 -> 20 distinct prices (76.7% collapse)
  median margin 7.52% unguarded, 6.33% guarded
  tightest: [('duel', '4.50%'), ('atg.se', '4.96%'), ('ballybet', '4.96%')]

Three things to take from that run. The guard drops market 151 from 198 quotes to 123, matching what the NBA opening-night board did on a board opened six weeks out. Without the guard, 1xBet tops market 151 at 3.11% on a price sitting inside a market row flagged marketActive: false; with it, Duel leads at 2.89%. And 123 quotes collapse to 29 distinct price tuples, with 28 slugs sharing 1.81 / 2.02, so dedupe on the tuple before you call anything a consensus. The consensus calculator does that step for you.

The guard also moves the two markets relative to each other. Unguarded, the three-way reads 1.96 points wider than the two-way, which is what you expect from an extra leg. Guarded, the order reverses and 153 lands at 6.33% against 6.75% on 151, because the guard removes a different slice of books from each market. Rank the two separately. The vig calculator covers the leg-count guard that keeps an incomplete three-way off the top of your table.

Pinnacle and Kalshi post no price on this fixture. Pass bookmakers=pinnacle and the call returns HTTP 200 with the slug absent from bookmakerOdds, which is the feed telling you the book has not opened the fixture. A slug that does not exist returns HTTP 400 with INVALID_PARAMETER instead, so the two cases never look alike.

Step 5: Prove the Two Clocks With Settlement Data

Take the Philadelphia Flyers against the Boston Bruins on 5 April 2026. The fixture finished 1-1 after 60 minutes and 2-1 to Philadelphia in overtime. /v4/scores returns both scores under separate period keys, and /v4/settlements grades every outcome.

FIXTURE = "id1500023462079036"   # Philadelphia Flyers v Boston Bruins, 5 Apr 2026

periods = get("scores", fixtureId=FIXTURE)["scores"]["periods"]
reg, final = periods["fulltime"], periods["result"]
print(f"after 60 minutes {reg['participant1Score']}-{reg['participant2Score']}"
      f" | final {final['participant1Score']}-{final['participant2Score']}")

grid = get("settlements", fixtureId=FIXTURE)["markets"]

LABELS = {
    "151":   ("Winner (incl. overtime and penalties)", {"151": "home", "152": "away"}),
    "153":   ("Regular Time Result", {"153": "home", "154": "draw", "155": "away"}),
    "1584":  ("Handicap -0.5, regulation", {"1584": "home -0.5", "1585": "away +0.5"}),
    "15232": ("Handicap -0.5, incl. overtime", {"15232": "home -0.5", "15233": "away +0.5"}),
    "1514":  ("Total 2.5, regulation", {"1514": "over", "1515": "under"}),
    "15162": ("Total 2.5, incl. overtime", {"15162": "over", "15163": "under"}),
}
for market_id, (name, legs) in LABELS.items():
    outcomes = grid[market_id]["outcomes"]
    graded = {legs[o]: outcomes[o]["players"]["0"]["result"] for o in legs if o in outcomes}
    print(f"  {market_id:<6} {name:<34} {graded}")
after 60 minutes 1-1 | final 2-1
  151    Winner (incl. overtime and penalties) {'home': 'WIN', 'away': 'LOSE'}
  153    Regular Time Result                {'home': 'LOSE', 'draw': 'WIN', 'away': 'LOSE'}
  1584   Handicap -0.5, regulation          {'home -0.5': 'LOSE', 'away +0.5': 'WIN'}
  15232  Handicap -0.5, incl. overtime      {'home -0.5': 'WIN', 'away +0.5': 'LOSE'}
  1514   Total 2.5, regulation              {'over': 'LOSE', 'under': 'WIN'}
  15162  Total 2.5, incl. overtime          {'over': 'WIN', 'under': 'LOSE'}

Back Philadelphia on market 151 and you win. Back Philadelphia on market 153 and you lose to a draw leg, because the fixture was level when the third period ended. The same inversion runs through the −0.5 handicap and the 2.5 total, on one fixture, for one participant.

Run the comparison across the whole twin map and the pattern holds. On nine fixtures that went past regulation between 5 and 12 April 2026, 105 twin pairs graded on both IDs and exactly 11 pairs flipped on every one. On six fixtures decided in regulation, all 105 pairs agreed and nothing flipped.

Fixture After 60 Final Pairs graded Pairs flipped
SEA v CHI 2-4 2-4 105 0
SJ v NSH 3-6 3-6 105 0
EDM v VGK 1-5 1-5 105 0
PHI v BOS 1-1 2-1 105 11
LA v NSH 2-2 3-2 105 11
DET v CBJ 3-3 3-4 105 11
COL v VGK 2-2 2-3 105 11

Across 166 played NHL fixtures between 20 March and 12 April 2026, 32 went past regulation. At 19.3%, a grader that reads the wrong ID agrees with the book four times in five, then pays the wrong side on the fifth. That is the worst failure rate to debug, because the first 80% of your test set passes.

The Twin Split Reaches Into History Too

Historical odds are free on every plan, and the split survives into the archive. On that Philadelphia fixture, /v4/historical-odds returns 93 market IDs for Pinnacle, and Pinnacle holds both puck lines: 179 snapshots on the regulation −1.5 and 322 on the overtime −1.5. DraftKings holds 14 market IDs, of which 13 sit under fulltime and one under result. Caesars holds 52, split 5 regulation and 19 overtime plus the period markets. If you backtest a hockey model against one bookmaker’s archive, the ID it favours decides which clock your model learned.

Pinnacle’s market 151 on that fixture opened at 1.724 / 2.22, nineteen hours before the puck dropped, and its last active snapshot read 1.806 / 2.06. The margin widened from 3.05% to 3.91% over that run while the home-leg limit climbed from 3,453 to 5,583. Its regulation −1.5 carried no snapshot until 19:48:06, the minute play started, while the overtime −1.5 had been running since the night before. The backtesting guide walks through pulling a full archive, and the 176-book study covers how many feeds a backtest needs before the numbers settle.

Where This Fits Next to the Other Market-ID Traps

Three sports in this corpus ship one bet under several market IDs, and the fix differs each time.

Sport Shape What to do
Boxing three IDs, one bet merge them
Aussie Rules two IDs, one result read both, then merge
CS2 15 IDs, one ladder split on the handicap field
EuroLeague two IDs, two bets keep them apart
NHL 118 pairs, two clocks branch on period, never merge

Hockey is the strictest of the five. Merging a pair here changes what the bet settles on, so treat the twin map as a branch, not a lookup.

Going Live

Polling a 220-book board every few seconds burns your request budget for nothing. The WebSocket feed pushes each price change as it lands, and the same market IDs come down the socket, so the twin map you cached in step 2 still resolves in-play. For the broader NHL menu, including player props and the period markets, start with the NHL odds API guide.

Every number above came off the live API on 14 September 2026. Re-measure before you trust any of it: the board grows week by week through October, and the bookmaker split moves with it.

Get Your Key

Stop reading bookmaker rules pages to work out which clock a line settles on. The period field says it, on all 745 ice-hockey market definitions, across a catalogue of 350+ bookmaker feeds that carries native handicap and total ladders rather than a flattened spread. Historical odds come with the free tier, and the WebSocket feed carries the same market IDs. Grab a free API key and run the twin map against tonight’s board.

FAQ

What is the difference between market 151 and market 153 in the NHL odds API?

Market 151 is Winner (incl. overtime and penalties), a two-way moneyline that settles on the final score. Market 153 is Regular Time Result, a three-way with a draw leg that settles on the score after 60 minutes. On the 29 September opener, 198 bookmakers posted 151 and 196 posted 153.

How do I tell a regulation market from an overtime market?

Read the period field on the market definition from /v4/markets. fulltime means the bet settles on the 60-minute score, result means it settles on the final score including overtime and the shootout, and fulltime+overtime covers the player props that include overtime but not the shootout.

How many NHL bookmakers does OddsPapi carry?

The catalogue held 359 bookmaker slugs on 14 September 2026, 284 of them flagged for live odds. The Carolina against Florida opener carried 220 of them, with 181 posting a −1.5 puck line once you read both market IDs.

Does the OddsPapi API grade hockey bets?

/v4/settlements grades every outcome on a finished fixture, and it grades both sides of every twin pair. The April 2026 Philadelphia fixture returned a 634-market grid with 471 WIN, 477 LOSE, 47 PUSH and 445 UNDECIDED. /v4/scores returns the regulation score under fulltime and the final score under result, so you can check the grade yourself.

How often does an NHL fixture go past regulation?

32 of 166 played NHL fixtures between 20 March and 12 April 2026 finished after overtime or a shootout, which is 19.3%. On each of those fixtures, 11 of the 105 twin pairs that graded on both market IDs returned opposite results.

Is historical NHL odds data free?

Yes. /v4/historical-odds is on the free tier, and it keeps both sides of each twin pair. On a played April 2026 fixture, Pinnacle returned 93 market IDs with 179 snapshots on the regulation puck line and 322 on the overtime version.