Brasileirão Odds API: 164 Books, and Betano Leads the Locals

Brasileirão Odds API - OddsPapi API Blog
How To Guides September 2, 2026

Point a script at the Brasileirão Série A board and 164 bookmakers come back on a single fixture. Ten of them are Brazilian. One of those ten, KTO, prices the three-way at a 4.05% margin and beats Pinnacle’s 4.66% across a full round.

That result is real, and it is also a trap. KTO ships a byte-identical 1X2 to two Swedish operators on 10 of 10 fixtures. Dedupe the board first and the Brazilian set shrinks from ten brands to three prices.

This post walks the whole pull in Python: tournament discovery, the round board, the dedupe pass, the margin ranking, and a coverage test that stops you concluding “Pinnacle does not price corners here” when it does. Every number below came off the free tier on 25 August 2026.

What you actually get, and what the raw board tells you

Question Raw board says After the checks in this post
Books on one Brasileirão fixture 164 88 independent prices (42.1% collapse)
Brazilian books per fixture 10 on 10 of 10 3 priced independently
Tightest Brazilian three-way kto 4.05%, rank 4 of 160 Shared feed. Real local best is betano.bet.br 4.78%
Does Pinnacle price corners? Zero corner markets live 8 corner families on 6 of 6 played fixtures
Books on the following round 8 ~5 independent, two of them placeholder-priced

OddsPapi aggregates 350+ bookmakers behind one JSON endpoint, and the free tier includes the historical price history that makes the coverage test below possible. Competitors charge for that history and carry roughly 40 books.

Step 1: Authentication

The API key is a query parameter. It is not a header, and code that sends it as one gets a 401.

import requests, time

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

def get(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(4):
        r = requests.get(f"{BASE_URL}{path}", params=params, timeout=180)
        if r.status_code == 200:
            return r.json()
        if r.status_code == 404:           # empty fixture window, not an error
            return []
        wait = r.json().get("error", {}).get("retryMs", 2000) / 1000
        time.sleep(wait + 0.5)
    r.raise_for_status()

print(len(get("/sports")), "sports")

The free tier rate-limits per endpoint and returns a structured 429 with a retryMs field. Honour it. Do not thread /odds calls across fixtures, because concurrency at any worker count gets almost everything rejected.

Step 2: Find the Brasileirão, past 18 decoys

Brazil lists 133 soccer competitions in the catalogue. Nineteen of them have “Serie A” in the name, and only six carry fixtures. A substring match picks up Gaúcho Série A2, four Paulista tiers, and a fistful of under-20 state leagues.

tours  = get("/tournaments", sportId=10)
brazil = [t for t in tours if t.get("categoryName") == "Brazil"]
serie_a = [t for t in brazil if "serie a" in t["tournamentName"].lower()]

print("Brazil competitions:", len(brazil))                              # 133
print("named 'Serie A':", len(serie_a),                                 # 19
      "| with fixtures:", sum(1 for t in serie_a if t.get("futureFixtures")))   # 6

top = max(serie_a, key=lambda t: t.get("futureFixtures") or 0)
TOURNAMENT_ID = top["tournamentId"]
print(TOURNAMENT_ID, top["tournamentName"], top["futureFixtures"])
# 325 Brasileiro Serie A 142

Brasileirão Série A is tournamentId 325, slug brasileiro-serie-a, 142 future fixtures. Série B is 390 with 144. Read futureFixtures before you pick an ID, the same rule that applies to Grand Slam tennis draws.

Pull the round

fixtures = get("/fixtures", sportId=10, tournamentId=TOURNAMENT_ID,
               **{"from": "2026-08-29", "to": "2026-09-01"})

for f in fixtures:
    print(f["fixtureId"], f["startTime"], f["participant1Name"], "v", f["participant2Name"])
# 10 fixtures, 29-31 Aug

Two things bite here. The to parameter is a midnight-UTC instant, so set it to the day after your last match day or the final day looks empty. And an empty window returns HTTP 404 with a FIXTURE_NOT_FOUND body rather than an empty array, which kills any loop that calls raise_for_status().

Step 3: Read the board and dedupe it

A no-filter /odds call on a Brasileirão fixture returns 15 to 20 MB. Across the 29 to 31 August round: median 164.5 books, 521,364 prices, 85.2% of them active, median 522 distinct market IDs per fixture.

Prices live four levels down, and active sits on the price object rather than the outcome. Three guards belong in every parser: skip internal test feeds, skip suspended books, and require all three legs of the three-way.

import collections

FIXTURE = "id1000032566886958"          # Flamengo v Palmeiras, 30 Aug 2026
TEST_FEEDS = ("demo", "pinnacle+", "singbet-b")

def live_price(outcome):
    """Game lines sit under players['0']. Return the price only if it is active."""
    p = outcome.get("players", {}).get("0")
    return p.get("price") if p and p.get("active") else None

books = get("/odds", fixtureId=FIXTURE)["bookmakerOdds"]
print("books in payload:", len(books))                    # 164

quotes = {}
for slug, book in books.items():
    if slug.startswith(TEST_FEEDS) or book.get("suspended"):
        continue
    market = book.get("markets", {}).get("101")            # Full Time Result
    if not market:
        continue
    prices = [live_price(market["outcomes"].get(str(o), {})) for o in (101, 102, 103)]
    if any(p is None for p in prices):                     # partial or dead 1X2
        continue
    quotes[slug] = tuple(prices)

groups = collections.defaultdict(list)
for slug, tup in quotes.items():
    groups[tup].append(slug)

print("complete live 1X2:", len(quotes))                   # 152
print("independent quotes:", len(groups))                  # 88
print("collapse: %.1f%%" % (100 * (1 - len(groups) / len(quotes))))   # 42.1%

Sixty-four of the 152 quotes are duplicates of another slug. The largest group on this fixture runs 17 brands deep and includes ballybet, betparx, casumo, fourwinds, grosvenor and jacks.nl. None of them carries a cloneOf flag. The flag tracks catalogue lineage, not feed reality, so dedupe on the price tuple per fixture instead of trusting a static clone list.

The pinnacle+ guard matters more than it looks. pinnacle, pinnacle+5 and pinnacle+30 all shipped a 4.66% median margin over the round. Count them and you count Pinnacle three times.

Step 4: Rank the Brazilian books honestly

Sort the deduped quotes by bookmaker margin and the Brazilian brands land all over the table.

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

for slug, tup in sorted(quotes.items(), key=lambda kv: margin(kv[1]))[:6]:
    print(f"{slug:22s} {tup} {margin(tup):5.2f}%")

Across all ten fixtures, 160 books carried a complete live three-way on at least seven of them. Median margins:

Slug Median 1X2 margin Rank of 160 Ships the same prices as
kto 4.05% 4 atg.se, svenskaspel (10/10)
pinnacle 4.66% 8 Independent
betano.bet.br 4.78% 12 Independent
superbet.bet.br 5.27% 18 napoleonsports.be, superbet.ro, superbet.rs
betmgm.bet.br 5.94% 29 expekt.dk
betboo.bet.br 5.97% 35 betmgm, borgata, bwin and 5 more
betnacional 5.99% 46 Independent
stake.bet.br 6.72% 55 bingoal.be (9/10)
estrelabet 7.44% 92 Independent
bet365.bet.br 8.94% 111 bet365 and 4 regional skins
sportingbet.bet.br no three-way n/a See below

Ten Brazilian slugs, three Brazilian prices. KTO’s 4.05% is a genuine number and a genuine best-of-board price, and it is not a local price: ATG and Svenska Spel quote it to the decimal on every fixture in the round. The tightest independently-priced Brazilian book is betano.bet.br at 4.78%, which sits 0.12 percentage points behind Pinnacle.

That lands where the La Liga and Serie A boards landed. Locally licensed books turn up in force and none of them beats the sharp on the three-way. Brazil comes closer than Spain, Italy, Germany or France, and it still does not clear the bar.

Two slugs that look like the same book and are not

betano and betano.bet.br are separate feeds with separate prices. The global brand posts 5.95% (rank 31); the Brazilian licence posts 4.78% (rank 12). Query the wrong one and you are 1.17 percentage points off on every fixture.

Step 5: Three catalogue traps that return silence

The licensed brand is often a clone that never ships

Eleven slugs end in .bet.br, the suffix for a Brazilian federal licence. Two of the biggest are dead ends:

cat = {b["slug"]: b for b in get("/bookmakers")}
for s in ("kto", "kto.bet.br", "estrelabet", "estrelabet.bet.br", "pixbet", "blaze.bet.br"):
    b = cat[s]
    print(f"{s:20s} live={b['liveOdds']!s:5s} cloneOf={b['cloneOf']}")

# kto                  live=True  cloneOf=None
# kto.bet.br           live=True  cloneOf=kto
# estrelabet           live=True  cloneOf=None
# estrelabet.bet.br    live=True  cloneOf=estrelabet
# pixbet               live=True  cloneOf=bcgame
# blaze.bet.br         live=True  cloneOf=blaze

kto.bet.br and estrelabet.bet.br are flagged as clones of their parents, and clone-flagged slugs never appear in an /odds payload. The feed ships under kto and estrelabet. Pixbet is stranger: it clones bcgame, and on this round bcgame, betfury, blaze, gamdom, megadice and rainbet all quoted one identical tuple.

Filtering /odds with bookmakers=kto.bet.br returns an empty payload, not an error. Check the slug against /bookmakers before you build a pipeline on it.

liveOdds: false means pre-match only

bet365.bet.br and betnacional both carry liveOdds: false, and both quoted a complete three-way on all ten fixtures. The flag marks in-play coverage. Treat it as a pre-match book, not a missing one.

A book can carry 87 markets and skip the headline one

sportingbet.bet.br appeared on 10 of 10 fixtures with 87 markets across 57 families, including totals, European handicaps, team totals and exact score. It priced the Full Time Result on none of them. Soccer keeps the three-way under a single market ID (101), so there is no second ID hiding the quote. Handle the gap rather than assuming a book that shows up prices the main line.

Step 6: Rank per market family, not per book

On six European competitions, sharp books price the Asian handicap tighter than their own three-way and soft books widen it. KTO breaks the pattern in the other direction.

MARKET_NAME = {str(m["marketId"]): m["marketName"] for m in get("/markets", sportId=10)}

for slug in ("pinnacle", "kto", "betano.bet.br", "estrelabet"):
    book = books.get(slug)
    three_way, handicaps = None, []
    for market_id, market in book["markets"].items():
        prices = [p for p in (live_price(o) for o in market["outcomes"].values()) if p]
        if market_id == "101" and len(prices) == 3:
            three_way = margin(prices)
        elif MARKET_NAME.get(market_id) == "Asian Handicap" and len(prices) == 2:
            handicaps.append(margin(prices))
    print(f"{slug:16s} 1X2 {three_way:5.2f}%  best AH {min(handicaps):5.2f}%  "
          f"ratio {min(handicaps)/three_way:.2f}x")
Book Median 1X2 Median best handicap Ratio
pinnacle 4.66% 3.40% 0.73x
sharpbet 4.65% 3.42% 0.74x
betano.bet.br 4.78% 3.69% 0.77x
kto 4.05% 5.36% 1.32x
superbet.bet.br 5.27% 6.52% 1.24x
estrelabet 7.44% 5.90% 0.79x
draftkings 7.91% 9.93% 1.26x

KTO beats Pinnacle by 0.61 points on the three-way and loses to it by 1.96 points on the handicap. Whatever is behind that feed prices the market Brazilian recreational money uses and gives ground on the one it does not. Benchmark handicaps against a sharp and shop three-ways separately.

Pinnacle’s handicap ladder runs nine rungs on a Brasileirão fixture, margin lowest near the true line at 3.41% and widening to 4.55% at the ends. Its limit field decodes to a flat base: base = limit if price >= 2 else limit * (price - 1) returned $300 on 89 of 90 rungs across the round. La Liga runs $1,500 and Serie A $1,000 at the same distance from kickoff, so Pinnacle takes a fifth of the position on Brazil that it takes on Spain.

Step 7: The coverage test that stops you publishing a false negative

Pinnacle priced zero corner markets on all ten upcoming fixtures. It would be easy to write “Pinnacle skips Brazilian corners” and ship it. That conclusion is wrong, and the reason is the clock.

Test coverage on a fixture that has already been played. Live prices drop after the whistle, but /historical-odds keeps the full menu.

import datetime as dt, statistics

PLAYED  = "id1000032566886948"      # Cruzeiro v Flamengo, played 22 Aug 2026
KICKOFF = dt.datetime.fromisoformat("2026-08-22T23:30:00+00:00")

markets = get("/historical-odds", fixtureId=PLAYED,
              bookmakers="pinnacle")["bookmakers"]["pinnacle"]["markets"]
print("market IDs over the fixture's life:", len(markets))     # 156

def family(name):
    if "Corner" in name:  return "corners"
    if "Booking" in name: return "bookings"
    if name in ("Full Time Result", "Asian Handicap", "Over Under Full Time"):
        return "core"
    return "derivatives"

opens = collections.defaultdict(list)
for market_id, market in markets.items():
    group = family(MARKET_NAME.get(market_id, ""))
    for outcome in market["outcomes"].values():
        for snapshots in outcome["players"].values():
            pre = [s for s in snapshots if
                   dt.datetime.fromisoformat(s["createdAt"].replace("Z", "+00:00")) < KICKOFF]
            if not pre:
                continue
            first = dt.datetime.fromisoformat(pre[0]["createdAt"].replace("Z", "+00:00"))
            opens[group].append((KICKOFF - first).total_seconds() / 86400)

for group in ("core", "derivatives", "corners", "bookings"):
    print(f"{group:12s} opened T-{max(opens[group]):.2f}d")
market IDs over the fixture's life: 156
core         opened T-6.03d
derivatives  opened T-3.26d
corners      opened T-1.69d
bookings     opened T-1.13d

Pinnacle priced 8 corner families and 5 booking families on 6 of 6 played fixtures from that round: full-time and first-half corner totals, per-team corners, a corners handicap, bookings 1X2, a bookings handicap and bookings totals. It priced 156 market IDs across the fixture's life against the 36 visible live four days out.

The wave is clean and it repeats. Core markets open around a week out, derivatives follow at three days, corners at 1.5 to 2.5 days, bookings inside 30 hours. European leagues open corners at almost exactly three days, so Brazil runs about a day later. A coverage audit at T-4d reports no corner data on the Brasileirão and is wrong every time. Poll side markets from two days out.

Live corner coverage on the round is deep once the window opens: 83 books on Corners - Over Under Full Time, 78 on Corners - 1X2, 70 on first-half corner totals. Cards stay narrow, with 14 books on Player To Be Carded and 10 on Bookings - 1X2.

Step 8: What the round after this one looks like

Books open the Brasileirão one round at a time. The 29 to 31 August round carried 154 to 166 books per fixture. A 2 September fixture, three days later, carried eight.

Slug Markets 1X2
superbet.bet.br 137 1.32 / 4.70 / 8.60
napoleonsports.be 65 1.32 / 4.70 / 8.60
superbet.ro 118 1.25 / 5.45 / 9.40
superbet.rs 126 1.25 / 5.45 / 9.40
superbet.pl 112 1.24 / 5.30 / 8.90
bet99 14 1.263 / 5.75 / 11.00
polymarket 60 1.22 / 2.174 / 2.381
kalshi 1 1.25 / 1.538 / 1.493

Five Superbet-family slugs, two of them identical, plus two prediction markets quoting nonsense. Kalshi's three legs sum to a 112% margin and Polymarket's to 62%, which is what an empty book looks like when it has posted a placeholder. Every one of these fixtures reports hasOdds: true. The flag says a book has touched the fixture, and it says nothing about depth.

Step 9: The payoff

Best price per outcome on the worked fixture, against Pinnacle:

Outcome Pinnacle Best on board Gain
Flamengo 1.408 1.45 (atg.se) +2.98%
Draw 5.06 5.333 (apuestatotal) +5.40%
Palmeiras 6.97 8.30 (balkanbet.rs) +19.08%

The 19% on the away side is where a wide board earns its keep, and it is also where you check your work. Confirm the quote is active, confirm the book is not suspended, and confirm no other slug is shipping the same tuple. See line shopping in Python for the full best-price loop and the vig calculator for the margin maths.

Player props

Brasileirão prop coverage is the deepest of any league board measured: Anytime Goal Scorer on 91 books, First Goal Scorer 86, Player Goals 72, Last Goal Scorer 46, Player Assists 43, Shots on Goal totals 32. Prop markets key players by player ID rather than "0", so the game-line parser above returns nothing on them. Iterate the dict and skip the "0" key.

The checklist

  1. Resolve tournamentId 325 by futureFixtures, not by name.
  2. Set to on /fixtures to the day after your last match day.
  3. Treat a 404 from /fixtures as an empty window.
  4. Filter demo, pinnacle+ and singbet-b out of any census.
  5. Test active on the price object, and suspended on the book.
  6. Dedupe on the price tuple per fixture. Ignore cloneOf for this.
  7. Check a slug exists before filtering on it. A missing slug returns empty, not an error.
  8. Rank margins per market family.
  9. Test coverage on a played fixture through /historical-odds, never on an upcoming one.
  10. Sleep 1 second between same-endpoint calls, 4.5 seconds after /historical-odds, and never parallelise.

FAQ

What is the Brasileirão tournament ID in the OddsPapi API?

Brasileirão Série A is tournamentId 325 (slug brasileiro-serie-a) and Série B is 390. Nineteen Brazilian competitions have "Serie A" in the name, so resolve by futureFixtures rather than string matching.

Which Brazilian bookmakers ship live odds?

Ten appeared on every fixture of the 29 to 31 August 2026 round: kto, betano.bet.br, superbet.bet.br, betmgm.bet.br, betboo.bet.br, betnacional, stake.bet.br, estrelabet, bet365.bet.br and sportingbet.bet.br. Only betano.bet.br, betnacional and estrelabet price independently. The rest share a feed with a non-Brazilian brand.

Does any Brazilian bookmaker price tighter than Pinnacle?

kto posts a 4.05% median three-way margin against Pinnacle's 4.66%, but it ships identical prices to atg.se and svenskaspel on every fixture, so it is not a locally-priced book. The tightest independent Brazilian quote is betano.bet.br at 4.78%, which is 0.12 points wider than Pinnacle.

Why does the API return no odds for kto.bet.br?

kto.bet.br carries cloneOf: kto, and clone-flagged slugs never appear in an /odds payload. The feed ships under the parent slug. The same applies to estrelabet.bet.br and blaze.bet.br. Filtering on a clone slug returns an empty payload rather than an error.

Does Pinnacle price corners on the Brasileirão?

Yes. It prices 8 corner families and 5 booking families, verified on 6 of 6 played fixtures through /historical-odds. Corner markets open 1.5 to 2.5 days before kickoff, so a live call four days out returns none of them.

How many bookmakers cover one Brasileirão fixture?

A no-filter /odds call returned 154 to 166 books per fixture across the round, with a median of 164.5 and 522 distinct market IDs. After deduping on the price tuple, 152 complete three-ways collapsed to 88 independent quotes.

Get the data

Every figure in this post came off the free tier, including the historical snapshots behind the open-clock study. The free key covers 350+ bookmakers, the full Brazilian licensed set, and price history with no extra charge. Grab your free API key and run the census on your own round.

Next: the general soccer odds API guide, storing odds in SQLite, or the Brazilian arbitrage scanner.