Bet Builder API
Get bet365's real Bet Builder price for any set of legs on a match.
Overview
You choose the legs, we ask bet365, and you get back the price bet365 would give you for that exact Bet Builder.
This is not the single odds multiplied together. bet365 adjusts the price when legs are linked (for example, a team winning and that team scoring often happen together), so its number is different. That adjusted number is what you get.
It works for matches that haven't started yet (pre-match). Everything uses three endpoints:
| Endpoint | What it's for | Key needed |
|---|---|---|
GET/catalog | List every match | No |
GET/snapshot?fi=<id> | One match's markets and legs | No |
POST/betbuilder/price | Get the Bet Builder price | Yes |
Base URL: https://moviodds.com
Getting a key
Pricing needs an API key. Message @Movisu on Discord to get one. Send it in the X-API-Key header on every pricing request, and keep it private.
Quick start
-
Find a match
curl https://moviodds.com/catalog
Returns every match in
fixtures[], each with anid,home,away,league,kickoff_utcandin_play. Pick one wherein_playisfalse. Itsidis thefixtureyou'll send. -
Get its legs
curl "https://moviodds.com/snapshot?fi=201750116"
Returns that match's selections in
quotes[]. Each one has asel_id. That's a leg. See Finding legs for which selection is which. -
Get the price
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"]}]}'This prices Barcelona (W) to win plus both teams to score. The answer is in Response.
Finding legs
Every selection in /snapshot has a group_name (the market). Which other field tells you the selection depends on the market:
Market (group_name) | Look at | Example |
|---|---|---|
| Full Time Result, Double Chance, Draw No Bet, Half Time Result | label | label = "Barcelona (W)" |
| Both Teams To Score | label | label = "Yes" |
| Total Corners | label | label = "Under 6" |
| Goals Over/Under, Alternative Total Goals, Match Goals, Corners | side and line | side = "Over", line = "2.5" |
| Shots, Shots On Target | line is the player, side is the number | line = "Aitana Bonmati", side = "2+" |
| Correct Score | label is the score | label = "1-0" |
| Team Total Goals | side is the team, label is Over/Under and the line | side = "Arsenal", label = "Over 1.5" |
| Team Goals Range | line is the team, handicap is the range, side is Yes/No | line = "Wales", handicap = "2-4", side = "Yes" |
Most selections don't have a label, so check which fields a market uses before relying on one. For Full Time Result, n2 is "1", "X" or "2", which is easier than matching team names.
Leg IDs can change. Load the snapshot again each time you price, so you never send an old one.
Need every match at once? GET /odds returns all matches with their selections (about 9 MB), but it counts toward the stricter rate limit.
Request
POST/betbuilder/price
Send a JSON body with a list of slips. Each slip is one Bet Builder: a match and its legs.
{
"slips": [
{"fixture": "201750116", "legs": ["63643981", "91275799"]},
{"fixture": "201750116", "legs": ["63643981", "91275799", "92599147"]},
{"fixture": "201815758", "legs": ["SEL_ID", "SEL_ID"]}
]
}
- Up to 50 slips per request.
- Each slip has 2 to 12 legs, all from the same match, no duplicates.
- Different slips can be on different matches.
- Leg order doesn't matter: the same legs in any order get the same price.
Response
You get one result per slip, in the same order you sent them.
{
"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"
}]
}
The Bet Builder price is odds_dec (or odds_frac) on a slip whose status is priced. Each leg's odds_dec is only that single selection's price, and naive_odds_dec is the singles multiplied (1.95 here), included just for comparison. Don't bet at either of those.
| Field | Description |
|---|---|
fixture, home, away | The match. |
legs[] | Each leg's sel_id, market, label and its single odds_dec. |
status | priced, unavailable or error. See Statuses and errors. |
odds_dec, odds_frac | The Bet Builder price, decimal and fractional. Only present when status is priced. |
naive_odds_dec | The single odds multiplied. For comparison only. Only present when status is priced. |
priced_at | When bet365 gave the price (UTC). |
error | Why the slip wasn't priced (only when status is error). |
retryable | true if trying again later might work, false if you need to change the slip. |
Statuses and errors
| Status | Meaning | What to do |
|---|---|---|
| priced | bet365's current price for exactly these legs. | Use odds_dec. |
| unavailable | bet365 won't price this combination right now. It's suspended, or the legs contradict each other (for example, both teams to win). | Try again later, or change the legs. |
| error | The slip wasn't priced. error explains why. | If retryable is true, try again in a minute. If false, fix the slip. |
Errors that need a fix, not a retry
leg ... is not a current selection of this fixture: thesel_idis old, mistyped or from another match. Load the snapshot again.at least one leg's market is not offered in Bet Builder for this match: that market exists as a single bet but not in the Bet Builder (Draw No Bet on smaller leagues is common). Replace that leg.fixture has kicked off: only pre-match matches can be priced.a bet builder needs at least 2 legs,at most 12 legs per slip,leg ... appears twice: fix the legs.
Limits
- Put all your slips in one request. A request counts once toward the rate limit no matter how many slips it has.
- 16 slips take about 10 seconds and 50 slips about 25 seconds. Use a timeout of at least 60 seconds.
| HTTP code | Cause | What to do |
|---|---|---|
200 | The request worked. Check each slip's status. | — |
400 | Invalid JSON, no slips, or more than 50 slips. | Fix the request body. |
401 | Missing or wrong API key. | Check the X-API-Key header. |
405 | The request wasn't a POST. | Use POST. |
503 | Rate limit: more than 30 requests a minute from your IP to /betbuilder/price and /odds combined, or about 10 a second to /catalog and /snapshot. The body may be HTML. | Wait, then retry with a longer delay each time. |
Python example
This script does all three steps: it takes the next three matches from /catalog, loads their snapshots, builds four slips per match, prices them in one request and prints the results. Set your key in the BETBUILDER_KEY environment variable, then replace SLIPS with your own.
import os from datetime import datetime, timezone import requests BASE = "https://moviodds.com" KEY = os.environ["BETBUILDER_KEY"] # Each leg: (market, field, value[, field, value]) - see "Finding legs". SLIPS = { "Home win + BTTS": [("Full Time Result", "n2", "1"), ("Both Teams To Score", "label", "Yes")], "Away win + Over 2.5": [("Full Time Result", "n2", "2"), ("Goals Over/Under", "side", "Over", "line", "2.5")], "Draw + Under 2.5": [("Full Time Result", "n2", "X"), ("Goals Over/Under", "side", "Under", "line", "2.5")], "BTTS + Over 2.5": [("Both Teams To Score", "label", "Yes"), ("Goals Over/Under", "side", "Over", "line", "2.5")], } def find_leg(match, spec): market, *pairs = spec want = dict(zip(pairs[::2], pairs[1::2])) for q in match["quotes"]: if q["group_name"] == market and all(q.get(k) == v for k, v in want.items()): return q["sel_id"] return None # 1. Pick upcoming matches from the catalog. now = datetime.now(timezone.utc) catalog = requests.get(f"{BASE}/catalog", timeout=60).json()["fixtures"] upcoming = [f for f in catalog if not f["in_play"] and datetime.fromisoformat(f["kickoff_utc"].replace("Z", "+00:00")) > now] upcoming.sort(key=lambda f: f["kickoff_utc"]) # 2. Load each chosen match's markets (fresh sel_ids) from its snapshot. matches = [] for f in upcoming[:3]: snap = requests.get(f"{BASE}/snapshot", params={"fi": f["id"]}, timeout=60).json() matches.append({**f, "quotes": snap["quotes"]}) request, names = [], [] for m in matches: for name, specs in SLIPS.items(): legs = [find_leg(m, s) for s in specs] if None in legs: print(f"{m['home']} v {m['away']}: {name}: leg not offered, skipped") continue request.append({"fixture": m["id"], "legs": legs}) names.append(f"{m['home']} v {m['away']}: {name}") # 3. Price every slip in one request. resp = requests.post(f"{BASE}/betbuilder/price", json={"slips": request}, headers={"X-API-Key": KEY}, timeout=120) resp.raise_for_status() for name, r in zip(names, resp.json()["results"]): if r["status"] == "priced": print(f"{name}: {r['odds_dec']:.2f} ({r['odds_frac']})") elif r["status"] == "unavailable": print(f"{name}: unavailable right now") else: again = "retry later" if r.get("retryable") else "fix the slip" print(f"{name}: error - {r['error']} ({again})")
If a match doesn't offer one of a slip's markets, the script skips that slip.
Not supported yet
- In-play matches. Pricing is pre-match only for now.
- Prices come from bet365.com.au. Other bet365 sites might price a slip slightly differently.
- Super Boosts.
Questions, bugs or a market you need? Message @Movisu on Discord.