Table Tennis Odds API: Time Your Poll to the 125-Book Window

Table Tennis Odds API header
How To Guides October 29, 2026

If you search for a table tennis odds API, you want Winner prices on a lot of matches, from a lot of books, as JSON. Table tennis delivers the volume. Between 23 and 25 October 2026, OddsPapi recorded 647 to 675 finished matches a day across Czech Liga Pro, TT Cup and TT Elite Series. The median match lasted 16 minutes.

The depth arrives late. On 26 October at 13:32 UTC we read every one of the 385 priced fixtures in the next 36 hours. A Czech Liga Pro board held a median 7 books at 24 to 36 hours out, 42 books a day out, and 122 to 125 books inside the last four hours. If you poll a match at the wrong hour, you read a fraction of the market. This guide shows you when to poll, which book to anchor on, and how to settle a match from the period scores.

Why a scraper loses the table tennis board

A table tennis scraper has two problems. The first is volume: about 650 matches a day, 24 hours a day, and a new Czech Liga Pro fixture every few minutes. The second is timing: the books that make the board deep post their prices in steps, and the last step lands a few hours before the first serve. A scraper that visits each sportsbook page once a day sees the 7-book board and misses the 125-book board.

OddsPapi lists every scheduled match as a fixture and attaches every book that quotes it: 300+ bookmakers in the catalogue, 178 of them on table tennis in our census, under one schema. You poll one fixture ID and read the whole board. You read the price history of any played match from /v4/historical-odds, free on every plan, and the game-by-game score from /v4/scores.

Scrape the sportsbooks OddsPapi
Matches The pages you can reach before the match starts About 650 fixtures a day from /v4/fixtures, sportId 25
Books per match The sites you scrape Up to 129 on one call, deduped to about 20 distinct price pairs in Step 3
When the board fills Unknown until you watch it Book count per fixture on every poll, first snapshot per book in the history
Closing prices Your own archive Last active price before the start, any played match
Results A second data source Points per game and games won on /v4/scores

When the table tennis board fills

The table below comes from the 385-fixture census on 26 October, 13:32 to 13:40 UTC. Each row groups fixtures by the hours left before the scheduled start. Cancelled fixtures are excluded.

Tournament Hours to start Fixtures Median books Range
Czech Liga Pro 24 to 36 66 7 6 to 42
Czech Liga Pro 16 to 24 57 42 19 to 43
Czech Liga Pro 8 to 16 30 75 68 to 78
Czech Liga Pro 4 to 8 30 76 73 to 79
Czech Liga Pro 2 to 4 20 122 69 to 129
Czech Liga Pro 1 to 2 10 125 70 to 129
TT Cup 4 to 8 36 96.5 26 to 112
TT Cup 2 to 4 23 110 2 to 119
TT Cup 0 to 1 4 121 103 to 125
TT Elite Series 0 to 1 14 30.5 17 to 50
WTT Champions Montpellier (men and women) 16 to 36 13 61 50 to 67

Czech Liga Pro fills in four steps: about 7 books at a day and a half, 42 at a day, 75 overnight, then the jump to 120+ inside four hours. The price history agrees. On the played matches in Step 4, the first SBOBet snapshot on a Czech Liga Pro match landed a median 21.9 hours before the start, and on a TT Cup match 4.0 hours before. TT Elite Series never passed 50 books in the census. The WTT Champions event in Montpellier held 50 to 67 books a day ahead, and Polymarket quoted all 13 of its fixtures in both draws.

The practical rule: discover fixtures a day ahead, and take the price you plan to use inside the last four hours. All 59 boards with 100 or more books sat in Czech Liga Pro or TT Cup.

Which book to anchor on: SBOBet

On the 59 deep boards, the median board carried 118 books and 117 usable Winner quotes. Those 117 quotes collapsed to a median 20 distinct price pairs, because skins of one platform repeat the same pair. Dedupe before you count books or average prices.

SBOBet sat on all 59 deep boards and quoted a usable Winner pair on 55. On all 55 it was the tightest pair on the board. Its median margin was 5.03%, against a median 9.15% for the whole board on the same fixtures. Pinnacle and the Betfair exchange did not quote any of the 385 fixtures in the census, so SBOBet and a deduped consensus are your two reference prices here. Our SBOBet API guide covers the book on soccer, where it trades the handicap and parks the three-way.

The SBOBet edge belongs to the Czech circuits. On TT Elite Series and the WTT Champions boards, SBOBet quoted 9% to 12%, close to the board median. Rank books per tournament.

Closing prices show the same order. On played matches from 24 and 25 October, we took each book’s last active Winner pair before the start from /v4/historical-odds:

Tournament SBOBet close 1xBet close bet365 close
Czech Liga Pro (14 to 16 matches) 4.97% 6.40% 9.66%
TT Cup (10 to 11 matches) 4.98% 7.94% 9.23%

Those are small samples. Use them to choose an anchor, and measure your own tournament before you size a bet.

Table tennis markets in the API

Table tennis is sportId 25. /v4/markets?sportId=25 returned 161 market definitions on 26 October, in three families, all settled on the whole match (period: "result"):

  • Winner: market 251, outcomes 251 (participant 1) and 252 (participant 2). The same sportId * 10 + 1 pattern as the moneyline across 59 sports.
  • Total Points: 101 market IDs, one per line, from 40.0 to 90.0. The main line on a Czech Liga Pro board sat at 75.5 to 77.5.
  • Point Handicap: 59 market IDs, one per line, from -14.5 to +14.5.

Because each line has its own market ID, you map IDs to lines by name before you read the board. Step 4 does that.

Step 1: Find the circuits and the daily volume

Get a free key from the sign-up page and export it as ODDSPAPI_API_KEY. The key goes in the apiKey query parameter, not a header. This script counts finished matches per day and lists the tournaments with priced fixtures ahead.

import os, time, requests
from collections import Counter

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

def get(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(5):
        r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
        if r.status_code == 429:
            time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
            continue
        if r.status_code == 404:          # empty window, not an error
            return []
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"{path} kept returning 429")

# Table tennis is sportId 25. `to` is a midnight-UTC instant: one day past the end.
played = get("fixtures", sportId=25, **{"from": "2026-10-23", "to": "2026-10-26"})
finished = [f for f in played
            if f["statusName"] == "Finished" and f["startTime"] < "2026-10-26"]
per_day = Counter(f["startTime"][:10] for f in finished)
print("finished per day:", dict(sorted(per_day.items())))

upcoming = get("fixtures", sportId=25, **{"from": "2026-10-26", "to": "2026-10-29"})
priced = Counter((f["tournamentId"], f["tournamentName"]) for f in upcoming if f["hasOdds"])
print(f"\n{sum(priced.values())} upcoming fixtures with hasOdds, by tournament:")
for (tid, name), n in priced.most_common():
    print(f"  {tid:>6}  {name:<36} {n}")

Output on 26 October 2026:

finished per day: {'2026-10-23': 675, '2026-10-24': 661, '2026-10-25': 647}

427 upcoming fixtures with hasOdds, by tournament:
   36349  Czech Liga Pro                       231
   36377  TT Elite Series                      89
   32129  TT Cup                               77
   44707  WTT Champions Montpellier, Women     13
   44705  WTT Champions Montpellier            13
   14107  Challenger Series                    4

Keep the tournament IDs. The next two steps filter /v4/fixtures with tournamentId, which cuts the payload and lets you query more than ten days at a time.

Step 2: Measure the board-fill clock

This script picks one Czech Liga Pro fixture near each horizon from 30 minutes to 30 hours and counts the books on it. Drop cancelled fixtures first: a cancelled fixture keeps hasOdds: true and a thin board, and on our first run two of them read as 2-book and 17-book boards inside four hours.

import os, time, requests
from datetime import datetime, timezone

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

def get(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(5):
        r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
        if r.status_code == 429:
            time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"{path} kept returning 429")

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

now = datetime.now(timezone.utc)
fixtures = get("fixtures", tournamentId=CZECH_LIGA_PRO, **{"from": "2026-10-26", "to": "2026-10-29"})
# A cancelled fixture keeps hasOdds: true and a thin board, so drop it by status.
ahead = sorted((f for f in fixtures if f["statusName"] != "Cancelled" and ts(f["startTime"]) > now),
               key=lambda f: f["startTime"])

# One fixture per horizon: how many books hold a price N hours before the first serve?
for target in (0.5, 2, 4, 8, 12, 18, 24, 30):
    f = min(ahead, key=lambda f: abs((ts(f["startTime"]) - now).total_seconds() / 3600 - target))
    board = get("odds", fixtureId=f["fixtureId"])
    hours = (ts(f["startTime"]) - now).total_seconds() / 3600
    print(f"{hours:5.1f} h out  {len(board.get('bookmakerOdds', {})):>3} books  "
          f"{f['participant1Name']} v {f['participant2Name']}")
    time.sleep(1.0)

Output, 26 October 2026, 13:45 UTC:

  1.3 h out  126 books  Darin, Kyryl v Parhomenko, Dmitro
  1.8 h out  125 books  Wagner, Mark Robin v Ruzicka, Jiri
  3.8 h out  120 books  Nesnera, Jiri v Ruzicka, Jiri
  7.8 h out   76 books  Fisera, Milan v Trinta, Mihail
 11.8 h out   75 books  Fojt, Pavel v Cermak, Vladimir
 17.8 h out   43 books  Smesny, Milan v Svec, Jiri
 23.8 h out   42 books  Regner, Michal v Dolejsi, Stanislav
 29.8 h out    7 books  Sobisek, Martin v Tuma, Daniel

Run it on your own tournament before you choose a polling schedule. On TT Cup the books arrive later and closer together: the first SBOBet price on a played TT Cup match landed a median 4.0 hours before the start.

Step 3: Rank the Winner prices on a full board

This script walks forward to the first Czech Liga Pro board with 100+ books, applies the guards, dedupes on the price pair and ranks the pairs by margin. Three guards matter on table tennis:

  • Drop a book with suspended: true or bookmakerIsActive: false, and any price with active: false.
  • Drop a book whose newest changedAt on the fixture is more than six hours old. A 60-minute cut fits a soccer board, but table tennis books post a line and leave it for hours: on Czech Liga Pro boards one to four hours out, the newest price per book ranged from a few minutes to 23 hours old.
  • Reject a pair whose inverse prices sum to 1.0 or less. One book never offers an arb against itself.
import os, time, statistics, requests
from collections import defaultdict
from datetime import datetime, timezone

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

def get(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(5):
        r = requests.get(f"{BASE}/{path}", params=params, timeout=120)
        if r.status_code == 429:
            time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"{path} kept returning 429")

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

now = datetime.now(timezone.utc)
fixtures = get("fixtures", tournamentId=CZECH_LIGA_PRO, **{"from": "2026-10-26", "to": "2026-10-29"})
ahead = sorted((f for f in fixtures if f["statusName"] != "Cancelled" and ts(f["startTime"]) > now),
               key=lambda f: f["startTime"])

# Walk forward until a board has filled (100+ books).
for f in ahead:
    board = get("odds", fixtureId=f["fixtureId"])
    time.sleep(1.0)
    if len(board.get("bookmakerOdds", {})) >= 100:
        break

quotes = {}
for slug, book in board["bookmakerOdds"].items():
    if "+" in slug or slug in ("demo", "singbet-b"):           # internal test feeds
        continue
    if book.get("suspended") or book.get("bookmakerIsActive") is False:
        continue
    markets = book.get("markets") or {}
    stamps = [ts(p["changedAt"]) for m in markets.values() for o in m["outcomes"].values()
              for p in o["players"].values() if p.get("changedAt")]
    # Books post the line and leave it for hours, so a 60-minute cut is too tight here.
    if not stamps or (now - max(stamps)).total_seconds() > 6 * 3600:
        continue
    legs = (markets.get("251") or {}).get("outcomes", {})      # 251 = Winner
    try:
        p1, p2 = legs["251"]["players"]["0"], legs["252"]["players"]["0"]
    except KeyError:
        continue
    if p1.get("active") is False or p2.get("active") is False:
        continue
    if 1 / p1["price"] + 1 / p2["price"] <= 1.0:                 # one book never arbs itself
        continue
    quotes[slug] = (p1["price"], p2["price"])

groups = defaultdict(list)                                      # dedupe on the price pair
for slug, q in quotes.items():
    groups[q].append(slug)

def margin(q):
    return 100 * (1 / q[0] + 1 / q[1] - 1)

fair_p1 = statistics.median((1 / a) / (1 / a + 1 / b) for a, b in groups)
hours = (ts(f["startTime"]) - now).total_seconds() / 3600
print(f"{f['participant1Name']} v {f['participant2Name']}, {hours:.1f} h out")
print(f"{len(board['bookmakerOdds'])} books on the board, {len(quotes)} usable Winner quotes, "
      f"{len(groups)} distinct price pairs")
print(f"median margin {statistics.median(margin(q) for q in quotes.values()):.2f}%, "
      f"deduped consensus P1 {fair_p1:.1%}")
print("tightest pairs:")
for q in sorted(groups, key=margin)[:5]:
    print(f"  {q[0]:>5} / {q[1]:<5} {margin(q):5.2f}%  {len(groups[q]):>2} books  {', '.join(sorted(groups[q])[:3])}")
for slug in ("sbobet", "1xbet", "bet365"):
    if slug in quotes:
        print(f"  {slug:<7} {quotes[slug][0]} / {quotes[slug][1]}  {margin(quotes[slug]):.2f}%")

Output, 26 October 2026, 13:45 UTC:

Darin, Kyryl v Parhomenko, Dmitro, 1.2 h out
126 books on the board, 117 usable Winner quotes, 27 distinct price pairs
median margin 9.39%, deduped consensus P1 55.2%
tightest pairs:
   1.75 / 2.09   4.99%   1 books  sbobet
   1.66 / 2.1    7.86%  13 books  bet365.bet.ar, bet365.es, bet365.gr
   1.65 / 2.11   8.00%   1 books  mozzartbet
   1.77 / 1.94   8.04%   2 books  atg.se, unibet.com.au
   1.66 / 2.09   8.09%  10 books  31bet, balkanbet.rs, dafabet
  sbobet  1.75 / 2.09  4.99%
  1xbet   1.67 / 2.02  9.39%
  bet365  1.63 / 2.1  8.97%

Thirteen bet365 skins share one pair at 7.86%, while the bet365 slug itself quotes 8.97%. SBOBet sits alone at the top. If you need a fair price, take the deduped consensus or de-vig the SBOBet pair; our consensus odds guide compares the methods.

Step 4: Settle a played match from the history and the scores

/v4/scores returns one key per game, p1 to p5, each with the points of both players, plus result with the games won. Sum the game keys to settle Total Points. This script maps the 101 Total Points IDs to their lines, reads the closing Winner pair and the closing main total for three books from /v4/historical-odds, and grades both markets.

import os, time, requests
from datetime import datetime

API_KEY = os.environ["ODDSPAPI_API_KEY"]
BASE = "https://api.oddspapi.io/v4"
FIXTURE = "id2503634975937924"           # Czech Liga Pro, 24 Oct 2026 14:00 UTC
BOOKS = "sbobet,bet365,1xbet"            # /historical-odds takes at most 3

def get(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(5):
        r = requests.get(f"{BASE}/{path}", params=params, timeout=180)
        if r.status_code == 429:
            time.sleep(r.json().get("error", {}).get("retryMs", 5000) / 1000 + 1)
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"{path} kept returning 429")

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

# Total Points ships one market ID per line. Map every ID to its line by NAME.
defs = get("markets", sportId=25)
total_line = {str(m["marketId"]): m["handicap"] for m in defs if m["marketName"] == "Total Points"}
print(f"Total Points: {len(total_line)} market IDs, lines {min(total_line.values())} to {max(total_line.values())}")

hist = get("historical-odds", fixtureId=FIXTURE, bookmakers=BOOKS)
time.sleep(4.6)
fx = get("fixtures", tournamentId=36349, **{"from": "2026-10-24", "to": "2026-10-25"})
start = ts(next(f["startTime"] for f in fx if f["fixtureId"] == FIXTURE))

def close(snaps):
    """Last active price recorded before the first serve."""
    pre = [s for s in snaps if ts(s["createdAt"]) < start and s.get("active") is not False]
    return max(pre, key=lambda s: s["createdAt"])["price"] if pre else None

periods = get("scores", fixtureId=FIXTURE)["scores"]["periods"]
games = periods["result"]
points = sum(p["participant1Score"] + p["participant2Score"]
             for k, p in periods.items() if k.startswith("p"))
p1_won = games["participant1Score"] > games["participant2Score"]
print(f"result {games['participant1Score']}-{games['participant2Score']} in games, {points} points\n")

for slug, book in hist["bookmakers"].items():
    mk = book.get("markets", {})
    win = mk.get("251", {}).get("outcomes", {})
    p1 = close(win.get("251", {}).get("players", {}).get("0", []))
    p2 = close(win.get("252", {}).get("players", {}).get("0", []))
    # Main total = the closing rung with the most balanced prices, inside a [1.5, 2.6] band.
    best = None
    for mid, line in total_line.items():
        legs = [close(o["players"]["0"]) for o in mk.get(mid, {}).get("outcomes", {}).values()]
        if len(legs) == 2 and all(legs) and all(1.5 <= x <= 2.6 for x in legs):
            if best is None or abs(legs[0] - legs[1]) < best[0]:
                best = (abs(legs[0] - legs[1]), line, legs)
    margin = 100 * (1 / p1 + 1 / p2 - 1)
    winner = f"{p1} / {p2}  {margin:.2f}%  P1 {'WIN' if p1_won else 'LOSE'}"
    total = f"total {best[1]}  {'OVER' if points > best[1] else 'UNDER'}" if best else "no total"
    print(f"{slug:<7} {winner}   {total}")

Output:

Total Points: 101 market IDs, lines 40.0 to 90.0
result 1-3 in games, 73 points

1xbet   2.28 / 1.6  6.36%  P1 LOSE   total 75.5  UNDER
bet365  2.37 / 1.5  8.86%  P1 LOSE   total 75.5  UNDER
sbobet  2.44 / 1.56  5.09%  P1 LOSE   total 77.5  UNDER

The three books closed on two different main totals: 1xBet and bet365 on 75.5, SBOBet on 77.5. Grade each book on its own line. If you key the total on one line for the whole board, you grade SBOBet’s bet on a line it never offered. For the full history workflow, see the historical odds API guide. The esports results guide uses the same /v4/scores pattern on best-of-five series.

Polling every few minutes works on a 16-minute match, but the WebSocket feed pushes each new price as the book posts it. If you came here for lawn tennis, the tennis moneyline guide covers the ATP, WTA and ITF boards.

Table tennis odds API FAQ

Is there a free table tennis odds API?

Yes. OddsPapi’s free plan returns Winner, Total Points and Point Handicap prices on table tennis fixtures (sportId 25) from 300+ bookmakers, plus the price history and the game scores of played matches. Czech Liga Pro, TT Cup, TT Elite Series and the WTT events are all in the feed.

How many table tennis matches does the API cover?

Between 23 and 25 October 2026, 647 to 675 finished matches a day, most of them in Czech Liga Pro, TT Cup and TT Elite Series. The median match lasted 16 minutes.

When should I poll table tennis odds?

Inside the last four hours before the start. On 26 October, Czech Liga Pro boards held a median 122 to 125 books in that window, against 42 a day ahead. TT Cup boards reached 110 books at two to four hours out.

Which bookmaker has the tightest table tennis odds?

On Czech Liga Pro and TT Cup, SBOBet. It quoted the tightest Winner pair on all 55 deep boards where it had a usable price, at a 5.03% median margin, and closed played matches at about 5%.

How do I settle table tennis bets from the API?

Call /v4/scores. The p1 to p5 keys carry the points of each game and result carries the games won. Winner settles on result; Total Points settles on the sum of the points in every game.

Read the whole table tennis day from one key

Every call above runs on the free key: 300+ bookmakers under one schema, free historical odds on every played match, and a WebSocket feed for a sport that plays 650 matches a day. Get a free API key and run Step 2 on tonight’s Czech Liga Pro schedule.