Magic Markets API (0.1.0)

Download OpenAPI specification:

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:

  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:

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:

pip install requests websockets

Every snippet reads its configuration from the environment:

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 — the WebSocket closes silently on a bad key, so verify here first:

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):

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.:

["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", …]:

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:

["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:

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:

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:

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]
["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. Four fields are required — duration is the order's lifetime in seconds (default 15):

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):

{
  "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:

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:

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:

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

On error:

{ "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.
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.
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 (no body envelope) magic-api could not reach the upstream — body is { "detail": "Service unavailable" }.

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. The window is sliding — capacity frees up as earlier requests age out, with no calendar-aligned reset.

Applies to Limit
All endpoints 100 requests/second burst, 1200 requests/minute sustained
POST /v2/betslips/ 10 requests/second
POST /v2/orders/ 5 requests/second

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. There are no daily caps, and these are the only rate limits — no separate per-IP limit applies.

A rejected request gets a 429 throttled error with the wait in Retry-After and data.retry_after (integer seconds).

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:

["USDT", 115.38]

Price ticks

All 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

A price you submit on an order is snapped onto this schedule. An off-tick price is rounded to the nearest valid tick that does not tighten your limit: down for back (for) orders, up for lay (against) orders. The snapped price is the one the order runs with and the one reported back in the order response.

Feed prices

Every price delivered on the stream (price_list, offers, pmms) is already on the schedule, so a price quoted straight from the feed is always valid and is never re-rounded.

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 × 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 ∞)
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 × 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,o,2.5 Match-result + over/under combo
for,mo_both_score,h,yes Home wins AND both teams score
for,aou,h,3 Betfair "any other unquoted", 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>15 (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 19
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 15

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 means the string did not parse.

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 up to 6 hours after placement.

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 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).

Betslips

Create and retrieve betslips. Quotes arrive asynchronously as a single price_list (stakes in USDT): poll GET /v2/betslips/{betslip_id}/ or watch the stream.

List betslips

Returns all open betslip IDs for the authenticated customer.

Authorizations:
ApiKeyHeader

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": [
    ]
}

Create betslip

Create a new betslip. For normal/lay bets supply sport, event_id, and bet_type. For parlays supply a legs array instead.

The response carries no prices: quotes are gathered asynchronously. Poll GET /v2/betslips/{betslip_id}/ until price_list populates (typically a couple of seconds; watch expiry_ts), or read the quote off the stream.

Authorizations:
ApiKeyHeader
Request Body schema: application/json
required
sport
string

Sport code (required for normal/lay) — see "Sports & bet types" in the introduction.

event_id
string

Event ID (required for normal/lay)

bet_type
string

Bet type string (required for normal/lay) — see "Sports & bet types" in the introduction.

Array of objects [ 2 .. 10 ] items

Parlay legs (required for parlay, 2–10 legs)

betslip_type
string
Default: "normal"
Enum: "normal" "lay" "parlay"
equivalent_bets
boolean
Default: true
user_data
string or null <= 512 characters
exclude_danger
boolean
Default: false

When true, only liquidity sources that do not hold bets in danger status are used. When false or omitted, all available liquidity sources are used.

Responses

Request samples

Content type
application/json
Example
{
  • "sport": "fb",
  • "event_id": "2026-06-15,1001,2002",
  • "bet_type": "for,h",
  • "betslip_type": "normal",
  • "equivalent_bets": true
}

Response samples

Content type
application/json
Example
{
  • "status": "ok",
  • "data": {
    }
}

Get betslip

Returns a single betslip with prices and stakes in USDT. price_list may be empty until quotes arrive, or when nothing is currently quoting the selection.

Authorizations:
ApiKeyHeader
path Parameters
betslip_id
required
string

Betslip ID

Responses

Response samples

Content type
application/json
Example
{
  • "status": "ok",
  • "data": {
    }
}

Refresh betslip

Extend the betslip expiration timeout.

Authorizations:
ApiKeyHeader
path Parameters
betslip_id
required
string

Betslip ID

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": null
}

Orders

Place and retrieve orders. All stake fields are converted to USDT. Supports three order formats: normal (match sports), multirunner (outrights), and parlay (accumulators).

List orders

Returns a paginated list of orders for the authenticated customer.

Authorizations:
ApiKeyHeader
query Parameters
page
integer >= 1
Default: 1

Page number

page_size
integer [ 1 .. 1000 ]
Default: 25

Results per page

status
Array of strings

Filter by status (open, pending, done, failed)

sport
Array of strings

Filter by sport code — see "Sports & bet types" in the introduction.

event_id
Array of strings

Filter by event ID

order_type
Array of strings

Filter by order type (normal, lay, parlay)

date_from
string <date-time>

Start of date range (ISO 8601)

date_to
string <date-time>

End of date range (ISO 8601)

search
string

Free-text search

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": [
    ]
}

Create order

Places a new order on an existing betslip.

Authorizations:
ApiKeyHeader
Request Body schema: application/json
required
betslip_id
required
string
price
required
number

Desired decimal price. Off-tick prices are rounded down for back (for) orders and up for lay (against) orders; see "Price ticks".

required
Array of items (StakeTuple) = 2 items

[currency, amount] — e.g. ["USDT", 115.38]

duration
required
number

Order duration in seconds (default 15)

exchange_mode
string
Default: "make_and_take"
Enum: "make_and_take" "make" "take"
keep_open_ir
boolean
Default: false

Keep order open when event goes in-play

user_data
string or null <= 512 characters
request_uuid
string

Idempotency key

accept_partial_fill
boolean
Default: true
accept_better_price
boolean
Default: true
force_want_price
boolean
Default: false
Array of StakeTuple (items) or null
current_score
string or null

Current match score for in-play orders

exclude_danger
boolean
Default: false

When true, only liquidity sources that do not hold bets in danger status are used. When false or omitted, all available liquidity sources are used.

Responses

Request samples

Content type
application/json
Example
{
  • "betslip_id": "bs-single-001",
  • "price": 3.25,
  • "stake": [
    ],
  • "duration": 5,
  • "exchange_mode": "make_and_take"
}

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Order updates

Returns orders updated within the given time range. Both updated_at_from and updated_at_to must be at least 60 seconds in the past, and the window (updated_at_to − updated_at_from) must not exceed 70 minutes. For longer syncs, page through successive 70-minute windows.

Authorizations:
ApiKeyHeader
query Parameters
updated_at_from
required
string <date-time>

Start of update window (ISO 8601). Must be at least 60 seconds in the past.

updated_at_to
required
string <date-time>

End of update window (ISO 8601). Must be at least 60 seconds in the past and within 70 minutes of updated_at_from.

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": [
    ]
}

Get order

Returns a single order by ID with all stakes in USDT.

Authorizations:
ApiKeyHeader
path Parameters
order_id
required
string

Order ID

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Get order by UUID

Retrieve an order using the request_uuid from order creation instead of the order ID. Available up to 6 hours after placement.

Authorizations:
ApiKeyHeader
path Parameters
uuid
required
string <uuid>

The request_uuid used when creating the order

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Close an order

Close (cancel) a single open order. The order's lifecycle update is delivered on the WebSocket as an ["order", …] entry with closed: true and close_reason: "cancelled". The response data field is always null.

Authorizations:
ApiKeyHeader
path Parameters
order_id
required
integer

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": null
}

Close multiple orders

Close multiple orders synchronously. Maximum 500 order IDs per request.

Authorizations:
ApiKeyHeader
Request Body schema: application/json
required
order_ids
required
Array of integers <= 500 items

Responses

Request samples

Content type
application/json
{
  • "order_ids": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Close all open orders

Request cancellation of all open orders. Optionally filter by sport and/or event.

Authorizations:
ApiKeyHeader
Request Body schema: application/json
optional
sport
string

Only close orders on this sport — see "Sports & bet types" in the introduction.

event_id
string

Only close orders on this event (requires sport)

Responses

Request samples

Content type
application/json
Example
{ }

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": [
    ]
}

Calculate position

Calculate profit/loss position based on filtered orders. Accepts the same query parameters as the list orders endpoint.

Authorizations:
ApiKeyHeader
query Parameters
status
Array of strings
sport
Array of strings
event_id
Array of strings
order_type
Array of strings
date_from
string <date-time>
date_to
string <date-time>
search
string
include_cashout_info
boolean
Default: false

Include a cashout valuation for the position (football only)

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Account

Account-level information: current balance, open stake, and smart credit, all in USDT.

Account balance

Returns the authenticated user's current balance, total stake on open bets, and smart credit. All values are ["USDT", amount] tuples. The same three figures are pushed on the stream as the balance message; open_stake is positive.

Authorizations:
ApiKeyHeader

Responses

Response samples

Content type
application/json
Example
{
  • "status": "ok",
  • "data": {
    }
}

Heartbeats

Heartbeat timers that automatically close all open orders when they expire. Use these as a dead-man's switch for automated trading.

Create heartbeat

Open a new heartbeat timer. When it expires all open orders are closed.

Authorizations:
ApiKeyHeader
Request Body schema: application/json
required
timeout
required
integer [ 10 .. 300 ]

Seconds before the heartbeat expires

Responses

Request samples

Content type
application/json
{
  • "timeout": 60
}

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

List heartbeats

Returns all currently open heartbeats. Note: unlike other list endpoints (/v2/orders/, /v2/betslips/) which return a flat array under data, this endpoint wraps the array under data.heartbeats for historical reasons. Clients must special-case this shape.

Authorizations:
ApiKeyHeader

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Get heartbeat

Returns information about a single heartbeat.

Authorizations:
ApiKeyHeader
path Parameters
heartbeat_id
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Cancel heartbeat

Cancel an active heartbeat. The response data field is always null.

Authorizations:
ApiKeyHeader
path Parameters
heartbeat_id
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": null
}

Refresh heartbeat

Extend the heartbeat expiration timeout.

Authorizations:
ApiKeyHeader
path Parameters
heartbeat_id
required
string

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Reference

Exchange rates and bet type information.

Exchange rates

Returns current exchange rates.

Authorizations:
ApiKeyHeader

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": [
    ]
}

Bet type info

Returns information about a bet type including the win/loss payout grid.

Authorizations:
ApiKeyHeader
path Parameters
sport
required
string

Sport code — see "Sports & bet types" in the introduction.

bet_type
required
string

Bet type string — see "Sports & bet types" in the introduction.

query Parameters
home_team
string

Home team name for display labels

away_team
string

Away team name for display labels

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "data": {
    }
}

Streaming API

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.

WebSocket stream

WebSocket endpoint — upgrade an HTTP connection to receive real-time prices. This is a separate service from the REST API.

Connection

Authenticate with an API key:

ws://<host>/v2/stream?api_key=<api_key>

Query parameters:

Param Required Description
api_key yes API key (the X-Api-Key value)
lang no Language code: en (default), ko, zh-hans

Wire format

Every frame sent by the server is a batch envelope:

{"ts": 1586042815.269000, "data": [ <message>, <message>,]}
  • ts — Unix timestamp in seconds with microsecond precision, stamped by the server when the frame is written.
  • data — one or more messages. The ["offer", …], ["response", …], ["event", …], ["remove_event", …], ["sync", …], live-event-state and account-update arrays shown below are the individual data[] entries; they are never sent as bare top-level frames.

Multiple messages may be batched into a single envelope — e.g. a register snapshot and its ok ["response", …] together, or offers alongside account updates. Batching boundaries are not semantically meaningful — iterate data[] and dispatch on each entry's leading type tag (entry[0]); never rely on ordering, grouping, or a type appearing exactly once per frame.

Initial sync

After connecting, the server sends a snapshot of the currently-priced events followed by a ["sync", {…}] marker whose payload carries the session_id of this stream (useful when correlating with REST errors or contacting support). Each event is an ["event", {…}] entry — a flat object carrying the identifiers and metadata — delivered inside the envelope:

{"ts": 1586042815.269000, "data": [
  ["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"}],
  ["event", {"event_type": "normal", "sport": "tn", "event_id": "2026-06-16,501,502", ...}],
  ["event", {"event_type": "multirunner", "sport": "af", "event_id": "2026-02-23,multirunner,100364405", "competition_id": 545, "competition_name": "USA NFL", "competition_country": "US", "teams": [{"team_id": 21614, "name": "Arizona Cardinals"}, {"team_id": 21615, "name": "Atlanta Falcons"}], "event_name": "NFL Super Bowl Winner", "start_time": "2026-02-23T21:00:00Z", "end_time": "2027-02-14T21:00:00Z"}],
  ["sync", {"session_id": "…"}]
]}

Two event shapes appear. A normal (match) event carries home and away. A multirunner (outright / futures) event has no home/away; instead it carries a teams array ([{"team_id", "name"}, …], one per runner) and an end_time. Dispatch on event_type; read the runner list from teams for multirunners.

The snapshot is not the full fixture list: it contains only events that currently have live prices. The dump may span several envelopes; ["sync", …] is the last data[] entry of the final one. From then on, changed events are re-sent as ["event", {…}] entries, and an event whose prices disappear is delivered as ["remove_event", {"sport": …, "event_id": …, …}].

Live event state

Events that are in play may additionally produce state updates as data[] entries. Payloads are sport-specific; the football shapes:

["event_time", {"sport": "fb", "event_id": "…", "time": ["1h", 23]}]
["event_score", {"sport": "fb", "event_id": "…", "score": [1, 0]}]
["event_red_cards", {"sport": "fb", "event_id": "…", "score": [0, 1]}]
  • time[period, minutes], where the football periods are "1h", "2h" and "ht"; null when no clock is available.
  • score[home, away] (the event_red_cards payload reuses the score key for the red-card counts).
  • ["ir_info", {…}] carries a full in-running state snapshot for an event (fields vary by sport), and ["remove_ir_info", {"sport": …, "event_id": …}] signals the state is gone — treat both as informational.
  • ["event_exchange_dark_liquidity", {"sport": …, "event_id": …, "lines": {…}}] — a rough estimate of additional liquidity available per line on the event, beyond the published offers. Informational.

Commands

Register for offers on an event:

["register_event", "<sport>", "<event_id>"]

On success the server immediately sends one ["offer", …] per active bet type on the event (the snapshot), then an ok response — typically batched in one envelope: {"ts": …, "data": [["offer", {…}], ["offer", {…}], ["response", {"status": "ok", "data": null}]]}. From then on, whenever the offers on the event change, the full current set is re-broadcast (with the affected bet types updated) and any bet type that has lost all liquidity is delivered as ["remove_offer", …].

Unregister:

["unregister_event", "<sport>", "<event_id>"]

Server responds with ["response", {"status": "ok", "data": null}] — also when the event was not registered (unregistering is idempotent). No further offer / remove_offer messages are sent for that event.

List currently registered events:

["list_registered_events"]

Server responds with the full set of (sport, event_id) pairs the session is registered for:

["response", {"status": "ok", "data": {
  "registered_events": [
    ["fb", "2026-06-15,1001,2002"],
    ["tennis", "2026-06-16,501,502"]
  ]
}}]

Keepalive (echo):

["echo", "any-payload"]["response", {"status": "ok", "data": ["any-payload"]}]

Arguments are optional, may be any JSON values, and are echoed back verbatim in data. The server also sends an ["info", …] entry every few seconds, so an idle connection still receives regular traffic.

offer / remove_offer messages

Each offer describes the available stake at every price for one (sport, event_id, bet_type) triple. The bet_type string fully identifies the market side (it encodes the market, handicap, outcome and for/against direction), so each triple is a distinct, independently-updated offer — for and against on the same selection arrive as two separate offer messages with different bet_type values.

["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]}}
  ]
}]

Each price_list entry is {"effective": {"price": <decimal>, "min": <stake|null>, "max": <stake>}}; stakes are ["USDT", amount] arrays. The min and max keys are always present:

  • min — the minimum stake accepted at that price; null when there is no minimum.
  • max — the total stake available at that price. Always a ["USDT", amount] pair (a price with no available stake is not published).

Entries are ordered by price (the decimal odds) descending, with at most one entry per price.

remove_offer carries only the (sport, event_id, bet_type) triple — that bet type has no remaining liquidity for the event:

["remove_offer", {
  "sport": "fb",
  "event_id": "2026-06-15,1001,2002",
  "bet_type": "for,ah,h,1"
}]

Account update messages

Account-level updates arrive on the same WebSocket, as plain entries inside the same {"ts": …, "data": […]} envelope that carries offer / remove_offer — siblings of the market-data messages.

{"ts": 1586042815.269000, "data": [
  ["balance", {"balance": ["EUR", 10000.1], "open_stake": ["EUR", 152.55]}],
  ["xrate", {"ccy": "EUR", "rate": 1.1347}],
  ["order", {...}],
  ["bet", {...}],
  ["pmm", {...}],
  ["betslip", {...}],
  ["info", {...}]
]}

data[] may contain these account entry types:

  • balance, xrate — amounts are in your account's native currency.
  • orderwant_stake, stake and profit_loss are in USDT; each entry of nested bets[] follows the bet format.
  • betwant_stake, got_stake, profit_loss and status.response_pmm.effective.min/max are in USDT.
  • pmm, betslip — the live quote and state of an open betslip (see the Quickstart). price_list entries follow the same {"effective": {"price", "min", "max"}} format as offer messages; price_list and total are in USDT, prices sorted descending.
  • betslip_closed{"betslip_id": …, "close_reason": …}. The betslip expired (betslips are short-lived) or was closed; no further pmm quotes will arrive for it. Create a new betslip to re-quote the selection.
  • info — feed status; registered_events is the number of events currently registered on this connection.
  • clear_events — the server lost its upstream market data feed: discard all event, offer and live-state data you hold. A fresh snapshot (events, then ["sync", …]) follows when the feed recovers.

The order, presence, and count of entry types within data[] are not contractual — the example above shows one possible ordering only. Dispatch on each entry's type tag.

balance message fields:

  • balance: [currency, amount] — current account balance in the customer's native currency.
  • open_stake: [currency, amount] — total stake across all unsettled bets, in the same currency.

Errors

Recoverable command-level errors arrive in-band on the open connection. The socket stays up; you can keep sending commands. Like every other message, the error element is a data[] entry inside the envelope (possibly batched with other messages):

{"ts": 1586042815.269000, "data": [["response", {"status": "error", "code": "<code>"}]]}

Codes emitted directly by the stream:

Code When
bad_json Frame is not valid JSON, not a JSON array, or an empty array.
invalid_input The command name is not a string or not recognised, or its argument shape is wrong for the command.
already_registered register_event for an event already registered on this session.
customer_event_limit_exceeded register_event would exceed your registered-events limit (counted across all your connections). Unregister something first.
invalid_customer register_event while the feed does not recognise your customer record (e.g. not yet propagated after a server restart). Retry after a short backoff; contact support if it persists.
system_error Transient server-side failure — retry after a short backoff.

Note there is no "unknown event" error: registering an event the feed has no prices for succeeds with an empty snapshot, and unregister_event of an unregistered event returns ok. Treat any other code string as opaque — log it and retry after a short backoff.

Authentication is enforced at the HTTP handshake: a missing or invalid api_key makes the WebSocket upgrade fail with a non-101 HTTP response, which clients see as a handshake error (for example the websockets library raises InvalidStatus). An invalid key takes slightly longer to reject than a missing one, since it is checked server-side. Verify the key against a REST endpoint before opening the socket.

Three classes of failure drop an established connection silently (raw TCP close, no WebSocket close frame, no in-band error):

  • Backpressure — the client is reading too slowly and the server's outbound buffer overflows. Reconnect and resume.
  • I/O error — any read or write failure on the socket.
  • Internal error — a rare server-side failure; not client-triggerable and observably identical to an I/O error. Reconnect with backoff.
Authorizations:
ApiKeyHeader
query Parameters
api_key
required
string

API key (the X-Api-Key value)

lang
string
Default: "en"
Enum: "en" "ko" "zh-hans"

Language for event/competition names

Responses