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/prematchIn-play GET /odds/live
phasealways prematchalways live
Update cadencepolling, check updated_atsecond-level; use WebSocket
Market count63 market types70–80 active markets per match, ~45 bet types, 499 codes in the dictionary
Main market codematch_winnerfulltime_result
Option labels for win/draw/winHome / Draw / Away1 / X / 2
Where the handicap livesembedded in the option name (Over 2.5, Home -1.25)a separate markets[].handicap field
Market stabilitystableshrinks and re-lists dynamically; may be [] after full time
Plan requiredPro and abovePremium

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:

PlanPer secondPer hourOdds access
Pro (199 USDT/mo)310,000pre-match
Premium (399 USDT/mo)620,000pre-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:

TierExamplesGood forTrade-off
Enterprise official feedsSportradar / Betradar, Stats PerformLicensed sportsbooksContract-based, high cost, onboarding requirements
General sports APIs with odds add-onsAPI-Football, SportMonksBroad coverage, many sportsOdds are polled, often a paid add-on, in-play depth varies
Odds aggregatorsThe Odds API, BetsAPIMulti-book comparisonLimited in-play depth, or industry-only access
AnySport—Developers who need in-play depth + pushSingle 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 — status has three shapes. A text value like FT / HT / Not Started, an HH:MM value meaning “not started, kicks off at this UTC time”, or a plain number meaning “in progress, this is the current minute”. So 19:00 above means the semi-final hasn’t started (pre-match odds territory), and 67 means the quarter-final is in the 67th minute (in-play odds territory). All times are UTC; combine date + time and 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, not null. Use "handicap" in mk, not mk["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, not Home / Draw / Away. Same outcome, different string. Double chance is 1X / X2 / 12. Corner-race markets also use 1 / 2, so always route by market_code before 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 markets can 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 between Over and 0.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 is 1X2 (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 40301 ack 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

#GotchaWhat to do
1Two label systemsPre-match Home/Draw/Away, in-play 1/X/2. Map them in one normalizer.
2Handicap positionPre-match: parse from the option name. In-play: markets[].handicap.
3Missing handicapField is omitted, not null. Test with "handicap" in mk.
4Shrinking market treeMarkets suspend, re-list, and vanish; markets may be [] after full time.
5Double space“Next N minutes” option names contain two spaces. Split on \s+.
6market_code growsOpen enum. Skip unknown codes, never throw.
7WebSocket ≠ RESThome_team/away_team, main_market: "1X2", odds as strings.
8WebSocket host and keywss://api.anysport.io/...?api_key= — the only place the key goes in a URL.
9HeartbeatSend 20010 every 30 s; 60 s of silence closes the connection.
10ReconnectSubscriptions don’t persist. Resubscribe after every reconnect.
1140301Plan doesn’t cover the product. Connection stays open; fix and resend.
12bookmakerCurrently always bet365. Multi-book is a future field, not a current feature.
13Rate limitsPer-second and per-hour, either one returns 429. Cache fixtures; push for odds.
14league namingCan 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:

  1. Discover the match with /matches/window and keep its as_ ID.
  2. Pull pre-match odds from /odds/prematch (63 markets, lines inside option names, Home/Draw/Away).
  3. Go live with /odds/live for a snapshot and WebSocket code 20100 for 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.