MagicMarkets 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 - a bad key fails the WebSocket upgrade with HTTP 401, so verify the key 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. Three fields are required (betslip_id, price, stake); duration is the order's lifetime in seconds (optional, 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. 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:

["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 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> - 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 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, 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.

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.

LiveScore (object) or null

The in-running score you are selecting against, e.g. {"home": 1, "away": 0}. Liquidity a source is quoting at a different in-running score is then never treated as equivalent to your selection, so a line that has already moved on a goal cannot be matched against it. The score stays on the betslip and applies again when an order placed on it re-selects liquidity. Omit it (or send null) for pre-match selections and to accept quotes at whatever score a source is on.

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

Close betslip

Close an open betslip. Its quotes stop, and it no longer counts toward the open-betslip cap.

Authorizations:
ApiKeyHeader
path Parameters
betslip_id
required
string

Betslip ID

Responses

Response samples

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

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

Limit decimal price. A bet is taken only at this price or better, and an off-tick limit moves to the first tick that honours it; see "Price ticks".

required
Array of items (StakeTuple) = 2 items

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

duration
number

Order duration in seconds (default 15)

exchange_mode
string
Default: "make_and_take"
Enum: "make_and_take" "take_only" "dark"

How the order interacts with the exchange. make_and_take: fill against the best available liquidity first; any remaining stake is advertised on the exchange at your price, while the order keeps taking newly available liquidity. take_only: only consume available liquidity; remaining stake is never advertised and other orders cannot match against it. dark: like make_and_take, but the advertised remaining stake is hidden from other customers - they can still match it when their price crosses yours, but they cannot see your price until then. There is no post-only mode: no order type rests in the book without also taking crossing liquidity.

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

dark orders only (rejected on other modes): the minimum total stake another order must request to be allowed to match yours at your price point. Cannot exceed the order's own stake. Set it non-zero to stop small probe orders from discovering your price.

Array of items

Placement-time score assertion, [home, away]. When present, the order is rejected with validation_error / non_field_errors: ["event_scores_dont_match"] unless the value matches the live score the exchange holds for the event - use it to avoid placing on a price that has not reacted to a goal yet. Only meaningful for football: for sports without a goal-style running score, and while no score is known yet, the server assumes [0, 0] and rejects any other value. Omit the field to skip the check.

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.

object

Optional per-source minimum stakes, keyed by source. A source is only used if it can take at least its minimum. Values are [currency, amount] tuples.

placer_type
string or null

Optional caller-supplied tag recorded against the order.

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 for several days after placement (until the order is purged upstream).

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 [ 1 .. 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 <= 12 characters

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

Accepted for compatibility. cashout_info is currently returned on every position regardless of this flag.

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. If the timer expires before being refreshed or cancelled, every open order on the account is closed, including orders placed by other sessions or API keys; only orders created after the expiry instant are spared. Closing cancels the unfilled stake and withdraws unmatched liquidity advertised on the exchange; bets that already matched are unaffected. Expiry is evaluated server-side about once per second.

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. Cancelling disarms the timer without closing any orders; only expiry triggers the order close-out. 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. A heartbeat that has already expired cannot be refreshed - its close-out has been triggered; open a new heartbeat instead.

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

Market data

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.

List events

Lists the events that currently have prices. Each entry is the object the stream sends as ["event", {...}].

For price-sensitive work, use the streaming API: it pushes each change as it happens.

ir_status keeps the events in running (in_running) or not yet started (pre_event), the same values each event carries.

event_type gives the shape. A normal (match) event has home and away. A multirunner (outright) event has teams and end_time.

Page with limit and after: pass the previous page's last "{sport},{event_id}" as after. A page shorter than limit is the last one. The response has no total count.

event_id and competition_id filters need sport; a request without it gets a 400. Every entry has its sport, so a follow-up call can always supply it. Paging without filters needs no sport.

A filter that matches no event returns an empty data. This includes a sport, competition or event that has no prices at the moment. Each event_id must have the form an event carries, such as 2026-06-15,1001,2002; any other form gets a 400.

Authorizations:
ApiKeyHeader
query Parameters
sport
Array of strings <= 100 items
Example: sport=fb

Sport codes to keep. Repeatable, up to 100 values; omit for every sport. Required alongside event_id or competition_id. See "Sports & bet types" in the introduction.

competition_id
Array of integers <= 100 items [ items [ 0 .. 2147483647 ] ]

Competitions to keep. Repeatable, up to 100 values; omit for every competition. Requires sport.

event_id
Array of strings <= 100 items [ items^\d{4}-\d{2}-\d{2},[^,]+,[^,]+$ ]

Events to keep. Repeatable, up to 100 values; omit for every event. Requires sport. Each value is an event's event_id, such as 2026-06-15,1001,2002; any other form gets a 400.

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

Language for competition and team names.

limit
integer [ 1 .. 5000 ]
Default: 500

Events per page.

after
string
Example: after=fb,2026-06-15,1001,2002

Cursor - the previous page's last "{sport},{event_id}".

start_time_from
string <date-time>
Example: start_time_from=2026-06-15T00:00:00Z

Keep events starting at or after this instant, 1970 or later. An event whose start time is unknown is never inside a window.

start_time_to
string <date-time>
Example: start_time_to=2026-06-16T00:00:00Z

Keep events starting before this instant, 1970 or later. The window is half-open, so an event starting exactly at this instant is excluded.

ir_status
string
Enum: "pre_event" "in_running"

Keep events with this ir_status. Omit for both.

Responses

Response samples

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

List offers for an event

Returns the stake available at each price, for each market on the named events. Each entry is the object the stream sends as ["offer", {...}].

A reply can be about 4 seconds behind the live prices: the stream updates every 2 seconds, and a client may cache the response for 2 seconds (Cache-Control: private, max-age=2). For live prices, register the event on the stream with register_event.

sport and event_id are required, with up to 20 events per request. GET /v2/events/ lists them.

bet_type limits the response to the named markets. It takes the same strings as POST /v2/betslips/, so a bet_type from here goes into a betslip unchanged.

market_type keeps the named market families, such as ah, ou or wdw. min_liquidity keeps a market when its max stakes, added up across its price_list, come to at least that many USDT.

Offers come back in event_id order, then bet_type order. Page with limit and after: pass the previous page's last "{event_id},{bet_type}" as after. A page shorter than limit is the last one. Each page is read when it is requested, so for a consistent view of an event, use the stream.

Markets with no available stake are left out. for and against on one selection are two markets, each with its own bet_type. For a sport your account is not enabled for, the list is empty, as on the stream.

An event that has no prices at the moment, or that the feed does not know, returns no offers. The other events in the request still come back. Each event_id must have the form an event carries, such as 2026-06-15,1001,2002; any other form gets a 400.

Authorizations:
ApiKeyHeader
query Parameters
sport
required
string
Example: sport=fb

Sport code. See "Sports & bet types" in the introduction.

event_id
required
Array of strings [ 1 .. 20 ] items [ items^\d{4}-\d{2}-\d{2},[^,]+,[^,]+$ ]
Example: event_id=2026-06-15,1001,2002

Events to price. Repeatable, up to 20 per request. Each value is an event's event_id, such as 2026-06-15,1001,2002; any other form gets a 400.

bet_type
Array of strings <= 100 items

Markets to keep. Repeatable, up to 100 values; omit for every market on the event. See "Sports & bet types" in the introduction.

market_type
Array of strings <= 100 items
Example: market_type=ah&market_type=ou

Market families to keep, as in an offer's market_type. Repeatable, up to 100 values; omit for every family.

min_liquidity
number >= 0
Example: min_liquidity=250

USDT. Keep markets whose max stakes, added up across the price_list, come to at least this.

limit
integer [ 1 .. 5000 ]
Default: 500

Offers per page.

after
string
Example: after=2026-06-15,1001,2002,for,ah,h,1

Cursor - the previous page's last "{event_id},{bet_type}".

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

The server does not restrict the Origin header, so browser-based clients - including pages opened from file:// - can connect directly.

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], "smart_credit": ["EUR", 300.0]}],
  ["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.
  • order - want_stake, stake and profit_loss are in USDT; each entry of nested bets[] follows the bet format.
  • bet - want_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.
  • smart_credit: [currency, amount] - extra funds available for betting on top of the balance, equal to the amount the account is expected to win from the bets already placed. Absent when the account has no smart credit.

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 and admission are enforced at the HTTP handshake. A refused upgrade returns a non-101 status with a short text/plain body naming the reason. Clients see a handshake error.

Status Body What to do
400 missing_credentials, invalid_lang api_key is missing, or lang is not an allowed value. Fix the request.
401 auth_rejected The key was not accepted. Do not retry with the same key; check it against a REST endpoint.
503 unavailable The server cannot accept connections right now. Retry with backoff.

Treat all other 4xx as 401 and all other 5xx as 503. The websockets library raises InvalidStatus with the body on exc.response.body.

Backpressure - if you read too slowly, the server's outbound buffer overflows and the server closes with code 1008. Read faster or register fewer events. An immediate reconnect overflows again. A client that stopped reading never receives the frame and sees only the raw TCP close.

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

  • 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