> ## Documentation Index
> Fetch the complete documentation index at: https://docs.magicmarkets.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Refresh betslip

> Extend the betslip expiration timeout.



## OpenAPI

````yaml /openapi/magicmarkets-v2.json post /v2/betslips/{betslip_id}/refresh/
openapi: 3.1.0
info:
  title: MagicMarkets API
  version: 0.1.0
  description: >
    REST and WebSocket API for MagicMarkets. Place orders, stream real-time

    prices, and manage your positions programmatically.


    ## Authentication


    Every request must be authenticated with an **API key**. Pass it in the

    `X-Api-Key` header:


    ```

    X-Api-Key: <your-api-key>

    ```


    ### Getting an API key


    API keys are created and managed through the MagicMarkets website at

    [magicmarkets.com](https://magicmarkets.com/):


    1. Go to **Settings -> API**.

    2. Click **Add API Key** and give it a name.

    3. Copy the key value - it is shown **once at creation**, so store it
       somewhere safe immediately.

    You can also revoke or rename existing keys from the same page. There is

    no endpoint in this API to manage keys - all key management happens on

    the website.


    ### Using a key


    Send the key on every request:


    ```bash

    curl https://magicmarkets.com/v2/xrates/ \
      -H "X-Api-Key: $MAGIC_API_KEY"
    ```


    ## Quickstart


    This walkthrough goes from zero to a placed bet: stream prices over the

    WebSocket, read a `bet_type` off the feed, quote it as a betslip, place

    an order, and watch the order update on the same socket.


    ### Before you start


    You need an API key (see **Authentication** above) and Python 3.9+ with

    two libraries:


    ```bash

    pip install requests websockets

    ```


    Every snippet reads its configuration from the environment:


    ```bash

    export MAGIC_API_URL="https://<host>/v2"

    export MAGIC_WS_URL="wss://<host>/v2/stream"

    export MAGIC_API_KEY="<your-api-key>"

    ```


    Three concepts carry the whole flow:


    - **Two steps to a bet.** A *betslip* registers your interest in one
      selection (`POST /v2/betslips/`); its live quote then arrives as
      `["pmm", ...]` entries on the WebSocket. An *order* commits a stake
      against that quote (`POST /v2/orders/`). You always create the
      betslip first.
    - **`bet_type` comes from the feed.** Offers on the WebSocket carry the
      `bet_type` string ready to use - pass it to `POST /v2/betslips/`
      verbatim. You never need to construct or parse it (the grammar in
      **Sports & bet types** is reference material, not required reading).
    - **Stakes are USDT.** Wherever money appears it is a
      `["USDT", amount]` pair.

    ### Step 1 - Check your key


    Before opening the socket, prove the key works with a cheap REST call -

    a bad key fails the WebSocket upgrade with HTTP 401, so verify the key

    first:


    ```bash

    curl "$MAGIC_API_URL/xrates/" -H "X-Api-Key: $MAGIC_API_KEY"

    ```


    A `200` with `{"status": "ok", ...}` means you are good to go.


    ### Step 2 - Connect to the stream


    Connect with the key as a query parameter. Every frame the server sends

    is a batch envelope `{"ts": ..., "data": [...]}` - iterate `data[]` and

    dispatch on each entry's leading type tag (see the **Streaming API**

    endpoint for the full wire format):


    ```python

    import json, os

    from websockets.sync.client import connect


    ws =
    connect(f"{os.environ['MAGIC_WS_URL']}?api_key={os.environ['MAGIC_API_KEY']}")


    events, synced = [], False

    while not synced:
        frame = json.loads(ws.recv())
        for entry in frame["data"]:
            if entry[0] == "event":
                events.append(entry[1])   # {"sport": ..., "event_id": ..., ...}
            elif entry[0] == "sync":
                synced = True
    ```


    After `["sync", ...]` you hold the list of currently-priced events, e.g.:


    ```json

    ["event", {"event_type": "normal", "sport": "fb",
      "event_id": "2026-06-15,1001,2002", "competition_id": 1,
      "competition_name": "England Premier League",
      "competition_country": "XE", "home": "Arsenal", "away": "Chelsea",
      "event_name": "Arsenal vs. Chelsea", "ir_status": "pre_event",
      "start_time": "2026-06-15T15:00:00Z"}]
    ```


    ### Step 3 - Register for offers on an event


    Pick an event and register. The server replies with one `["offer", ...]`

    per bet type (the snapshot), then an ok `["response", ...]`:


    ```python

    event = events[0]

    ws.send(json.dumps(["register_event", event["sport"], event["event_id"]]))


    offers, registered = [], False

    while not registered:
        frame = json.loads(ws.recv())
        for entry in frame["data"]:
            if entry[0] == "offer":
                offers.append(entry[1])
            elif entry[0] == "response":
                if entry[1]["status"] == "ok":
                    registered = True
                else:
                    raise SystemExit(f"register_event failed: {entry[1]['code']}")
    ```


    (Registering an event that has not yet appeared in the sync stream is

    not an error - you just get an empty snapshot, and offers start flowing

    if the event becomes priced. Prefer event ids you saw in the sync

    stream.) From now on the full offer set is re-broadcast whenever this

    event's prices change.


    ### Step 4 - Read the bet_type off an offer


    Each offer is one priced selection. Everything the next step needs is

    already in it:


    ```json

    ["offer", {
      "sport": "fb",
      "event_id": "2026-06-15,1001,2002",
      "bet_type": "for,ah,h,1",
      "market_type": "ah",
      "in_running": false,
      "price_list": [
        {"effective": {"price": 2.0, "min": ["USDT", 5.0], "max": ["USDT", 150.0]}},
        {"effective": {"price": 1.99, "min": null, "max": ["USDT", 80.0]}}
      ]
    }]

    ```


    `price_list` is sorted by price descending; `min` is `null` when there

    is no minimum stake. Pick a priced offer - its `sport`, `event_id` and

    `bet_type` are everything the next step needs:


    ```python

    offer = next(o for o in offers if o["price_list"])

    ```


    ### Step 5 - Create a betslip


    Quote the selection by passing the offer's fields through verbatim:


    ```python

    import requests


    API = os.environ["MAGIC_API_URL"]

    HEADERS = {"X-Api-Key": os.environ["MAGIC_API_KEY"]}


    betslip = requests.post(f"{API}/betslips/", headers=HEADERS, json={
        "sport": offer["sport"],
        "event_id": offer["event_id"],
        "bet_type": offer["bet_type"],
        "betslip_type": "normal",
    }).json()["data"]

    ```


    The response registers the betslip - note the `betslip_id` and the

    `expiry_ts` (betslips are short-lived; re-create one that expires).

    Do not expect prices in this response: your private quote arrives on

    the WebSocket you already hold open, as `["pmm", ...]` entries carrying

    your `betslip_id` - typically within a couple of seconds, refreshed

    while the betslip stays open:


    ```python

    quote = None

    while quote is None:
        frame = json.loads(ws.recv())
        for entry in frame["data"]:
            if entry[0] == "pmm" and entry[1]["betslip_id"] == betslip["betslip_id"]:
                if entry[1]["price_list"]:
                    quote = entry[1]
    ```


    ```json

    ["pmm", {
      "betslip_id": "65b6ff7da480479b9dda1c7ff765c434",
      "sport": "fb",
      "event_id": "2026-06-15,1001,2002",
      "bet_type": "for,ah,h,1",
      "status": {"code": "success"},
      "price_list": [
        {"effective": {"price": 2.0, "min": ["USDT", 5.0], "max": ["USDT", 150.0]}}
      ],
      "total": ["USDT", 150.0]
    }]

    ```


    The `price_list` uses the same format as offers - prices descending,

    stakes in USDT. A pmm whose `price_list` stays empty means there is no

    liquidity for this selection right now - pick another offer and

    re-quote.


    If you are not holding the stream open, poll

    `GET /v2/betslips/{betslip_id}/` until `price_list` populates.


    ### Step 6 - Place an order


    Commit a stake at one of the quoted prices. Three fields are

    required (`betslip_id`, `price`, `stake`); `duration` is the order's

    lifetime in **seconds** (optional, default 15):


    ```python

    best = quote["price_list"][0]["effective"]


    order = requests.post(f"{API}/orders/", headers=HEADERS, json={
        "betslip_id": betslip["betslip_id"],
        "price": best["price"],
        "stake": ["USDT", 10.0],
        "duration": 5.0,
    }).json()["data"]

    ```


    The response confirms acceptance - `order_id` and `status: "open"`

    (abridged; `bets` appear on the subsequent updates as the order fills):


    ```json

    {
      "order_id": 5001,
      "status": "open",
      "bet_type": "for,ah,h,1",
      "sport": "fb",
      "want_price": 2.0,
      "want_stake": ["USDT", 10.0],
      "closed": false,
      "price": null,
      "stake": null,
      "profit_loss": null
    }

    ```


    ### Step 7 - Watch the order on the WebSocket


    Order updates arrive on the socket you already hold open, as

    `["order", ...]` and `["bet", ...]` entries in the same envelopes as

    offers. An order moves `open -> pending -> done | failed`; when it

    closes, `price`, `stake` and `profit_loss` are filled in:


    ```python

    while True:
        frame = json.loads(ws.recv())
        for entry in frame["data"]:
            if entry[0] == "order" and entry[1]["order_id"] == order["order_id"]:
                o = entry[1]
                print("order:", o["status"], o.get("close_reason"))
                if o["status"] in ("done", "failed"):
                    raise SystemExit(0)
    ```


    Note that a `done` order is filled, not settled - the final

    `profit_loss` lands after the event finishes. To re-check an order

    later (e.g. after a restart), `GET /v2/orders/{order_id}/` returns the

    same object on demand.


    ### Handling errors


    Always check `status` before reading `data`. REST errors use the

    envelope from the **Errors** section below - `validation_error`

    bodies name the offending field (e.g. `bet_type: ["invalid_bet_type"]`),

    and on `429` honour `data.retry_after` (the limits are listed under

    **Rate limiting**). WebSocket errors arrive

    in-band as `["response", {"status": "error", ...}]` entries; the reason
    table and the

    silent-close cases are documented on the **Streaming API** endpoint.


    ### Complete example


    The whole flow, runnable as-is with the three environment variables set:


    ```python

    import json, os

    import requests

    from websockets.sync.client import connect


    API = os.environ["MAGIC_API_URL"]

    KEY = os.environ["MAGIC_API_KEY"]

    HEADERS = {"X-Api-Key": KEY}


    # 1. verify the key via REST first - the socket closes silently on a bad key

    requests.get(f"{API}/xrates/", headers=HEADERS).raise_for_status()


    with connect(f"{os.environ['MAGIC_WS_URL']}?api_key={KEY}") as ws:
        # 2. initial sync: collect events until ["sync", ...]
        events, synced = [], False
        while not synced:
            frame = json.loads(ws.recv())
            for entry in frame["data"]:
                if entry[0] == "event":
                    events.append(entry[1])
                elif entry[0] == "sync":
                    synced = True
        if not events:
            raise SystemExit("no priced events right now")

        # 3+4. register events until one returns a priced offer
        offer = None
        for event in events:
            ws.send(json.dumps(["register_event", event["sport"], event["event_id"]]))
            offers, registered = [], False
            while not registered:
                frame = json.loads(ws.recv())
                for entry in frame["data"]:
                    if entry[0] == "offer":
                        offers.append(entry[1])
                    elif entry[0] == "response":
                        if entry[1]["status"] == "ok":
                            registered = True
                        else:
                            raise SystemExit(f"register_event failed: {entry[1]['code']}")
            offer = next((o for o in offers if o["price_list"]), None)
            if offer:
                print("picked", offer["bet_type"], "on", event["event_name"])
                break
            ws.send(json.dumps(["unregister_event", event["sport"], event["event_id"]]))
        if offer is None:
            raise SystemExit("no priced offers right now")

        # 5. register a betslip (bet_type verbatim), then read the quote off the socket
        resp = requests.post(f"{API}/betslips/", headers=HEADERS, json={
            "sport": offer["sport"],
            "event_id": offer["event_id"],
            "bet_type": offer["bet_type"],
            "betslip_type": "normal",
        }).json()
        if resp["status"] != "ok":
            raise SystemExit(f"betslip rejected: {resp}")
        betslip = resp["data"]

        quote = None
        while quote is None:
            frame = json.loads(ws.recv())
            for entry in frame["data"]:
                if entry[0] == "pmm" and entry[1]["betslip_id"] == betslip["betslip_id"]:
                    if entry[1]["price_list"]:
                        quote = entry[1]
        print("quoted", quote["price_list"][0])

        # 6. place an order at the quote's best price
        best = quote["price_list"][0]["effective"]
        resp = requests.post(f"{API}/orders/", headers=HEADERS, json={
            "betslip_id": betslip["betslip_id"],
            "price": best["price"],
            "stake": ["USDT", 10.0],
            "duration": 5.0,
        }).json()
        if resp["status"] != "ok":
            raise SystemExit(f"order rejected: {resp}")
        order = resp["data"]
        print("placed order", order["order_id"], "status:", order["status"])

        # 7. watch it on the same socket
        while True:
            frame = json.loads(ws.recv())
            for entry in frame["data"]:
                if entry[0] == "order" and entry[1]["order_id"] == order["order_id"]:
                    o = entry[1]
                    print("order:", o["status"], o.get("close_reason"))
                    if o["status"] in ("done", "failed"):
                        raise SystemExit(0)
    ```


    From here: the **Streaming API** endpoint documents every message on the

    socket, **Betslips** and **Orders** cover the remaining endpoints

    (including parlays and lay orders), and **Heartbeats** provides a

    dead-man's switch for automated trading.


    ## Response format


    All JSON responses share a common envelope:


    ```json

    { "status": "ok", "data": ... }

    ```


    On error:


    ```json

    { "status": "error", "code": "<code>", "data": <details> }

    ```


    `data` may be `null`, a string, or an object - depending on the code. See

    the **Errors** section below for the most common shapes.


    ## Errors


    Error responses always include `status: "error"` and a stable string

    `code` that clients should branch on. The HTTP status conveys the

    category; `code` narrows it down.


    ### Common codes


    | HTTP | `code` | When |

    |------|--------|------|

    | 400 | `validation_error` | Request body or query failed validation.
    `data.validation_errors` is a `{ field: [reason, ...] }` map; cross-field
    problems land in `non_field_errors`. A rejected list field is index-keyed
    instead: `{ field: { "0": [reason, ...] } }`. |

    | 400 | `order_closed` | `POST /v2/orders/{id}/close/` on an order that
    exists but is already closed or settled. Distinct from `not_found`, which
    means the order id is unknown. |

    | 401 | `auth_error` | API key missing, malformed, or rejected. Also used by
    login flows for 2FA / inactive / locked accounts. |

    | 403 | `forbidden` | The key is valid but is not allowed to perform this
    action. |

    | 403 | `invalid_customer` | Market data: the feed has no record of your
    account. Contact support; a retry gets the same answer. |

    | 404 | `not_found` | The addressed resource (betslip, order, heartbeat,
    token, session) does not exist or is not visible to this key. |

    | 409 | `order_already_created` | A `request_uuid` from `POST /v2/orders/`
    was reused. `data` includes the existing `order_id`. |

    | 409 | `limit_reached` | A per-customer cap was hit (e.g. maximum API
    tokens). `data.detail` describes the cap. |

    | 429 | `throttled` | Rate limit hit - see **Rate limiting** below. `data`
    is `{ "message": "...", "retry_after": <seconds> }` and a `Retry-After`
    header is sent. |

    | 500 | `server_error` | Unexpected internal error. `data` is `["An error
    has occurred, token:", "<token>"]`; quote the token if you contact support.
    |

    | 503 | `warming` or no envelope | `warming` (market data): the feed is
    loading its market data or customer records, which clears by itself, so
    retry with backoff. No envelope (any endpoint): an upstream service could
    not be reached, and the body is `{ "detail": "Service unavailable" }`; retry
    with backoff. |


    For `validation_error` responses, branch on the inner reason - the

    keys of `data.validation_errors` (`non_field_errors` for cross-field

    rejections, otherwise the offending field name). Each endpoint

    documents the concrete codes it emits next to its 400 response.


    ## Rate limiting


    Limits are per **account**: all of an account's API keys draw from

    one budget. Each limit is a token bucket that refills continuously

    at its own rate, so capacity returns steadily rather than in one

    reset, and an account that has been idle can spend a full bucket in

    a single burst.


    | Applies to | Limit |

    |------------|-------|

    | All endpoints | 100 requests/second burst, 1200 requests/minute sustained
    |

    | `POST /v2/betslips/` | 10 requests/second, and 2000 betslips/day |

    | `POST /v2/orders/` | 5 requests/second |

    | `GET /v2/offers/` | 4 requests in flight at once |


    The placement rows are dedicated budgets: a `POST /v2/betslips/` or

    `POST /v2/orders/` call counts only against its own limit, not the

    general one. Polling market data therefore never throttles order

    placement. Every other endpoint, market data included, shares the

    general budget, so a busy poller leaves less of it for other GETs.


    Budgets are keyed on the account, not the source address, so

    spreading an integration across several hosts does not multiply it.


    A rejected request returns a `429` `throttled` error with the wait

    in `Retry-After` and `data.retry_after` (integer seconds).


    The `GET /v2/offers/` row counts open requests: an account can have 4

    of them in flight at once, because each one prices every event it names.

    A call can name up to 20 events at the same cost against the cap, so one

    call naming 20 events beats 20 calls naming one. A request over the cap

    gets the `throttled` envelope. Retry when one of your open requests

    returns; `retry_after` is a fixed 1 second, because the server cannot

    know when your open requests will finish.


    The WebSocket stream has no message-rate limit; its connection-level

    limits (registered-event cap, slow-reader disconnect) are documented

    on the **Streaming API** endpoint.


    Limits can be adjusted per account - contact support if your

    integration needs more headroom.


    ## Currencies


    Stake fields are `[currency, amount]` tuples. Stakes in responses are

    always returned in **USDT**:


    ```json

    ["USDT", 115.38]

    ```


    ## Price ticks


    Single-market prices lie on a fixed tick schedule. The tick (the smallest

    step between two valid prices) widens as the decimal price grows:


    | Price (cents) | Decimal price | Tick |

    |---------------|---------------|------|

    | 50c - 99c     | 1.01 - 2      | 0.01 |

    | 33.3c - 50c   | 2 - 3         | 0.02 |

    | 25c - 33.3c   | 3 - 4         | 0.05 |

    | 16.7c - 25c   | 4 - 6         | 0.10 |

    | 10c - 16.7c   | 6 - 10        | 0.20 |

    | 5c - 10c      | 10 - 20       | 0.50 |

    | 3.3c - 5c     | 20 - 30       | 1    |

    | 2c - 3.3c     | 30 - 50       | 2    |

    | 1c - 2c       | 50 - 100      | 5    |

    | 0.1c - 1c     | 100 - 1000    | 10   |


    Cents are the implied probability of a price: `cents = 100 / decimal

    price`. The band boundaries are exact in decimal price; the cents labels

    are only approximate, so always match a price to its band by the decimal

    value.


    ### Order prices


    The price you submit on an order is a limit. A bet is taken only at your

    price or better: better means higher for a back (`for`) order and lower for

    a lay (`against`) one.


    An off-tick limit moves to the first tick that honours it, never to one

    that does not. The market carries only ticks, so that tick is the cheapest

    price that satisfies the limit you named. A back limit of 7.15 becomes 7.20

    and is not filled at 7.00. The moved price is the one the order runs with,

    and the one the order response reports.


    ### Feed prices


    Every single-market price delivered on the stream (`price_list`, offers,

    pmms) is already on the schedule, so a price quoted straight from the feed

    is always a valid limit.


    ### Accumulator prices


    An accumulator price is the product of its legs' prices, so it lies on no

    tick schedule. Accumulator prices are quoted, and accepted as limits, at

    full precision, up to the ceiling of 1000 that every quoted price shares.


    ## Sports & bet types


    Every betslip, order and bet carries a `sport` and a `bet_type`. They

    are short, opaque-looking strings whose grammar is described here.


    ### Sport codes


    `sport` is a lowercase string. Current values:


    | Code | Sport |

    |------|-------|

    | `fb` | Football, full 90 minutes |

    | `fb_ht` | Football, first half only |

    | `fb_2h` | Football, second half only |

    | `fb_et` | Football, extra time only |

    | `fb_corn` | Football corners (90 min) |

    | `fb_corn_ht` | Football corners (1st half) |

    | `fb_book` | Football yellow cards (90 min) |

    | `fb_htft` | Football combined half-time / full-time result |

    | `basket` | Basketball, full match |

    | `basket_ht` | Basketball, first half |

    | `basket_2h` | Basketball, second half |

    | `basket_q1` | Basketball, 1st quarter |

    | `basket_q2` | Basketball, 2nd quarter |

    | `basket_q3` | Basketball, 3rd quarter |

    | `basket_q4` | Basketball, 4th quarter |

    | `tennis` | Tennis |

    | `tt` | Table tennis |

    | `ih` | Ice hockey |

    | `af` | American football |

    | `rl` | Rugby league |

    | `ru` | Rugby union |

    | `arf` | Australian rules football |

    | `hand` | Handball |

    | `volley` | Volleyball |

    | `baseball` | Baseball |

    | `cricket` | Cricket |

    | `darts` | Darts |

    | `snooker` | Snooker |

    | `boxing` | Boxing |

    | `mma` | Mixed martial arts |

    | `golf` | Golf |

    | `cycling` | Cycling |

    | `moto` | Motorsport |

    | `horse` | Horse racing |

    | `dog` | Greyhound racing |

    | `esports` | Esports |

    | `politics` | Political markets |

    | `specials` | Specials / novelty markets |


    On accumulator (parlay) orders and betslips, `sport` is the literal

    string `parlay` and the per-leg sport sits inside each `legs[]` entry.


    Treat the table above as informational, not as a closed enum - new

    sports are added over time. Do not hard-fail on unknown codes.


    ### Bet type grammar


    `bet_type` is a comma-separated string. The first token is the

    direction:


    - `for` - back the outcome (you win if it happens).

    - `against` - lay the outcome (you win if it doesn't).


    The remaining tokens identify the market and its parameters.

    **Handicaps always refer to the home team.**


    **Asian handicap lines are integers equal to 4 x the actual line.**

    This keeps the wire format integer-only across `0.25`-step lines:


    | Wire integer | Real line |

    |--------------|-----------|

    | `0`  | 0.0 |

    | `2`  | 0.5 |

    | `7`  | 1.75 |

    | `8`  | 2.0 |

    | `-4` | -1.0 |

    | `-21` | -5.25 |


    ### Markets


    Match result:


    | Bet type | Meaning |

    |----------|---------|

    | `for,h` / `for,d` / `for,a` | Home / Draw / Away win |

    | `for,sd` | Score draw (any non-0-0 draw) |

    | `for,win_90,h` | Home wins in 90 min (excluding extra time) |

    | `for,dnb,h` | Home win, void if draw (draw-no-bet) |

    | `for,hnb,a` | Away win, void if home wins (home-no-bet) |

    | `for,anb,h` | Home win, void if away wins (away-no-bet) |

    | `for,ml,h` | Moneyline - home wins, draw is void |

    | `for,dc,h,d` | Double chance: home or draw |

    | `for,uswin,h` | US-style home win (draw is half-stake split) |

    | `for,awdw,h` | Asian win/draw/win |

    | `for,ko,h` | Home team to kick off |

    | `for,qualify,h` | Home team to qualify |


    Goals (totals):


    | Bet type | Meaning |

    |----------|---------|

    | `for,over,2.5` / `for,under,2.5` | Over/under non-integer line |

    | `for,overeq,3` / `for,undereq,3` | Over/under integer line, inclusive |

    | `for,exact_total,3` | Exactly 3 goals |

    | `for,exact_total,3,inf` | 3 or more goals |

    | `for,gr,1,3` | Goal range 1-3 inclusive (use `inf` for infinity) |

    | `for,teamgr,h,0,2` | Home team scores 0-2 |

    | `for,odd` / `for,even` | Total goals odd / even |

    | `for,odd,h` / `for,even,a` | Per-team odd / even |


    Asian handicaps (lines as 4 x the actual line):


    | Bet type | Meaning |

    |----------|---------|

    | `for,ah,h,-4` | Asian handicap, home -1.0 |

    | `for,ahover,7` / `for,ahunder,7` | Asian over/under 1.75 goals |

    | `for,tahover,h,2` / `for,tahunder,a,2` | Team Asian over/under 0.5 goals |

    | `for,eh,h,1` | English handicap, home +1 |


    Correct score and margins:


    | Bet type | Meaning |

    |----------|---------|

    | `for,cs,2,1` | Correct score 2-1 |

    | `for,othercs,3,3` | Any score outside `home <= 3 AND away <= 3` |

    | `for,othercs,1,1,3,3` | Any score outside both ranges |

    | `for,wm,h,2,2` | Home wins by exactly 2 |

    | `for,wm,h,2,inf` | Home wins by 2+ |

    | `for,wmo,h,1,2.5` | Home wins by 1 + over 2.5 goals |

    | `for,awm,1` | Absolute margin 1 (either side) |

    | `for,wg,h,2` | Home wins and scores >= 2 |

    | `for,quatro,h,o,2.5` | Home wins AND over 2.5 goals |

    | `for,moou,h,over,2.5` | Match-result + over/under combo |

    | `for,mo_both_score,h,yes` | Home wins AND both teams score |

    | `for,aou,h,3` | "Any other unquoted" score, home, max draw at 3-3 |


    Score / clean sheet:


    | Bet type | Meaning |

    |----------|---------|

    | `for,score,both,yes` / `for,score,both,no` | Both teams (don't) score |

    | `for,score,either` / `for,score,neither` / `for,score,one` | Score
    patterns |

    | `for,score,h,yes` / `for,score,h,no` | Home (does not) score |

    | `for,clean,h` | Home clean sheet |

    | `for,clean,both` / `for,clean,either` / `for,clean,neither` /
    `for,clean,one` | Clean-sheet patterns |

    | `for,fg,no_goal` | No goals (first goal markets) |

    | `for,swm,no_goal` / `for,swm,sd` | Score-and-margin: no goal / score draw
    |


    ### Tennis


    Tennis bet types include a period and a void rule:


    ```

    for,tset,<period>,<void_rule>,<unit>[,<market>,<args>...]

    ```


    - `<period>` - `1`-`5` (a specific set) or `all` (whole match).

    - `<void_rule>` - `vwhole`, `vsetN`, `vgameN` - when the bet voids
      if a player retires.
    - `<unit>` - `set` or `game`, optionally followed by a market and args.


    Examples:


    - `for,tset,all,vset1,p1` - player 1 to win the match (voids unless
      set 1 completes).
    - `for,tset,1,vwhole,p1` - player 1 to win set 1.

    - `for,tset,all,vwhole,game,ahover,62` - total games in the match
      over 15.5.

    ### Time-period sports (other than tennis)


    Bets on a specific period of a match use one of these tokens:


    | Token | Meaning |

    |-------|---------|

    | `tp,<period>` | Generic period - `<period>` is `all`, `reg`, or `1`-`9` |

    | `tperiod,<n>` | Specific period (e.g. ice hockey, hand-ball) |

    | `thalf,<n>` | First or second half |

    | `tquarter,<n>` | Quarter (basketball, NFL) |

    | `tinnings,<n>` | Inning (baseball) - `<n>` is integer or `all` |

    | `tmap,<n>` | Map (esports) - `<n>` is `1`-`5` |


    The token is followed by an optional `sub,<subsport>` modifier

    (used for things like darts 180-counts) and then the regular

    market and its arguments:


    ```

    for,<period_token>[,sub,<subsport>],<market>[,<args>...]

    ```


    Examples:


    - `for,tp,all,ahunder,16` - total under 4.0 across all periods.

    - `for,thalf,1,ah,h,0` - Asian handicap, home 0.0, in the first half.

    - `for,tquarter,2,wdw,h` - home to win the second quarter.

    - `for,tmap,1,ahover,42` - esports, total kills on map 1 over 10.5.

    - `for,tp,all,sub,180,ahover,8` - darts, over 2.0 180s.


    The legacy aliases `tall`, `treg`, `tp1`, `tp2`, ... are no longer

    accepted; use the tokens above.


    ### Multirunner (outright) events


    For events with many runners (horse racing, golf, etc.):


    - `for,win,<team_id>` - runner to win outright.

    - `for,top,<n>,<team_id>` - runner to finish in the top `<n>`
      (e.g. `for,top,3,1042` means runner 1042 to place top-3).

    ### Validating a bet type


    Call `GET /v2/sports/{sport}/bet_types/{bet_type}/` with the

    candidate string. A 200 response includes a human-readable

    `bet_type_description` and the win/loss payoff grid; a 400

    (`invalid_bet_type`) means the string did not parse. This endpoint

    cannot validate multirunner bet types (`for,win,...`, `for,top,...`);

    they need an event context and always return the 400.


    ## Idempotency


    `POST /v2/orders/` accepts an optional `request_uuid`. Retrying the same

    request with the same UUID will not create a duplicate order, and the

    order can be retrieved by UUID from `GET /v2/orders/tracked/{uuid}/` for

    several days after placement (until the order is purged upstream).


    ## Machine-readable docs


    The canonical OpenAPI spec is served at:


    - `GET /v2/openapi.json` (parsed JSON)

    - `GET /v2/openapi.yaml` (raw YAML)


    Both URLs return the same schema rendered on this page.


    For LLMs and coding agents:


    - `GET /llms.txt` - index of machine-readable documentation
      ([llms.txt](https://llmstxt.org/) format)
    - `GET /docs.md` (alias `GET /llms-full.txt`) - this entire reference as a
      single Markdown document
    - `GET /docs` with an `Accept: text/markdown` header returns the Markdown
      reference instead of HTML

    All paths also work behind the `/magic-api` ingress prefix

    (e.g. `/magic-api/llms.txt`, `/magic-api/v2/openapi.yaml`).
servers:
  - url: https://magicmarkets.com
    description: Production
security:
  - ApiKeyHeader: []
tags:
  - name: Betslips
    description: >-
      Create, retrieve and close betslips. Quotes arrive asynchronously as a
      single `price_list` (stakes in USDT): poll `GET
      /v2/betslips/{betslip_id}/` or watch the stream.
  - name: Orders
    description: >-
      Place and retrieve orders. All stake fields are converted to USDT.
      Supports three order formats: normal (match sports), multirunner
      (outrights), and parlay (accumulators).
  - name: Account
    description: >-
      Account-level information: current balance, open stake, and smart credit,
      all in USDT.
  - name: Heartbeats
    description: >-
      Heartbeat timers that automatically close all open orders when they
      expire. Use these as a dead-man's switch for automated trading.
  - name: Reference
    description: Exchange rates and bet type information.
  - name: Market data
    description: >-
      Event and market discovery over REST. Entries have the same shape as the
      stream's `event` and `offer` entries. For live prices, use the streaming
      API: it pushes each change as it happens.
  - name: Streaming API
    description: >-
      Real-time WebSocket stream: event discovery, live prices with stakes in
      USDT (updated every 2 seconds), and account updates (betslips, quotes,
      orders, bets, balance) on a single socket. Runs as a separate service -
      connect directly to the stream's WebSocket server.
paths:
  /v2/betslips/{betslip_id}/refresh/:
    post:
      tags:
        - Betslips
      summary: Refresh betslip
      description: Extend the betslip expiration timeout.
      parameters:
        - name: betslip_id
          in: path
          required: true
          schema:
            type: string
          description: Betslip ID
      responses:
        '200':
          description: Betslip expiration extended
          content:
            application/json:
              example:
                status: ok
                data: null
        '401':
          $ref: '#/components/responses/Error401'
        '403':
          $ref: '#/components/responses/Error403'
        '429':
          $ref: '#/components/responses/Error429'
        '500':
          $ref: '#/components/responses/Error500'
components:
  responses:
    Error401:
      description: Authentication failed - API key missing, malformed, or rejected.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            code: auth_error
            data:
              detail: Authentication credentials were not provided.
    Error403:
      description: Authorization failed - the key is valid but cannot perform this action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            code: forbidden
            data: null
    Error429:
      description: Rate limit hit. Honour the `Retry-After` header.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            code: throttled
            data:
              message: Request was throttled. Expected available in 12 seconds.
              retry_after: 12
    Error500:
      description: >-
        Unexpected internal error. Quote `data[1]` (the token) when contacting
        support.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            status: error
            code: server_error
            data:
              - 'An error has occurred, token:'
              - ABC123abc456DEF789gh
  schemas:
    ErrorEnvelope:
      type: object
      description: >-
        Standard error response. The shape of `data` varies by `code` - see the
        **Errors** section in the introduction and the named examples on each
        endpoint's error responses.
      required:
        - status
        - code
      properties:
        status:
          type: string
          enum:
            - error
        code:
          type: string
          description: >-
            Stable machine-readable error code (e.g. `validation_error`,
            `not_found`, `forbidden`).
          example: validation_error
        data:
          description: Variable. Null, string, or object - see examples.
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-Api-Key
      description: API key created from the magic-markets website

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.