African Football Odds API: betPawa Undercuts Pinnacle in Egypt’s Tier Two

African Football Odds API - OddsPapi API Blog
How To Guides September 11, 2026

Search for an African football odds feed and you get two answers, both wrong. The first is a scraper repo aimed at one bookmaker’s website. The second is an enterprise data contract with a sales call attached. Neither tells you what is actually on the board for an Egyptian Premier League match on a Wednesday afternoon.

So we measured it. On 2 September 2026 we pulled the live board for 12 fixtures across 12 African competitions and counted every bookmaker, every price, and every duplicate. One Egypt Premier League fixture carried 254 bookmakers. A Uganda Premier League match carried 129. Both numbers are larger than most developers assume the whole market to be.

The counts are not the interesting part. The interesting part is which books price these leagues well, and the answer is not the one the brand names suggest.

What the African board actually looks like

The soccer catalogue holds 55 African competitions, counting the 16 domestic categories plus the CAF continental cups. Sixteen of them had future fixtures loaded when we ran the census.

Competition tournamentId Future fixtures Books on a sampled fixture
Egypt Premier League 808 27 254 / 239
South Africa Premiership 358 25 223 / 178
Zimbabwe Premier Soccer League 23479 9 152
Algeria Ligue 1 841 14 141 / 140
Uganda Premier League 14864 142 132 / 129
Egypt 2. Division A 19492 360 101 / 101
Tanzania Premier League 2436 205 board not yet open
CAF Champions League 1054 58 board not yet open
Live /v4/odds census, 2 September 2026. A census describes the moment you take it.

Egypt’s second division carries 360 loaded fixtures, more than any top flight on the continent, and its matches still draw a hundred bookmakers each. Uganda’s top flight carries 142. If you are building a model for a league that European tipsters ignore, the data volume is there.

Three quarters of the board is a copy

Now the correction. Those 254 books do not give you 254 opinions. Group the quotes by their price tuple and the board collapses:

Fixture Complete + active 1X2 Independent prices Collapse
Egypt PL (14:00 kick-off) 243 77 68.3%
Egypt PL (17:00 kick-off) 229 63 72.5%
South Africa Premiership 219 66 69.9%
South Africa Premiership 175 38 78.3%
Uganda Premier League 81 20 75.3%
Algeria Ligue 1 131 43 67.2%
Egypt 2. Division A 101 26 74.3%
Zimbabwe PSL 88 25 71.6%

On the deepest Egypt fixture, 27 separate slugs quoted the identical 2.63 / 2.75 / 2.90. That single tuple is 11% of the board on its own. Average across the raw slug list and you weight one pricing desk 27 times.

This matters more here than it does on the Premier League. A wide European board still resolves to 104 to 112 independent feeds, which is enough for a stable consensus. A South Africa Premiership fixture resolves to 38. Our study on how many bookmakers a backtest needs put the knee of the accuracy curve at 25 independent books, so an African board sits close to that floor rather than comfortably above it.

betPawa prices these leagues better than the brands do

Rank each board by bookmaker margin and the familiar names do badly. On the deeper Egypt Premier League fixture, bet365 ranked 231st of 243 at a 14.77% overround and Betway ranked 240th at 16.67%. 1xBet and 22bet clustered around 9.9% on every league we sampled.

betPawa CM, a book most European developers have never heard of, ranked in the single digits on four of the eight boards we ranked:

Fixture betPawa CM Pinnacle 1xBet bet365
Egypt PL 7.11% (39th) 4.47% (14th) 9.85% (145th) 14.77% (231st)
Algeria Ligue 1 8.04% (3rd) absent 9.93% (52nd) absent
Egypt 2. Division A 9.04% (4th) 9.18% (10th) 10.15% (19th) absent
Egypt 2. Division A 9.18% (9th) 9.28% (15th) 10.18% (28th) absent
South Africa Premiership 7.95% (7th) absent 9.89% (57th) 13.43% (161st)
Overround on the full-time 1X2 market, complete and active quotes only.

On both Egypt second-division fixtures betPawa posted a tighter number than Pinnacle. That is a narrow win on a shallow market, and it does not make betPawa a sharp book. It does mean that a scanner which only reads the twelve bookmakers you recognise will miss the best price on these leagues most of the time.

Pinnacle’s margin scales with how obscure the league is

Pinnacle closes an English Premier League three-way at roughly 3%. Across the African boards it priced, the number climbed with the tier:

Competition Pinnacle 1X2 overround Rank on that board
English Premier League (closing, for reference) 2.95% to 3.20% 23rd to 24th of 175
Egypt Premier League 4.38% to 4.47% 14th of 229 to 243
South Africa Premiership 7.54% 19th of 219
Egypt 2. Division A 9.18% to 9.28% 10th to 15th of 101
Uganda Premier League 11.56% 41st of 81

Pinnacle still prices a Ugandan league match, which is worth knowing on its own. It charges nearly four times its Premier League margin to do it, and 40 books on that board beat it. Treating Pinnacle as the automatic fair-value anchor works on Anfield and stops working in Kampala. Build your anchor from the de-vigged board instead, the way our consensus odds calculator does.

Old way vs OddsPapi

Task Scraping bookmaker sites OddsPapi /v4
Find African competitions Guess URLs per operator /tournaments?sportId=10, one call
Books per fixture One per scraper you maintain Up to 254 in one JSON response
Local operators Geo-blocked, JS-rendered, CAPTCHA betPawa, Mozzart Bet, 1xBet, BetWinner in the same payload
Duplicate feeds Invisible until you diff by hand Dedupe on the price tuple in four lines
Historical prices Build your own recorder, wait a season /historical-odds on the free tier
Cost Proxies, maintenance, breakage Free API key

Tutorial: pull and rank an African board in Python

Everything below runs against the live API with a free key. Authentication is a query parameter called apiKey, never a header.

Step 1: a request helper that survives the rate limit

The API rate-limits per endpoint and returns a real HTTP 429 with the wait time in the body. It also returns HTTP 404 for a date window with no fixtures, which is a normal result rather than a failure. Handle both once, at the bottom, and the rest of your code stays clean.

import requests, time, collections

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

def call(path, **params):
    params["apiKey"] = API_KEY
    for _ in range(5):
        r = requests.get(f"{BASE_URL}{path}", params=params)
        if r.status_code == 200:
            return r.json()
        if r.status_code == 404:          # empty window, not an error
            return []
        if r.status_code == 429:
            time.sleep(r.json()["error"]["retryMs"] / 1000 + 0.5)
            continue
        raise RuntimeError(f"{r.status_code} {r.text[:200]}")
    raise RuntimeError("rate limited five times in a row")

Step 2: discover the African competitions

OddsPapi calls a league a tournament, a game a fixture, and a team a participant. Each tournament carries a categoryName holding the country, so filtering by country gets you the domestic leagues and a prefix match gets you the CAF cups.

AFRICA = {"South Africa", "Egypt", "Algeria", "Morocco", "Tunisia", "Ghana",
          "Kenya", "Nigeria", "Tanzania", "Uganda", "Zambia", "Zimbabwe",
          "Angola", "Cameroon", "Ivory Coast", "Senegal"}

tours = call("/tournaments", sportId=10)
african = [t for t in tours
           if t["categoryName"] in AFRICA or t["tournamentName"].startswith("CAF")]
live = [t for t in african if t["futureFixtures"] > 0]

for t in sorted(live, key=lambda x: -x["futureFixtures"])[:8]:
    print(f'{t["tournamentId"]:6d}  {t["categoryName"]:14s} '
          f'{t["tournamentName"]:26s} {t["futureFixtures"]:4d}')
 19492  Egypt          2. Division A                360
  2436  Tanzania       Premier League               205
 14864  Uganda         Premier League               142
  1054  International Clubs CAF Champions League      58
   808  Egypt          Premier League                27
   358  South Africa   Premiership                   25

Read futureFixtures > 0 as proof that a competition is running. Do not read a zero as proof that it is dead. Several circuits report zero on /tournaments while carrying priced fixtures inside 48 hours, so discover through /fixtures whenever the count looks wrong.

Step 3: fixtures inside one competition

tournamentId filters /fixtures server-side and cuts the payload by roughly twenty times. Set to to the day after your last day, because the window ends at midnight UTC on the to date.

EGYPT_PL = 808

fixtures = call("/fixtures", sportId=10, tournamentId=EGYPT_PL,
                **{"from": "2026-09-02", "to": "2026-09-12"})
priced = [f for f in fixtures if f.get("hasOdds")]
print(len(fixtures), "fixtures,", len(priced), "flagged hasOdds")
# 14 fixtures, 14 flagged hasOdds

Step 4: parse the 1X2 board

The odds response nests four levels deep and the price object sits below players["0"], not on the outcome. Two flags decide whether a quote counts: suspended at the book level, and active on the price itself. A book can pull its market and keep returning numbers, and half a wide board is posted and suspended at any moment.

OUTCOMES = {"101": "home", "102": "draw", "103": "away"}

def one_x_two(payload):
    """{slug: {home, draw, away}} for complete, active, unsuspended quotes."""
    board = {}
    for slug, book in payload.get("bookmakerOdds", {}).items():
        if book.get("suspended") or book.get("bookmakerIsActive") is False:
            continue
        market = book.get("markets", {}).get("101")
        if not market or not market.get("marketActive", True):
            continue
        quote = {}
        for oid, name in OUTCOMES.items():
            price = market["outcomes"].get(oid, {}).get("players", {}).get("0")
            if not price or not price.get("active") or not price.get("price"):
                quote = None
                break
            quote[name] = price["price"]
        if quote:
            board[slug] = quote
    return board

fixture = sorted(priced, key=lambda f: f["startTime"])[0]
odds = call("/odds", fixtureId=fixture["fixtureId"])
board = one_x_two(odds)

print(len(odds["bookmakerOdds"]), "books in payload")
print(len(board), "complete and active 1X2 quotes")
# 254 books in payload
# 243 complete and active 1X2 quotes

Step 5: dedupe before you average anything

Group by the rounded price tuple. Anything that shares a tuple shares a pricing desk, whatever the catalogue says about clone flags.

tuples = collections.Counter(
    tuple(round(q[n], 4) for n in ("home", "draw", "away"))
    for q in board.values())

collapse = 1 - len(tuples) / len(board)
biggest, count = tuples.most_common(1)[0]

print(f"{len(tuples)} independent prices, {collapse:.1%} of the board is a copy")
print(f"largest tuple: {count} slugs at {biggest}")
# 77 independent prices, 68.3% of the board is a copy
# largest tuple: 27 slugs at (2.63, 2.75, 2.9)

Step 6: rank the board by margin

def margin(q):
    return sum(1 / q[n] for n in ("home", "draw", "away")) - 1

ranked = sorted(board.items(), key=lambda kv: margin(kv[1]))
place = {slug: i + 1 for i, (slug, _) in enumerate(ranked)}

for slug in ("pinnacle", "betpawa.cm", "1xbet", "bet365"):
    if slug in place:
        print(f"{slug:12s} rank {place[slug]:3d} of {len(ranked)}  "
              f"{margin(board[slug]):.2%}")
# pinnacle     rank  14 of 243  4.47%
# betpawa.cm   rank  39 of 243  7.11%
# 1xbet        rank 145 of 243  9.85%
# bet365       rank 231 of 243  14.77%

The full method, including the power and Shin de-vig variants, is in our vig calculator walkthrough.

Three traps specific to these boards

The board opens about 36 hours out

Every African fixture that returned bookmakers kicked off within roughly 48 hours of the call. Every fixture kicking off two or more days later returned zero bookmakers while still reporting hasOdds: true. The flag describes the fixture, not the moment.

The history confirms the window. On a South Africa Premiership match that kicked off at 17:30 UTC on 25 August, the first snapshots landed at 06:54 (betPawa), 09:12 (1xBet) and 10:11 (Pinnacle) on 24 August. That is 31 to 35 hours before kick-off.

So poll inside the window, and test your coverage against a played fixture through /historical-odds. A live call on a Thursday match asked for on Monday reports a false zero.

An exchange is not automatically the tight book

On the Egypt Premier League fixture, five exchange slugs shared the tightest quote on the board at a 1.80% overround. On Algeria Ligue 1 the same betfair-ex slug quoted 1.60 / 1.25 / 1.60, which sums to a 105% overround, and 1.14 / 1.27 / 1.14 on the second fixture, which sums to 154%. Those are near-empty ladders on an illiquid market, and they rank 127th of 131.

Guard the ranking rather than trusting the venue type:

SANE = {slug: q for slug, q in board.items() if 0.005 <= margin(q) <= 0.30}

A sub-100% best price is usually stale, not free money

Take the best price on each outcome across the whole board and the composite sometimes sums to less than 1.00. On one Uganda fixture it came out at 0.924. That is not an arbitrage you can take. On a board with 20 independent prices it is one book that stopped updating, and a stale longshot inflates the composite. Our line shopping guide covers the sanity checks that keep an outlier out of the number.

Where the best-price gap is real, it is large. Against the full Egypt Premier League board, the best available price beat betPawa’s own number by up to 10.9%, 1xBet by 18.0% and Betway by 19.0% on a single outcome.

Debugging the bookmakers filter

Passing bookmakers= gives you three distinct outcomes, and reading the status code tells you which problem you have.

Status Code Meaning
400 INVALID_PARAMETER The slug is not in the catalogue. The error body lists every valid slug, so a deliberate 400 is a free catalogue dump.
403 RESTRICTED_ACCESS The slug is real but your plan does not read it.
200 The slug is valid and simply not on that fixture. Use .get() rather than indexing.
r = requests.get(f"{BASE_URL}/odds",
                 params={"apiKey": API_KEY, "fixtureId": fid,
                         "bookmakers": "betking"})
print(r.status_code, r.json()["error"]["code"])
# 400 INVALID_PARAMETER   (the real slug set is what the body returns)

Backtesting an African league for free

Historical prices are the part competitors put behind a paywall. On these leagues they are on the free tier, and finished fixtures keep their history after live prices drop off.

# max 3 bookmakers per call; retry a failed batch one slug at a time
hist = call("/historical-odds", fixtureId=played_id,
            bookmakers="pinnacle,betpawa.cm,1xbet")

for slug, book in hist["bookmakers"].items():
    snaps = book["markets"]["101"]["outcomes"]["101"]["players"]["0"]
    print(slug, len(book["markets"]), "markets,", len(snaps), "snapshots,",
          "opened", snaps[0]["createdAt"][:16])
# pinnacle   50 markets, 180 snapshots, opened 2026-08-24T10:11
# 1xbet      90 markets,  87 snapshots, opened 2026-08-24T09:12
# betpawa.cm 72 markets,  73 snapshots, opened 2026-08-24T06:54

Note the shape difference. The top level is bookmakers here, not bookmakerOdds, and players["0"] is a list of snapshots rather than a single price. Pinnacle repriced the home side twice as often as 1xBet on little more than half the menu, which is the pattern you would expect from a book that trades a market rather than parking it.

That one call returned 3.01 MB. Loop three slugs at a time, sleep about 4.5 seconds between calls, and filter createdAt < startTime when you want a true closing line, because snapshots keep recording into the second half.

What to build with this

Two things follow from the numbers. Dedupe first, because a 68% collapse means an unweighted average is really a vote by one desk repeated 27 times. Then widen, because the books that price these leagues tightest are not the ones with the marketing budget.

Egypt’s second division alone carries 360 loaded fixtures with a hundred bookmakers each. That is a season of training data for a league nobody is modelling.

Grab a free OddsPapi API key and run the census yourself. Twelve competitions, 264 distinct bookmakers, no sales call. If you want the same treatment applied to a market with a similar local-versus-global split, our Brasileirão breakdown found 10 Brazilian slugs collapsing to 3 genuinely independent prices, and the free odds API overview covers the rest of the catalogue.

Frequently Asked Questions

Which African football leagues does the OddsPapi API cover?

The soccer catalogue holds 55 African competitions, including the Egypt Premier League, Egypt 2. Division A, South Africa Premiership, Uganda Premier League, Tanzania Premier League, Algeria Ligue 1, Ghana Premier League, Kenya Premier League, Tunisia Ligue 1, Morocco Botola Pro, Zimbabwe Premier Soccer League, plus the CAF Champions League and CAF Confederations Cup. Query /v4/tournaments?sportId=10 and filter on categoryName for the current list.

How many bookmakers price an African football match?

On 2 September 2026, an Egypt Premier League fixture carried 254 bookmakers and a Uganda Premier League fixture carried 132. Across 12 sampled fixtures we saw 264 distinct bookmaker slugs. Dedupe on the price tuple before you use those counts, because 67% to 78% of the quotes on any one board repeat another slug’s exact prices.

Does Pinnacle price African leagues?

Yes, including the Uganda Premier League and Egypt’s second division. Its margin runs wider than on European football: 4.4% on the Egypt Premier League, 7.5% on the South Africa Premiership and 11.6% on a Uganda Premier League fixture, against roughly 3% on an English Premier League close. Forty books beat Pinnacle on that Uganda board, so build your fair-value anchor from the de-vigged consensus rather than from one book.

Which bookmaker gives the tightest African football odds?

betPawa CM ranked in the single digits on four of the eight boards we ranked, including 3rd of 128 on Algeria Ligue 1 and 4th of 100 on Egypt 2. Division A, where it posted a tighter overround than Pinnacle. The bigger international brands ranked far lower on the same fixtures, with bet365 at 231st of 243 and Betway at 240th on one Egypt Premier League match.

When do odds open for African football fixtures?

Roughly 36 hours before kick-off. On a South Africa Premiership match, the first recorded snapshots landed 31 to 35 hours out. Fixtures more than two days away return hasOdds: true with no bookmakers attached, so test coverage against a played fixture through /historical-odds rather than a live /odds call.

Is historical African football odds data free?

Yes. /v4/historical-odds is on the free tier and finished fixtures keep their full snapshot history after live prices drop. One South Africa Premiership fixture returned 3.01 MB across three bookmakers, with 180 Pinnacle snapshots on the home side alone. The endpoint caps at three bookmakers per call, so loop and merge.