If you’ve ever tried to build anything that touches soccer odds — a comparison page, a line-movement tracker, a value-bet screener — you already know the hard part isn’t fetching a number. It’s that odds aren’t a number. One match carries sixty-plus pre-match markets, and once the whistle blows, seventy to eighty in-play markets are moving at the same time. Half of them have a handicap line attached, the labels change between pre-match and in-play, and the whole tree reshapes itself every time a goal goes in.
This guide walks you through getting soccer odds from the AnySport API end to end: finding the match, pulling pre-match odds, pulling in-play odds, switching to WebSocket push for second-level updates, and turning the raw decimals into something useful like implied probability and movement alerts. Every request below uses real endpoints, real match IDs from the 2026 World Cup, and real response payloads. Copy, paste, run.
What you can build with soccer odds data
Odds are the highest-value data stream in soccer, and they power a surprisingly wide range of products:
- Odds comparison and display pages — show the 1X2, Asian handicap, and over/under lines next to your fixtures and live scores.
- Line-movement monitoring — track how a market drifts before kickoff or reacts mid-match, and alert users when a line moves sharply.
- Implied-probability analytics — convert odds into the market’s view of each outcome and chart it against form, xG, or your own model.
- Trading and arbitrage tools — in-play odds updated every second are the core input for any live trading workflow.
- Match preview content — enrich pre-match analysis articles with the current handicap, goal line, and correct-score prices.
- In-play engagement features — “next goal”, “next corner”, and “next 10 minutes” markets make live match pages far more interactive.
Before you start: five things to know
1. Pre-match and in-play are two separate streams
This is the single most important thing to understand. AnySport serves odds through two endpoints, and they are not the same feed in two states. They come from different upstream streams and have different structures:
Pre-match GET /odds/prematch | In-play GET /odds/live | |
|---|---|---|
phase | always prematch | always live |
| Update cadence | polling, check updated_at | second-level; use WebSocket |
| Market count | 63 market types | 70–80 active markets per match, ~45 bet types, 499 codes in the dictionary |
| Main market code | match_winner | fulltime_result |
| Option labels for win/draw/win | Home / Draw / Away | 1 / X / 2 |
| Where the handicap lives | embedded in the option name (Over 2.5, Home -1.25) | a separate markets[].handicap field |
| Market stability | stable | shrinks and re-lists dynamically; may be [] after full time |
| Plan required | Pro and above | Premium |
2. One match_id across everything
Every AnySport endpoint — fixtures, live score, match events, pre-match odds, in-play odds, and every WebSocket stream — uses the same as_-prefixed match ID. There’s no mapping table between “odds IDs” and “fixture IDs”. Find a match once, and that ID works everywhere.
3. Odds are decimal, bookmaker is bet365
All prices are decimal (European) odds. The bookmaker field is currently always bet365 on both streams. It’s there so the schema is ready for multi-bookmaker support without a breaking change, but don’t build a comparison UI that expects several books today.
4. market_code is an open enum
Market types are exposed as snake_case codes (match_winner, asian_handicap, goals_over_under, over_under_extra_time…). We add new markets over time, so your parser must skip or pass through unknown codes rather than throw.
5. Authentication and rate limits
The REST base URL is https://data.anysport.io/v1/soccer. Auth is a single header, apikey: YOUR_API_KEY Two rate limits apply at once, per second and per hour, and exceeding either returns 429:
| Plan | Per second | Per hour | Odds access |
|---|---|---|---|
| Pro (199 USDT/mo) | 3 | 10,000 | pre-match |
| Premium (399 USDT/mo) | 6 | 20,000 | pre-match + in-play + WebSocket |
The WebSocket endpoint is different: wss://api.anysport.io/v1/ws/live?api_key=YOUR_API_KEY. Note the api. host and the key in the URL — that’s the one place it goes there.
Where AnySport fits among soccer odds APIs
The market for soccer odds data splits into a few rough tiers, and it’s worth being honest about where each one makes sense:
| Tier | Examples | Good for | Trade-off |
|---|---|---|---|
| Enterprise official feeds | Sportradar / Betradar, Stats Perform | Licensed sportsbooks | Contract-based, high cost, onboarding requirements |
| General sports APIs with odds add-ons | API-Football, SportMonks | Broad coverage, many sports | Odds are polled, often a paid add-on, in-play depth varies |
| Odds aggregators | The Odds API, BetsAPI | Multi-book comparison | Limited in-play depth, or industry-only access |
| AnySport | — | Developers who need in-play depth + push | Single bookmaker source today, soccer only |
Coverage and pricing for other providers change frequently; check their sites for current figures. Where we’ve positioned AnySport is simple: a full in-play market tree, native WebSocket push, and one match_id across fixtures, scores and odds — at a price a small team can actually afford, paid in USDT with no sales call required.
Now let’s build.
Step-by-step: getting soccer odds from the AnySport API
Step 0 — Get a key and set up the client
Sign up at dashboard.anysport.io. Your API key is shown on the dashboard home page after login. Pre-match odds need the Pro plan; in-play odds and WebSocket need Premium.
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://data.anysport.io/v1/soccer"
HEADERS = {"apikey": API_KEY} # the only accepted auth method
def get(path, **params):
r = requests.get(f"{BASE_URL}{path}", params=params, headers=HEADERS, timeout=10)
r.raise_for_status()
body = r.json()
if not body.get("success"):
raise RuntimeError(f"API error, traceId={body.get('traceId')}")
return body["data"]Step 1 — Find the match_id
Odds are keyed by match, so the first call is always fixture discovery. The relative-window endpoint is the easiest entry point — no date math required:
# World Cup 2026 matches over the next 7 days
fixtures = get("/matches/window",
league_id="asl_62db9cfc3ebf4b87a44570b16668b23e",
from_days=0, to_days=7, limit=100)
for m in fixtures:
print(m["match_id"], m["date"], m["time"], m["status"],
m["home"]["name"], "vs", m["away"]["name"])Sample output:
as_37f0da0520bb4e9a9588f80994cefc5b 2026-07-15 19:00 19:00 England vs Argentina
as_3b37c382f39a4235aa93705f053b2da4 2026-07-11 20:00 67 Argentina vs Switzerland⚠️ Gotcha —
statushas three shapes. A text value likeFT/HT/Not Started, anHH:MMvalue meaning “not started, kicks off at this UTC time”, or a plain number meaning “in progress, this is the current minute”. So19:00above means the semi-final hasn’t started (pre-match odds territory), and67means the quarter-final is in the 67th minute (in-play odds territory). All times are UTC; combinedate+timeand convert locally.
Step 2 — Get pre-match odds
Query by match_id for one match, or by league name in bulk. Here’s England vs Argentina, the World Cup semi-final:
def prematch_odds(match_id):
rows = get("/odds/prematch", match_id=match_id)
return rows[0] if rows else None
o = prematch_odds("as_37f0da0520bb4e9a9588f80994cefc5b")
print(f"{o['home']} vs {o['away']} "
f"1X2: {o['home_odd']} / {o['draw_odd']} / {o['away_odd']} "
f"({len(o['markets'])} markets, updated {o['updated_at']})")Response (excerpt of 59 markets):
{
"match_id": "as_37f0da0520bb4e9a9588f80994cefc5b",
"home": "England",
"away": "Argentina",
"bookmaker": "bet365",
"phase": "prematch",
"main_market": "match_winner",
"home_odd": 2.62,
"draw_odd": 3.0,
"away_odd": 3.0,
"markets": [
{
"market_code": "match_winner",
"options": [
{ "name": "Home", "odd": 2.62 },
{ "name": "Draw", "odd": 3.0 },
{ "name": "Away", "odd": 3.0 }
]
},
{
"market_code": "home_away",
"options": [
{ "name": "Home", "odd": 1.73 },
{ "name": "Away", "odd": 2.0 }
]
},
{
"market_code": "asian_handicap",
"options": [
{ "name": "Home -1.25", "odd": 4.8 },
{ "name": "Away +1.25", "odd": 1.18 },
{ "name": "Home -1", "odd": 4.4 }
]
}
],
"updated_at": "2026-07-15T14:02:11Z"
}The top-level home_odd / draw_odd / away_odd are a shortcut to the main market. Everything else lives in markets[].
Reading handicap markets in pre-match. Notice the Asian handicap above: there is no handicap field. The line is part of the option name, and one market carries several lines at once (Home -1.25, Home -1, …). The same applies to goals_over_under (Over 2.5, Under 2.5, Over 1.5…), corners, and cards. You need to pull the number out of the string:
import re
NUM = re.compile(r"([+-]?\d+(?:\.\d+)?)\s*$")
def split_prematch_option(name):
"""'Home -1.25' -> ('Home', -1.25); 'Over 2.5' -> ('Over', 2.5); 'Draw' -> ('Draw', None)"""
m = NUM.search(name)
if not m:
return name.strip(), None
return name[:m.start()].strip(), float(m.group(1))
o = prematch_odds("as_37f0da0520bb4e9a9588f80994cefc5b")
for mk in o["markets"]:
if mk["market_code"] == "goals_over_under":
for opt in mk["options"]:
side, line = split_prematch_option(opt["name"])
print(side, line, opt["odd"]) # Over 2.5 1.8 / Under 2.5 2.0 / ...⚠️ Gotcha — pre-match handicap lines are in the option name, never at the market level. If you’re building a “pick the main line” feature, group options by the parsed line value first. Asian handicap options come in pairs with opposite signs (
Home -1.25/Away +1.25), so group by absolute value.
Step 3 — Get in-play odds via REST
Once the match is live, switch to /odds/live. Called without a match_id, it returns every match that currently has active in-play markets — a handy “what’s live right now” discovery call. With a match_id, you get every active market for that match:
def live_odds(match_id=None):
return get("/odds/live", **({"match_id": match_id} if match_id else {}))
lo = live_odds("as_3b37c382f39a4235aa93705f053b2da4")[0]
print(f"{lo['home']} vs {lo['away']} 1X2: {lo['home_odd']} / {lo['draw_odd']} / {lo['away_odd']}")
for mk in lo["markets"]:
line = f" @ {mk['handicap']}" if "handicap" in mk else ""
opts = ", ".join(f"{opt['name']}={opt['odd']}" for opt in mk["options"])
print(f"[{mk['market_code']}{line}] {opts}")Response (Argentina vs Switzerland during extra time, 31 markets in this frame, excerpt):
{
"match_id": "as_3b37c382f39a4235aa93705f053b2da4",
"home": "Argentina",
"away": "Switzerland",
"league": "World Cup 2026",
"bookmaker": "bet365",
"phase": "live",
"main_market": "fulltime_result",
"home_odd": 13.0,
"draw_odd": 1.04,
"away_odd": 51.0,
"markets": [
{
"market_code": "over_under_extra_time",
"handicap": 4.5,
"options": [
{ "name": "Over", "odd": 101 },
{ "name": "Under", "odd": 1 }
]
},
{
"market_code": "anytime_goal_scorer",
"options": [
{ "name": "Leandro Paredes", "odd": 5 },
{ "name": "Denis Zakaria", "odd": 5.5 },
{ "name": "Granit Xhaka", "odd": 6 }
]
}
],
"updated_at": "2026-07-11T21:52:40Z"
}Three things are different from pre-match, and all three will bite you if you reuse the pre-match parser:
⚠️ Gotcha — in-play puts the line in
markets[].handicap, and omits the field entirely when the market has no line. It’s absent, notnull. Use"handicap" in mk, notmk["handicap"] is not None. For over/under it’s the total line (2.5), for Asian handicap it’s from the home side’s perspective (-0.5= home gives half a goal), and for “next N minutes” markets it’s the starting minute of the window.⚠️ Gotcha — in-play labels are
1/X/2, notHome/Draw/Away. Same outcome, different string. Double chance is1X/X2/12. Corner-race markets also use1/2, so always route bymarket_codebefore interpreting an option label.⚠️ Gotcha — the market tree shrinks. Markets get suspended and re-listed after every goal, correct-score options are trimmed to scores that are still possible, the count falls sharply near full time, and after the final whistle
marketscan be an empty array. Your UI must handle a market disappearing between two frames.
One more, specific to the “next 10 minutes” family:
⚠️ Gotcha — double space in option names. Markets like the next-10-minute multi-metric over/under have option names such as
Goals/Over 0.5— two spaces betweenOverand0.5, with the line embedded in the name. Split with\s+, never with a literal single space.
Step 4 — Get real-time in-play odds over WebSocket
Polling /odds/live at 3–6 requests per second doesn’t scale past a handful of matches, and it still lags the market. For anything real-time, subscribe over WebSocket and let us push every change. The flow is: open the socket, send a subscribe message with code 20100, wait for the 20101 ack, then process odds_live_update pushes. Send a heartbeat (20010) every 30 seconds or the server closes the connection at 60.
Here’s a complete Node.js client (the ws package):
import WebSocket from "ws";
import { randomUUID } from "crypto";
const API_KEY = process.env.ANYSPORT_API_KEY;
const MATCH_ID = "as_3b37c382f39a4235aa93705f053b2da4";
function connect() {
const ws = new WebSocket(`wss://api.anysport.io/v1/ws/live?api_key=${API_KEY}`);
let heartbeat;
ws.on("open", () => {
// Subscribe to in-play odds for one match.
// Omit match_id (or pass null) to receive every live match your plan covers.
ws.send(JSON.stringify({
code: 20100,
trace: randomUUID(),
data: { match_id: MATCH_ID },
}));
heartbeat = setInterval(() => {
ws.send(JSON.stringify({ code: 20010, trace: randomUUID() }));
}, 30_000);
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.code === 20101) return console.log("subscribed to in-play odds");
if (msg.code === 40301) return console.error("plan does not include in-play odds:", msg.msg);
if (msg.type === "error") return console.error("connection error:", msg.message);
if (msg.type === "odds_live_update") {
const d = msg.data;
console.log(
`${d.home_team} vs ${d.away_team} ${d.main_market}: ` +
`${d.home_odd} / ${d.draw_odd} / ${d.away_odd} @ ${d.source_updated_at}`
);
// d.markets_json holds the full market tree for this frame
}
});
ws.on("close", () => {
clearInterval(heartbeat);
console.log("closed, reconnecting in 3s");
setTimeout(connect, 3_000); // subscriptions do not survive a reconnect
});
ws.on("error", (err) => console.error("socket error", err.message));
}
connect();A push frame looks like this:
{
"type": "odds_live_update",
"data": {
"type": "odds",
"match_id": "as_3b37c382f39a4235aa93705f053b2da4",
"home_team": "Argentina",
"away_team": "Switzerland",
"league": "World Cup 2026",
"match_time": "2026-07-11T20:00:00Z",
"bookmaker": "bet365",
"phase": "live",
"main_market": "1X2",
"home_odd": "13.00",
"draw_odd": "1.04",
"away_odd": "51.00",
"markets_json": { "...": "full market tree" },
"source_updated_at": "2026-07-11T21:52:40Z",
"push_ts": "2026-07-11T21:52:40.123Z",
"received_at": "2026-07-11T21:52:40.200Z"
},
"timestamp": "2026-07-11T21:52:40.250Z"
}⚠️ Gotcha — the WebSocket payload is not the REST payload. Team names are
home_team/away_team(REST:home/away), the main market is1X2(REST:fulltime_result), and the odds fields arrive as strings ("13.00") rather than numbers. Normalize at the boundary before feeding either source into the same model.⚠️ Gotcha — a
40301ack does not close the connection. It means your plan doesn’t cover this product or the key has expired. Fix the plan and resend the subscribe message; no reconnect needed. Sending the same subscribe code again with new filters also just overwrites the old filter.
Want pre-match odds pushed too? Same connection, code 20300, push type odds_prematch_update, identical payload with phase: "prematch". And you can subscribe to live scores (20200) and match situation (20000) on the same socket, so a live betting page runs on one connection and one match_id.
Step 5 — Turn odds into something useful
Getting the odds is half the job. Here are the two transforms almost every odds product needs.
Normalize both streams into one shape. Because labels and handicap positions differ, write a single normalizer so the rest of your code never cares which stream a market came from:
LABEL_MAP = {"Home": "1", "Draw": "X", "Away": "2"} # pre-match -> in-play convention
def normalize_market(mk, phase):
"""Return {market_code, options: [{side, line, odd}]} for both streams."""
out = []
for opt in mk["options"]:
if phase == "prematch":
side, line = split_prematch_option(opt["name"])
side = LABEL_MAP.get(side, side)
else:
side, line = opt["name"], mk.get("handicap")
out.append({"side": side, "line": line, "odd": float(opt["odd"])})
return {"market_code": mk["market_code"], "options": out}Implied probability and the bookmaker margin. 1 / decimal_odd is the implied probability including the bookmaker’s overround. Summing the three 1X2 probabilities gives you a number above 1; the excess is the margin, and dividing through gives the market’s “fair” probabilities:
def implied(home_odd, draw_odd, away_odd):
raw = {"1": 1 / home_odd, "X": 1 / draw_odd, "2": 1 / away_odd}
overround = sum(raw.values())
fair = {k: v / overround for k, v in raw.items()}
return fair, overround - 1
fair, margin = implied(2.62, 3.0, 3.0) # England vs Argentina pre-match
print({k: f"{v:.1%}" for k, v in fair.items()}, f"margin {margin:.1%}")
# {'1': '36.4%', 'X': '31.8%', '2': '31.8%'} margin 4.8%Line-movement alerts. With WebSocket push you already have a stream of frames; keep the last main-market price per match and emit an alert when the implied probability moves more than a threshold:
last = {}
def on_odds_frame(match_id, home_odd, draw_odd, away_odd, threshold=0.03):
fair, _ = implied(float(home_odd), float(draw_odd), float(away_odd))
prev = last.get(match_id)
last[match_id] = fair
if prev:
for side in ("1", "X", "2"):
delta = fair[side] - prev[side]
if abs(delta) >= threshold:
print(f"{match_id} {side}: {prev[side]:.1%} -> {fair[side]:.1%} ({delta:+.1%})")Because pre-match and in-play share the same match_id, you can also compare the closing pre-match line to the first in-play frame — the basis of most closing-line-value analysis — without any ID reconciliation.
Gotcha cheat sheet
| # | Gotcha | What to do |
|---|---|---|
| 1 | Two label systems | Pre-match Home/Draw/Away, in-play 1/X/2. Map them in one normalizer. |
| 2 | Handicap position | Pre-match: parse from the option name. In-play: markets[].handicap. |
| 3 | Missing handicap | Field is omitted, not null. Test with "handicap" in mk. |
| 4 | Shrinking market tree | Markets suspend, re-list, and vanish; markets may be [] after full time. |
| 5 | Double space | “Next N minutes” option names contain two spaces. Split on \s+. |
| 6 | market_code grows | Open enum. Skip unknown codes, never throw. |
| 7 | WebSocket ≠ REST | home_team/away_team, main_market: "1X2", odds as strings. |
| 8 | WebSocket host and key | wss://api.anysport.io/...?api_key= — the only place the key goes in a URL. |
| 9 | Heartbeat | Send 20010 every 30 s; 60 s of silence closes the connection. |
| 10 | Reconnect | Subscriptions don’t persist. Resubscribe after every reconnect. |
| 11 | 40301 | Plan doesn’t cover the product. Connection stays open; fix and resend. |
| 12 | bookmaker | Currently always bet365. Multi-book is a future field, not a current feature. |
| 13 | Rate limits | Per-second and per-hour, either one returns 429. Cache fixtures; push for odds. |
| 14 | league naming | Can differ between the two odds streams. Join on match_id, never on the league name. |
Summary
Getting real-time soccer odds from AnySport comes down to three steps and one ID:
- Discover the match with
/matches/windowand keep itsas_ID. - Pull pre-match odds from
/odds/prematch(63 markets, lines inside option names,Home/Draw/Away). - Go live with
/odds/livefor a snapshot and WebSocket code20100for second-level push (70–80 markets,markets[].handicap,1/X/2, shrinking tree).
Get the normalizer from Step 5 right, respect the fourteen gotchas above, and the data layer of your odds product is done. What’s left is the interesting part: the model, the alerts, the UI.
Start today. Sign up at anysport.io for a free 7-day trial to get the fixtures and live-score pipeline running, then move to Pro for pre-match odds or Premium for in-play odds and WebSocket push. All plans are billed in USDT with no contract.