MoviOdds Docs
API docs Markets Python moviodds.com

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:

EndpointWhat it's forKey needed
GET/catalogList every matchNo
GET/snapshot?fi=<id>One match's markets and legsNo
POST/betbuilder/priceGet the Bet Builder priceYes

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

  1. Find a match

    curl https://moviodds.com/catalog

    Returns every match in fixtures[], each with an id, home, away, league, kickoff_utc and in_play. Pick one where in_play is false. Its id is the fixture you'll send.

  2. Get its legs

    curl "https://moviodds.com/snapshot?fi=201750116"

    Returns that match's selections in quotes[]. Each one has a sel_id. That's a leg. See Finding legs for which selection is which.

  3. 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 atExample
Full Time Result, Double Chance, Draw No Bet, Half Time Resultlabellabel = "Barcelona (W)"
Both Teams To Scorelabellabel = "Yes"
Total Cornerslabellabel = "Under 6"
Goals Over/Under, Alternative Total Goals, Match Goals, Cornersside and lineside = "Over", line = "2.5"
Shots, Shots On Targetline is the player, side is the numberline = "Aitana Bonmati", side = "2+"
Correct Scorelabel is the scorelabel = "1-0"
Team Total Goalsside is the team, label is Over/Under and the lineside = "Arsenal", label = "Over 1.5"
Team Goals Rangeline is the team, handicap is the range, side is Yes/Noline = "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"]}
  ]
}

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.

FieldDescription
fixture, home, awayThe match.
legs[]Each leg's sel_id, market, label and its single odds_dec.
statuspriced, unavailable or error. See Statuses and errors.
odds_dec, odds_fracThe Bet Builder price, decimal and fractional. Only present when status is priced.
naive_odds_decThe single odds multiplied. For comparison only. Only present when status is priced.
priced_atWhen bet365 gave the price (UTC).
errorWhy the slip wasn't priced (only when status is error).
retryabletrue if trying again later might work, false if you need to change the slip.

Statuses and errors

StatusMeaningWhat to do
pricedbet365's current price for exactly these legs.Use odds_dec.
unavailablebet365 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.
errorThe 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

Limits

HTTP codeCauseWhat to do
200The request worked. Check each slip's status.—
400Invalid JSON, no slips, or more than 50 slips.Fix the request body.
401Missing or wrong API key.Check the X-API-Key header.
405The request wasn't a POST.Use POST.
503Rate 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

Questions, bugs or a market you need? Message @Movisu on Discord.