Odds API

bet365 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://moviodds.com/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.

Soccer is the default. Add sport for any of the other 23 sports, for example /odds?sport=tennis, or sport=all for every sport at once; every match carries its own sport field. The keys and each sport's main markets are on the sports page.

A match in play carries a score field. It is always written home-away, the home side first, for every sport (for example "2-1"). Cricket is the one that does not use a plain number-to-number score: it uses bet365's own wickets/runs form, for example "7/199" (7 wickets, 199 runs), with each innings separated by a comma.

Want a link straight to the match on bet365? Add /odds?links=1 and every match gains a links object: match (open this: the live page while the match is in play, otherwise the pre-match page), prematch and live. They are bet365's own in-browser routes, so the same link works on any regional bet365 domain. It's opt-in so the default board stays small. To find a specific selection on that page, use the quote's sel_id.

If the response has "warming": true (and few or no matches), the service has just restarted 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://moviodds.com/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.

A remove message tells you a market has closed or the match has left the board, so those selections are no longer bettable. {"type":"remove","fixture":"123456","quotes":[{"group_id":"981","market_id":"100","sel_id":"9001"}]} means drop those selections; {"type":"remove","fixture":"123456"} with no quotes means the whole match is gone, so drop it. This is the signal that a WebSocket-only client used to miss: without it the server simply stops sending and a stale last price looks bettable. If your client does not handle the remove type, you still stop betting those selections anyway: just before each remove we send the same selections in a delta marked suspended, which an older client applies as a normal update.

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. The socket also pushes a remove (see section 3) the moment a market closes or a match leaves the board, so you do not have to wait for the next catalog refresh to drop a stale price.

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 a sport looks empty, check last_fixture_seen_by_sport on /health before assuming it is broken. It maps each sport to the most recent time we saw one of its fixtures, so a recent timestamp means we are still reading that sport and it is just a quiet off-week, while a stale or missing one points to a real problem worth reporting.

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 still being checked, so a large server_time − ts by itself does not mean stale. polled_at advances whenever we read a fixture's current prices, including when every price is suspended (a suspended market is still a successful read). Live matches are re-confirmed on a short cadence even when nothing is moving, so a suspended or quiet match keeps a recent polled_at. Only an empty or failed read leaves it where it was. 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://moviodds.com/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 (a temporary error, markets still loading, or 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.

6. Results of finished matches

GET /results?sport=<key> returns the matches that have finished, newest first, with the final score and, where the sport exposes them, the per-period scores (halves for soccer, sets for tennis, table tennis, volleyball and badminton, quarters for basketball and American football, periods for ice hockey, innings for baseball). Add &date=YYYY-MM-DD (UTC) to limit to one day, or &fi=<match id> for one match.

curl 'https://moviodds.com/results?sport=soccer'
{"sport":"soccer","count":1,"results":[{
  "fixture_id":"201150821","sport":"soccer","home":"Kosovo","away":"Austria",
  "league":"UEFA Nations League B","final_score":"1-0","status":"finished",
  "periods":[{"name":"1st Half","score":"0-0"},{"name":"2nd Half","score":"1-0"}],
  "finished_at":"2026-10-04T19:40:00Z"}]}

Each result's status is finished when the match reached full time (a player or side won the required sets, or baseball reached the 9th inning). It is unknown when the match stopped before that, usually because it was abandoned, suspended or postponed, or the sport has no finish model; the last score is still returned. finished_at is the last time the match was seen live, which is close to when it actually ended. When a match was decided beyond regulation the result adds "overtime":true, and for a tied ice-hockey score it adds a note that the shoot-out or overtime winning goal is not part of bet365's score feed.

Notes on specific sports:

A match that was only ever seen at 0-0 in its opening period, with no scoring and no period change, is treated as a bet365 placeholder that never really started and is not returned as a result.

Reference
FieldUse
home, away, leagueMatch names and competition, supplied by /odds and /catalog.
in_playTrue while the match is actually under way. A match bet365 lists on a court/table but has not started is not in play, and a finished match is dropped from in play once bet365 stops streaming it live or the results tracker records it finished — so in_play tracks real live play, not a stale "started" flag.
scoreLive scoreline while in play, in home-away order. One pair for goal/point sports ("1-0"). For the set sports it is the full set list: completed sets then the current one — tennis "6-4,2-3" (games), and table tennis, volleyball and badminton combine the completed games with the current game's points, e.g. "11-7,6-11,12-14,11-9,2-6" (four games won, current game 2-6). Absent before kickoff and when bet365 sends none.
linksbet365 deep links for the match, only when the request sent ?links=1. match is the one to open (live page in play, else pre-match page); prematch and live are the specific routes. Same link on any regional bet365 domain. Use a quote's sel_id to find a selection on the page.
group_id, group_nameMarket group ID and its display name. A group ID is global, so its group_name is the one stable English name for that market across every match, even when bet365's own feed labels it differently or in another language on some matches. It is absent only for a market bet365 never names anywhere.
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. A suspended market still advances it (a suspended price is a successful read), and live matches are re-confirmed on a short cadence even while quiet; only an empty or failed read 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_seenWhen the match was last listed (last_seen) and last seen in play (last_live_seen). For how fresh the odds are, use polled_at and ts.
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 allows 300 requests per minute per IP, and the WebSocket 25 connections per IP. Over the limit you get 503 (the body may be HTML, not JSON). /markets, /quotes, /fixtures and /inplay can also return 429 with a Retry-After header when the server is busy. In both cases back off and retry, and use /odds, /snapshot and the WebSocket where you can. 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":"..."}.

If you keep your own copy of the odds, reload /odds after reconnecting so markets that closed while you were away are dropped.

7. Historical odds and closing lines

Every price change we record is kept, so you can pull a selection's whole price timeline and its closing line after a match. Two read-only endpoints, limited to 120 requests per minute per IP.

A match can have a separate live id from its pre-match id. Both endpoints accept either one and return the whole record; fixture_ids lists the ids included and prematch_id names the pre-match one.

Price timeline: GET /history

GET /history?fi=<match> returns the recorded price points for a match, oldest first. Narrow it with group (a market-group id), sel (a selection id), and a from/to time window (RFC3339 like 2026-10-04T12:00:00Z, or Unix seconds). Results are capped at limit rows (default 5000, max 50000); the newest rows within your window are kept, so pass a from/to or a sel to page back through a busy match.

curl 'https://moviodds.com/history?fi=201150680&sel=63643981&from=2026-10-04T00:00:00Z'
{"fixture_id":"201150680","fixture_ids":["201150680"],"count":3,"truncated":false,"limit":5000,"sel":"63643981","points":[
  {"ts":"2026-10-04T15:06:11Z","group_id":"40","group_name":"Full Time Result","market_id":"","sel_id":"63643981","label":"Home","odds_dec":2.00,"odds_frac":"1/1","suspended":false},
  {"ts":"2026-10-04T16:31:11Z","group_id":"40","group_name":"Full Time Result","sel_id":"63643981","label":"Home","odds_dec":1.90,"odds_frac":"10/11","suspended":false},
  {"ts":"2026-10-04T16:46:11Z","group_id":"40","group_name":"Full Time Result","sel_id":"63643981","label":"Home","odds_dec":3.50,"odds_frac":"5/2","suspended":false}]}

Totals and handicap markets add the fields that pin the number: side ("O"/"U", or "H"/"A"), line and handicap (e.g. "side":"O","line":"2.5" for Over 2.5), and n2 (the second participant, where a market names one).

Each point carries ts (when that price was captured, UTC), the selection identity, odds_dec, and suspended. The identity fields are the same ones /odds quotes carry, so a point maps to a team/side/line without re-fetching /odds: group_id and group_name (the market), market_id, sel_id, label, and — on markets that use them — side, line, handicap and n2. Each identity field is present only when it has a value. odds_frac is a derived display fraction: the archive stores the decimal price, so treat odds_dec as authoritative. truncated is true when you hit the row cap; add a tighter window or filter and page back. fixture_ids lists every id the timeline was merged from; when it holds more than one, each point also carries a fixture_id telling you which id recorded it.

Closing line: GET /closing

GET /closing?fi=<match> returns, per selection, its pre-match close (pre_close: the last price recorded for it before kickoff, taken from the pre-match record) and its last recorded price (last: the final in-play price for a settled match, taken from whichever id recorded it last). Selections are keyed by (group_id, market_id, sel_id); bet365 sometimes prices a winner market under one group id before kickoff and another in play, so a market's pre_close and last can appear on two selection rows: the pre_close always comes from the pre-match group.

Each selection row, and the pre_close/last points inside it, carry the same selection-identity fields as /odds and /history — group_name, and side, line, handicap, n2 where the market uses them — so a row maps to a team/side/line without a /odds round-trip. Each is present only when it has a value.

pre_close_basis says how the close was found: "kickoff" (the last price at or before the scheduled kickoff), "prematch-records" (the last price recorded under the pre-match id, when the scheduled kickoff isn't known) or "omitted" (no pre-match close is reported — the match was only seen in play, or no price was recorded at or before the kickoff). When the basis is "omitted" the selections carry no pre_close. kickoff_estimated is true when the kickoff is an estimate (a live-clock reading, not bet365's scheduled start); an estimate is never used to pick the close. kickoff_known is false when no kickoff is known.

closed says whether the close is final or still provisional. It is false for an upcoming match — pre_close then just mirrors the current pre-match price and keeps moving until kickoff — and true once the match has kicked off, gone in play, or finished. Treat a pre_close as a settled closing line only when closed is true.

curl 'https://moviodds.com/closing?fi=202266307'   # a live id, merged with its pre-match twin
{"fixture_id":"202266307","fixture_ids":["202266307","201714830"],"prematch_id":"201714830",
 "kickoff_utc":"2026-10-04T20:05:00Z","kickoff_known":true,"kickoff_estimated":false,
 "pre_close_basis":"kickoff","in_play":true,"closed":true,"selection_count":2,"selections":[
  {"group_id":"1441","group_name":"Match Lines","sel_id":"59223314","label":"Home","fixture_id":"201714830",
   "pre_close":{"ts":"2026-10-04T20:00:01Z","group_name":"Match Lines","label":"Home","odds_dec":5.5,"odds_frac":"9/2","suspended":false}},
  {"group_id":"1777","group_name":"Match Lines","sel_id":"59223314","label":"Home","fixture_id":"202266307",
   "last":{"ts":"2026-10-04T21:40:00Z","group_name":"Match Lines","label":"Home","odds_dec":12.0,"odds_frac":"11/1","suspended":false}}]}

Find a selection's sel_id the same way as for the bet builder (section 5): from a match's quotes in /odds. Both endpoints answer 503 with a Retry-After header if history is briefly unavailable (a busy or timed-out archive read); back off and retry.

8. Futures / outright markets

GET /outrights?sport=<key> returns season-long outright (futures) markets: a field of participants priced for one winner question, kept separate from the head-to-head /odds board. Covered sports: motor racing (formula1), golf, cycling, and the team-sport season futures american_football (NFL, NCAAF and CFL: to win outright, conference, division, MVP, regular season awards, Heisman, winning conference, team perfect / winless record, team undefeated, final exact forecast and the other markets bet365 lists on the Futures tab), basketball (NBA championship, conference, division, MVP, season awards and alternate win totals), ice_hockey (Stanley Cup, conference, division, awards), baseball (World Series, league, MVP), rugby_union and rugby_league (World Cup / NRL winner) and australian_rules (AFL Grand Final, Brownlow Medal, plus the AFL Women Grand Final winner), and soccer (each top league's winner, top goalscorer and relegation for the Champions League, Premier League, La Liga, Serie A, Bundesliga and Ligue 1). The per-team grid futures are served too: to make the playoffs (a Yes/No selection per team) and regular season wins (an Over/Under selection per team, each carrying that team's line). Omit sport or pass all for every enabled sport.

curl https://moviodds.com/outrights?sport=american_football

Each event carries its markets; each market has a market_id, a market_name, optional each_way terms, and a variable-size selections array of {sel_id, name, odds_dec, odds_frac} (a suspended runner has odds_dec 0). A grid market's selection also carries side (Yes/No or Over/Under) and, for an over/under line, line. Outright prices update every few minutes.

Key and dedupe markets on market_id, not group_id: group_id repeats across markets in the same event (the eight NFL division winners share one, and MLB lists two distinct World Series markets under one), while market_id is distinct and stable per market; where two markets share a name, column_name tells them apart. each_way.places is the real paying-place count read from the terms (E/W 1/2 1-2 pays 2, 1-2-3 pays 3, 1-2-3-4 pays 4).

bet365's featured outright widget first serves only a market's 12 shortest-priced runners. For those markets the feed follows bet365's own "Show more" request and serves the complete field, so the Super Bowl (all 32 teams), the conferences, NBA and NHL championships, NCAAF, MVP and the soccer Top Goalscorer come through in full. A market keeps "field_truncated": true with runners_shown only when bet365 offers no "Show more" for it (for example the MLB World Series during the postseason) or when that follow-up request was empty this cycle, in which case treat the field (and its below-100% book) as a partial top 12. A complete field (most markets, a 4-team NFL division, AFL/AFLW/rugby, F1/golf/cycling, or any market of 12 or fewer runners) omits both.

An unknown sport key returns 400 {"error":"unknown sport key: X"}. A valid sport with no futures (covered but warming, or a sport the feature does not cover) returns a 200 with an empty events list. When the feature is off entirely the response is {"enabled":false}. Full schema in openapi.json.