Soccer API

bet365 soccer odds feed

Start with GET /odds to get the matches and current prices. Then connect to /ws to keep receiving odds updates.

1. Load all matches and their odds

curl https://soccer.162-35-121-154.sslip.io/odds

Example response:

{
  "matches": [{
    "id": "123456",
    "home": "Team A",
    "away": "Team B",
    "league": "Example League",
    "kickoff_utc": "2026-09-11T18:00:00Z",
    "in_play": false,
    "quotes": [{
      "fixture_id": "123456",
      "group_id": "981",
      "market_id": "100",
      "sel_id": "9001",
      "label": "Team A",
      "odds_dec": 2.5,
      "ts": "2026-09-11T12:00:00Z"
    }]
  }]
}

Save each match under its id, along with its names and odds

If quotes is empty, there are no cached odds for that match yet

Only need part of the board? /odds?in_play=1 returns just the matches in play, and /odds?groups=40 returns just the markets you list (comma-separated group ids; Full Time Result is 40 before kickoff and 1777 in play). Each match always carries market_count, the number of markets priced for it.

If the response has "warming": true (and few or no matches), the scraper was just (re)started and is still loading the board. Wait ~30 seconds and fetch again — matches and odds arrive within a couple of minutes.

2. Subscribe to all matches on one WebSocket

wss://soccer.162-35-121-154.sslip.io/ws

Once the connection opens, send:

{"op":"subscribe"}

Leave out fixtures and groups to get updates for all matches and markets. New matches are included as their odds come in.

You'll first get a snapshot for each match with cached odds. After that, delta messages arrive whenever quotes change. Matches without cached odds won't have a snapshot yet.

3. Apply each message to your match list

The fixture value tells you which match to update. It matches the id from the first request.

{
  "type": "delta",
  "fixture": "123456",
  "quotes": [{
    "fixture_id": "123456",
    "group_id": "981",
    "market_id": "100",
    "sel_id": "9001",
    "label": "Team A",
    "odds_dec": 2.6,
    "ts": "2026-09-11T12:00:05Z"
  }]
}

When a snapshot comes in, use it as the match's new odds list. A delta only contains changes, so update or add those quotes without clearing the rest

Within each match, use [group_id, market_id, sel_id] to identify a quote. If market_id is missing, use "".

Replace the whole quote when an update arrives. If suspended is missing, treat it as false.

4. Handle new matches and reconnects

Check /catalog about once a minute for updated names, kickoff times and the current match list. Keep the odds you already have, and remove matches that have left the catalog.

If odds arrive for a match you haven't seen yet, save them under its ID. The next catalog refresh will give you its details.

If the socket disconnects or you get a lagged message, fetch /odds again and reconnect. Wait a little longer after each failed retry. Missed updates can't be replayed.

Two timestamps tell you different things. ts is when a price last changed. polled_at (per fixture, on /odds and /snapshot) is when we last successfully read fresh prices for that fixture — whether or not any price moved. To judge whether a match is still being priced, compare polled_at to server_time, not ts: a stable price (normal for pre-match and far-off matches) keeps an old ts while it is being polled fine, so a large server_time − ts by itself does not mean stale. polled_at advances only on a successful price read — a fixture temporarily returning nothing, or one that is suspended, shows an ageing polled_at, the honest signal that we have not been able to re-confirm it. polled_at is not carried on the /ws frames; a WS consumer wanting a per-fixture liveness check should re-fetch /snapshot?fi=<id> for that match. Suspended quotes are unavailable. While disconnected, mark your saved prices as stale.

5. Price a bet builder

POST /betbuilder/price returns bet365's own Bet Builder (same-game multi) price for a set of legs on one match. bet365 correlates the legs, so the price is not the legs' odds multiplied together. Pre-match matches only. Send up to 50 slips per request, each with 2–12 legs; one request may mix slips from different matches.

Find your legs in /odds

A leg is a quote's sel_id from the match's quotes in /odds. Find it by group_name (the market) plus the selection fields, which depend on the market:

Market typeSelection is inExample
Result-style (Full Time Result, Both Teams To Score, Double Chance, Draw No Bet, Half Time Result, Total Corners)labelgroup_name "Both Teams To Score", label "Yes"; "Total Corners", label "Under 6"
Totals (Goals Over/Under, Alternative Total Goals, Match Goals, Corners)side = Over/Under, line = the numberside "Over", line "2.5"
Correct Scorelabel = the score, side = the team it favourslabel "1-0"
Team Total Goalsside = the team, label = Over/Under and the lineside "Arsenal", label "Over 1.5"
Team Goals Rangeline = the team, handicap = the range, side = Yes/Noline "Wales", handicap "2-4", side "Yes"
Player props (Shots, Shots On Target)line = the player name, side = the thresholdline "Aitana Bonmati", side "2+"

Most quotes have no label, so check which fields a market fills before mapping it. sel_ids belong to one match and can change, so re-read /odds before each pricing run rather than storing them for days.

Request and response

curl -X POST https://soccer.162-35-121-154.sslip.io/betbuilder/price   -H 'Content-Type: application/json' -H 'X-API-Key: <your key>'   -d '{"slips":[{"fixture":"201750116","legs":["63643981","91275799"]}]}'
{"server_time":"2026-09-30T05:10:00Z","results":[{
  "fixture":"201750116","home":"Roma (W)","away":"Barcelona (W)",
  "legs":[{"sel_id":"63643981","market":"Full Time Result","label":"Barcelona (W)","odds_dec":1.083},
          {"sel_id":"91275799","market":"Both Teams To Score","label":"Yes","odds_dec":1.8}],
  "status":"priced","odds_dec":2.15,"odds_frac":"23/20","naive_odds_dec":1.95,
  "priced_at":"2026-09-30T05:10:00Z"}]}

Results come back in request order, one per slip. The bet builder price is only ever odds_dec / odds_frac on a priced slip. Each leg's own odds_dec is that single selection's price, and naive_odds_dec (priced slips only) is the singles multiplied, for comparison; never bet at either. priced_at is when bet365 answered.

statusMeaningWhat to do
pricedbet365's current price for this exact combination.Use odds_dec.
unavailablebet365 declined this combination right now: suspended, contradictory legs, or a leg that can't be combined.Try later or change the legs.
errorNot priced; error says why.If retryable is true (upstream hiccup, markets still loading, busy), retry in a minute. If false (invalid slip, unknown match or leg, match already started), retrying won't help: fix the slip or re-read /odds.

A request counts once against the rate limit however many slips it carries, so batch your slips.

Reference
FieldUse
home, away, leagueMatch names and competition, supplied by /odds and /catalog.
group_id, group_nameMarket group ID and optional display name.
market_id, sel_idSub-market and selection IDs. Keep IDs as strings.
labelSelection name. Use IDs for storage keys, not labels.
line, handicap, side, n2Optional market-specific details.
odds_dec, odds_fracDecimal price and optional fractional price.
suspendedTrue means unavailable. Missing means false.
tsWhen the price last changed, in UTC. An unchanged price keeps its old ts, so a large server_time − ts means "hasn't moved", not "stale". For liveness use polled_at.
polled_atWhen we last successfully read fresh prices for this fixture (whether or not any price moved), in UTC. Per fixture on /odds and /snapshot only — not on /ws frames; absent until the match is first priced. An empty/failed poll or a suspended market does not advance it, so a rising server_time − polled_at means "not re-confirmed lately". WS consumers: re-fetch /snapshot?fi=<id> for a per-fixture liveness check.
last_seen, last_live_seenCatalog bookkeeping on /odds and /catalog — not price freshness. last_seen = the last catalog sweep that listed this match; last_live_seen = the last time it appeared in bet365's in-play index. For how fresh the odds are, use polled_at and ts, not these.
server_timeThe server's UTC clock at send time — on /odds and /snapshot it is the moment of the response; on WS frames it is the batch time (≈ send time). Compute ages without trusting your own clock: server_time − ts = time since the price last moved; server_time − polled_at = time since we last re-confirmed the fixture's prices (the staleness signal, on /odds//snapshot).

/odds shares a 30 requests/minute per-IP limit with the upstream-fetch routes. WSS allows five simultaneous connections per IP. Two different back-pressure responses can occur, so handle both: a per-IP rate-limit returns 503 (from the front proxy; the body may be HTML, not JSON), and the upstream-fetch routes (/markets, /quotes, /fixtures, /inplay) return 429 with a Retry-After header and a JSON {"error":…} body when the server is at its global live-fetch capacity. On either, back off and retry — or prefer the cached /snapshot, /odds and /ws feeds, which do no upstream fetch and are never subject to the 429 cap. Always check the HTTP status before parsing the body as JSON.

Send {"op":"ping"} to receive {"type":"pong"}. Send {"op":"unsubscribe"} to stop updates. Protocol errors arrive as {"type":"error","error":"..."}.

Coverage depends on discovered fixtures and cached markets. Quote removals are not fully represented in the stream; reload snapshots when reconciling stored data. Caches start empty after server restarts.