VALUEODDSAPI / DEVELOPER RESOURCES
How to build an odds comparison: a developer guide
Showing the best price across bookmakers looks simple, until the same match has five different names and the best price is fourteen minutes old. This guide walks through the data model, the matching problems, working Python code and the checks to run before launch.
Published by ValueOddsAPI · 2 November 2026

In 30 seconds
- The core idea: flatten every bookmaker’s odds into one row per price, then pick the best fresh price per outcome.
- The hard part: matching the same match and market across bookmakers. Names, lines and rules all differ.
- The classic bug: showing a best price that is minutes old. Always hide stale prices.
- Sanity check: if the best prices add up to less than 100%, suspect a data mismatch before you suspect arbitrage.
New to odds data? Five terms first
- Odds comparison
- A page or app that shows the best available price for each outcome across bookmakers.
- Normalise
- Convert every bookmaker’s naming into one shared format.
- Line
- The number in a market, such as 2.5 in Over/Under 2.5 goals.
- Stale price
- A price that has not been confirmed recently and may no longer be available.
- Arbitrage
- Best prices that together guarantee a profit. Rare, short-lived and often a data error.
Start with one row per price
Whatever your source, flatten it into one row per price. Every later step, from matching to display, becomes a simple group-by on these fields.
| Field | Example | Why it matters |
|---|---|---|
| event_id | ars-che | One ID for the same match at every bookmaker. Getting this right is most of the work. |
| market | 1x2, total, handicap | A shared market name, regardless of what each bookmaker calls it. |
| line | 2.5, −1, none | Over 2.5 and Over 3.5 are different markets and must never be compared. |
| selection | home, over | A shared outcome name, from the same perspective at every bookmaker. |
| bookmaker | Bookmaker A | Where the price comes from. |
| odds | 2.18 | Decimal odds. Convert fractional or American odds on the way in. |
| updated_at | 18:00:15 UTC | When the price was last confirmed. Without it you cannot hide stale prices. |
Step 1: match events across bookmakers
Bookmakers write the same match differently: “Man Utd” and “Manchester United”, reversed word order, missing accents, “FC” prefixes, kick-off times a few minutes apart. Matching on raw names silently splits one match into several, or worse, merges two different ones.
Normalise names: lower-case, strip accents and words such as FC or SK. Keep an alias table for names that still differ. Require more than a name: the same competition, both teams and a kick-off within a tolerance. Watch the traps: women’s, U21 and reserve teams share names with the first team.
Do not auto-merge on a fuzzy score alone. Send uncertain matches to a review queue. A wrong match shows users a “best price” for a different game.
Step 2: normalise markets and lines
One bookmaker’s “Total goals” is another’s “Over/Under” or “Goals”. Map every raw market name to your shared list, and keep the raw name too so you can remap later.
Lines must match exactly: compare Over 2.5 only with Over 2.5. Handicaps need one sign convention, for example always from the home team’s side. Settlement rules differ: 90 minutes or including extra time, and how a non-starting player is treated in player markets. Prices with different rules are not comparable.
Some bookmakers also list the same label twice in one market. Keep both with an index rather than letting one overwrite the other.
Step 3: pick the best fresh price
With clean rows, the comparison itself is short. This example uses illustrative data, not a live feed. It drops prices older than five minutes, then keeps the highest price per selection.
from collections import defaultdict
from datetime import datetime, timedelta, timezone
# One row per price: illustrative data in a normalised shape
now = datetime(2026, 11, 2, 18, 0, tzinfo=timezone.utc)
rows = [
# event_id, market, line, selection, bookmaker, odds, updated_at
("ars-che", "1x2", None, "home", "Bookmaker A", 2.10, now - timedelta(seconds=40)),
("ars-che", "1x2", None, "home", "Bookmaker B", 2.18, now - timedelta(seconds=75)),
("ars-che", "1x2", None, "home", "Bookmaker C", 2.25, now - timedelta(minutes=14)),
("ars-che", "1x2", None, "draw", "Bookmaker A", 3.50, now - timedelta(seconds=40)),
("ars-che", "1x2", None, "draw", "Bookmaker B", 3.40, now - timedelta(seconds=75)),
("ars-che", "1x2", None, "away", "Bookmaker A", 3.60, now - timedelta(seconds=40)),
("ars-che", "1x2", None, "away", "Bookmaker B", 3.75, now - timedelta(seconds=75)),
("ars-che", "total", 2.5, "over", "Bookmaker A", 1.95, now - timedelta(seconds=40)),
("ars-che", "total", 2.5, "over", "Bookmaker B", 1.91, now - timedelta(seconds=75)),
("ars-che", "total", 2.5, "under", "Bookmaker A", 1.87, now - timedelta(seconds=40)),
("ars-che", "total", 2.5, "under", "Bookmaker B", 1.93, now - timedelta(seconds=75)),
]
MAX_AGE = timedelta(minutes=5)
def best_prices(rows, now):
"""Best fresh price per selection, grouped by event, market and line."""
markets = defaultdict(dict)
for event, market, line, selection, book, odds, updated in rows:
if now - updated > MAX_AGE:
continue # stale price: never show it as best
key = (event, market, line)
best = markets[key].get(selection)
if best is None or odds > best[0]:
markets[key][selection] = (odds, book)
return markets
for (event, market, line), sels in best_prices(rows, now).items():
book_sum = sum(1 / odds for odds, _ in sels.values())
label = market if line is None else f"{market} {line}"
print(f"{event} {label} (best-price book: {book_sum:.1%})")
for sel, (odds, book) in sels.items():
print(f" {sel:6} {odds:.2f} {book}")Output:
ars-che 1x2 (best-price book: 101.1%)
home 2.18 Bookmaker B
draw 3.50 Bookmaker A
away 3.75 Bookmaker B
ars-che total 2.5 (best-price book: 103.1%)
over 1.95 Bookmaker A
under 1.93 Bookmaker BBookmaker C’s 2.25 on the home win would have been the best price, but it was 14 minutes old. A stale best price is the most common way an odds comparison misleads its users: the price is gone when they click.
Step 4: sanity-check the result
Add up 1 ÷ odds for the best prices in a market. A normal result is slightly above 100%: 101.1% for the 1X2 above. Below 100% means the best prices together guarantee a profit, which is rare and short-lived.
When you see it, check first for a data problem: mismatched events, a different line, different settlement rules or a stale price. Most “arbitrage” in a new comparison product is a matching bug. Read more about the margin in how to remove the bookmaker margin.
Launch checklist
| Check | Why | Priority |
|---|---|---|
| Hide prices older than your limit | A stale best price is gone when users click. | Must |
| Show when each price was updated | Users trust a comparison they can verify. | Must |
| Review uncertain event matches | A wrong match shows a best price for the wrong game. | Must |
| Only show bookmakers allowed in each market | Many countries restrict promoting unlicensed gambling operators. This is not legal advice. | Must |
| Alert on best prices adding up to under 100% | Usually a data bug, sometimes arbitrage. | Should |
| Estimate refresh needs and request volume | Drives cost; try the request calculator. | Should |
Using ValueOddsAPI for this
ValueOddsAPI delivers odds already normalised into events, markets and selections, including bookmakers that many global feeds miss, such as Danske Spil, Bet25 and NordicBet. Business plans start at $2,000/month (Pro, 20 bookmakers). See odds data for publishers.
Frequently asked questions
What is the hardest part of building an odds comparison?
Matching the same event and market across bookmakers. Names, market labels, lines and settlement rules all differ, and a single wrong match shows users a best price for the wrong game or the wrong line.
How fresh do the prices need to be?
It depends on the product. For pre-match comparison a few minutes is often acceptable if you show the update time; for live betting content you need seconds. Always hide prices older than your limit.
Why do my best prices add up to less than 100%?
Either you found a genuine arbitrage, which is rare and short-lived, or the data is mismatched: a different event, line or settlement rule, or a stale price. Check the data first.
Can I show any bookmaker to any user?
No. Many countries restrict which gambling operators may be promoted to their residents. Check the rules for each market you serve before displaying or linking to a bookmaker.
Related reading
ValueOddsAPI provides odds data, not betting advice. Betting involves risk of loss and is for adults (18+) only. If gambling stops being fun, contact your local support service, such as StopSpillet in Denmark.
Get the data into your product
Solo $999/month (personal use), Pro $2,000, Scale $4,500, Elite $9,000 and Enterprise from $15,000/month, excluding VAT. Coverage and usage rights are confirmed before activation. Missing a bookmaker? We add the sources you need, scoped and priced per project.