Download OpenAPI specification:
REST and WebSocket API for MagicMarkets. Place orders, stream real-time prices, and manage your positions programmatically.
Every request must be authenticated with an API key. Pass it in the
X-Api-Key header:
X-Api-Key: <your-api-key>
API keys are created and managed through the MagicMarkets website at magicmarkets.com:
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.
Send the key on every request:
curl https://magicmarkets.com/v2/xrates/ \
-H "X-Api-Key: $MAGIC_API_KEY"
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.
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:
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).["USDT", amount] pair.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.
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"}]
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.
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"])
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.
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
}
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.
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.
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.
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.
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.
| 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.
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.
Stake fields are [currency, amount] tuples. Stakes in responses are
always returned in USDT:
["USDT", 115.38]
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.
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.
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.
Every betslip, order and bet carries a sport and a bet_type. They
are short, opaque-looking strings whose grammar is described here.
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 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 |
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 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.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.
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).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.
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.
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 documentGET /docs with an Accept: text/markdown header returns the Markdown
reference instead of HTMLAll paths also work behind the /magic-api ingress prefix
(e.g. /magic-api/llms.txt, /magic-api/v2/openapi.yaml).
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.
Returns all open betslip IDs for the authenticated customer.
{- "status": "ok",
- "data": [
- "f0a46f72af524d1aa16da56f4e3d23d6",
- "d796fb4a52f747ce827160af46d60cfd",
- "56e045c8d62542ecb492e7843ac6b24e"
]
}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.
| 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. |
{- "sport": "fb",
- "event_id": "2026-06-15,1001,2002",
- "bet_type": "for,h",
- "betslip_type": "normal",
- "equivalent_bets": true
}{- "status": "ok",
- "data": {
- "betslip_id": "bs-spread-002",
- "sport": "fb",
- "event_id": "2026-06-15,3003,4004",
- "bet_type": "for,ahover,7",
- "bet_type_description": "Over 1.5 (Asian)",
- "expiry_ts": 1781234999,
- "is_open": true,
- "close_reason": null,
- "equivalent_bets": false,
- "customer_username": "user1",
- "customer_ccy": "USDT",
- "betslip_type": "normal",
- "user_data": null
}
}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.
| betslip_id required | string Betslip ID |
{- "status": "ok",
- "data": {
- "betslip_id": "bs-spread-002",
- "sport": "fb",
- "event_id": "2026-06-15,3003,4004",
- "bet_type": "for,ahover,7",
- "bet_type_description": "Over 1.5 (Asian)",
- "expiry_ts": 1781234999,
- "is_open": true,
- "close_reason": null,
- "equivalent_bets": false,
- "customer_username": "user1",
- "customer_ccy": "USDT",
- "betslip_type": "normal",
- "price_list": [
- {
- "effective": {
- "price": 7.4,
- "min": [
- "USDT",
- 5.769
], - "max": [
- "USDT",
- 92.304
]
}
}, - {
- "effective": {
- "price": 4.2,
- "min": [
- "USDT",
- 3.4614
], - "max": [
- "USDT",
- 173.07
]
}
}, - {
- "effective": {
- "price": 2.1,
- "min": null,
- "max": [
- "USDT",
- 346.14
]
}
}
], - "total": [
- "USDT",
- 611.514
], - "user_data": null
}
}Place and retrieve orders. All stake fields are converted to USDT. Supports three order formats: normal (match sports), multirunner (outrights), and parlay (accumulators).
Returns a paginated list of orders for the authenticated customer.
| 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 |
{- "status": "ok",
- "data": [
- {
- "order_id": 0,
- "order_type": "normal",
- "bet_type": "string",
- "bet_type_description": "string",
- "sport": "string",
- "want_price": 0,
- "want_stake": [
- "string",
- 0
], - "ccy_rate": 0,
- "placement_time": "2019-08-24T14:15:22Z",
- "expiry_time": "2019-08-24T14:15:22Z",
- "closed": true,
- "close_reason": "string",
- "event_info": {
- "event_type": "normal",
- "event_id": "string",
- "event_name": "string",
- "home_id": 0,
- "home_team": "string",
- "away_id": 0,
- "away_team": "string",
- "competition_id": 0,
- "competition_name": "string",
- "competition_country": "string",
- "start_time": "2019-08-24T14:15:22Z",
- "date": "2019-08-24",
- "result": {
- "ht_home": 0,
- "ht_away": 0,
- "ft_home": 0,
- "ft_away": 0,
- "runner_results": [
- {
- "team_id": 0,
- "position": 0
}
], - "non_runner_count": 0
}, - "teams": [
- {
- "team_id": 0,
- "name": "string"
}
], - "end_time": "2019-08-24T14:15:22Z",
- "leg_event_infos": [
- { }
]
}, - "bets": [
- {
- "bet_id": 0,
- "order_id": 0,
- "order_ccy_rate": 0,
- "status": "string",
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "ccy_rate": 0,
- "want_price": 0,
- "got_price": 0,
- "want_stake": [
- "string",
- 0
], - "got_stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "reconciled": "string",
- "exchange_role": "maker",
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
], - "user_data": "string",
- "status": "string",
- "keep_open_ir": true,
- "exchange_mode": "make_and_take",
- "price": 0,
- "stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "bet_bar_values": { },
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
]
}Places a new order on an existing betslip.
| betslip_id required | string |
| price required | number Desired decimal price. Off-tick prices are rounded down for back ( |
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. |
{- "betslip_id": "bs-single-001",
- "price": 3.25,
- "stake": [
- "USDT",
- 100
], - "duration": 5,
- "exchange_mode": "make_and_take"
}{- "status": "ok",
- "data": {
- "order_id": 5001,
- "order_type": "normal",
- "bet_type": "for,h",
- "bet_type_description": "Home",
- "sport": "fb",
- "want_price": 3.25,
- "want_stake": [
- "USDT",
- 115.38
], - "ccy_rate": 1,
- "placement_time": "2026-06-15T12:00:00+00:00",
- "expiry_time": "2026-06-15T12:05:00+00:00",
- "closed": false,
- "close_reason": null,
- "status": "open",
- "keep_open_ir": false,
- "exchange_mode": "make_and_take",
- "price": null,
- "stake": null,
- "profit_loss": null,
- "bet_bar_values": null,
- "legs": null,
- "event_info": {
- "event_type": "normal",
- "event_id": "2026-06-15,1001,2002",
- "event_name": "Arsenal vs. Chelsea",
- "home_id": 1001,
- "home_team": "Arsenal",
- "away_id": 2002,
- "away_team": "Chelsea",
- "competition_id": 1,
- "competition_name": "England Premier League",
- "competition_country": "XE",
- "start_time": "2026-06-15T15:00:00+00:00",
- "date": "2026-06-15",
- "result": null
}, - "bets": [ ],
- "user_data": null
}
}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.
| 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 |
{- "status": "ok",
- "data": [
- {
- "order_id": 0,
- "order_type": "normal",
- "bet_type": "string",
- "bet_type_description": "string",
- "sport": "string",
- "want_price": 0,
- "want_stake": [
- "string",
- 0
], - "ccy_rate": 0,
- "placement_time": "2019-08-24T14:15:22Z",
- "expiry_time": "2019-08-24T14:15:22Z",
- "closed": true,
- "close_reason": "string",
- "event_info": {
- "event_type": "normal",
- "event_id": "string",
- "event_name": "string",
- "home_id": 0,
- "home_team": "string",
- "away_id": 0,
- "away_team": "string",
- "competition_id": 0,
- "competition_name": "string",
- "competition_country": "string",
- "start_time": "2019-08-24T14:15:22Z",
- "date": "2019-08-24",
- "result": {
- "ht_home": 0,
- "ht_away": 0,
- "ft_home": 0,
- "ft_away": 0,
- "runner_results": [
- {
- "team_id": 0,
- "position": 0
}
], - "non_runner_count": 0
}, - "teams": [
- {
- "team_id": 0,
- "name": "string"
}
], - "end_time": "2019-08-24T14:15:22Z",
- "leg_event_infos": [
- { }
]
}, - "bets": [
- {
- "bet_id": 0,
- "order_id": 0,
- "order_ccy_rate": 0,
- "status": "string",
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "ccy_rate": 0,
- "want_price": 0,
- "got_price": 0,
- "want_stake": [
- "string",
- 0
], - "got_stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "reconciled": "string",
- "exchange_role": "maker",
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
], - "user_data": "string",
- "status": "string",
- "keep_open_ir": true,
- "exchange_mode": "make_and_take",
- "price": 0,
- "stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "bet_bar_values": { },
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
]
}Returns a single order by ID with all stakes in USDT.
| order_id required | string Order ID |
{- "status": "ok",
- "data": {
- "order_id": 5001,
- "order_type": "normal",
- "bet_type": "for,h",
- "bet_type_description": "Home",
- "sport": "fb",
- "want_price": 3.2,
- "want_stake": [
- "USDT",
- 115.38
], - "ccy_rate": 1,
- "placement_time": "2026-06-15T12:00:00+00:00",
- "expiry_time": "2026-06-15T12:05:00+00:00",
- "closed": true,
- "close_reason": "filled",
- "status": "done",
- "keep_open_ir": false,
- "exchange_mode": "make_and_take",
- "price": 3.2,
- "stake": [
- "USDT",
- 115.38
], - "profit_loss": [
- "USDT",
- 253.84
], - "bet_bar_values": null,
- "legs": null,
- "event_info": {
- "event_type": "normal",
- "event_id": "2026-06-15,1001,2002",
- "event_name": "Arsenal vs. Chelsea",
- "home_id": 1001,
- "home_team": "Arsenal",
- "away_id": 2002,
- "away_team": "Chelsea",
- "competition_id": 1,
- "competition_name": "England Premier League",
- "competition_country": "XE",
- "start_time": "2026-06-15T15:00:00+00:00",
- "date": "2026-06-15",
- "result": {
- "ht_home": 0,
- "ht_away": 0,
- "ft_home": 2,
- "ft_away": 1
}
}, - "bets": [
- {
- "bet_id": 1,
- "order_id": 5001,
- "order_ccy_rate": 1,
- "status": {
- "code": "success"
}, - "sport": "fb",
- "event_id": "2026-06-15,1001,2002",
- "bet_type": "for,h",
- "ccy_rate": 1,
- "want_price": 3.2,
- "got_price": 3.2,
- "want_stake": [
- "USDT",
- 115.38
], - "got_stake": [
- "USDT",
- 115.38
], - "profit_loss": [
- "USDT",
- 253.84
], - "reconciled": null,
- "exchange_role": "taker",
- "legs": null
}
], - "user_data": null
}
}Retrieve an order using the request_uuid from order creation instead of the order ID. Available up to 6 hours after placement.
| uuid required | string <uuid> The request_uuid used when creating the order |
{- "status": "ok",
- "data": {
- "order_id": 0,
- "order_type": "normal",
- "bet_type": "string",
- "bet_type_description": "string",
- "sport": "string",
- "want_price": 0,
- "want_stake": [
- "string",
- 0
], - "ccy_rate": 0,
- "placement_time": "2019-08-24T14:15:22Z",
- "expiry_time": "2019-08-24T14:15:22Z",
- "closed": true,
- "close_reason": "string",
- "event_info": {
- "event_type": "normal",
- "event_id": "string",
- "event_name": "string",
- "home_id": 0,
- "home_team": "string",
- "away_id": 0,
- "away_team": "string",
- "competition_id": 0,
- "competition_name": "string",
- "competition_country": "string",
- "start_time": "2019-08-24T14:15:22Z",
- "date": "2019-08-24",
- "result": {
- "ht_home": 0,
- "ht_away": 0,
- "ft_home": 0,
- "ft_away": 0,
- "runner_results": [
- {
- "team_id": 0,
- "position": 0
}
], - "non_runner_count": 0
}, - "teams": [
- {
- "team_id": 0,
- "name": "string"
}
], - "end_time": "2019-08-24T14:15:22Z",
- "leg_event_infos": [
- { }
]
}, - "bets": [
- {
- "bet_id": 0,
- "order_id": 0,
- "order_ccy_rate": 0,
- "status": "string",
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "ccy_rate": 0,
- "want_price": 0,
- "got_price": 0,
- "want_stake": [
- "string",
- 0
], - "got_stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "reconciled": "string",
- "exchange_role": "maker",
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
], - "user_data": "string",
- "status": "string",
- "keep_open_ir": true,
- "exchange_mode": "make_and_take",
- "price": 0,
- "stake": [
- "string",
- 0
], - "profit_loss": [
- "string",
- 0
], - "bet_bar_values": { },
- "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "bet_type_description": "string",
- "price": 0,
- "outcome": "won"
}
]
}
}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.
| order_id required | integer |
{- "status": "ok",
- "data": null
}Close multiple orders synchronously. Maximum 500 order IDs per request.
| order_ids required | Array of integers <= 500 items |
{- "order_ids": [
- 5001,
- 5002,
- 5003
]
}{- "status": "ok",
- "data": {
- "closed": [
- 5001,
- 5002
], - "not_found": [
- 5003
]
}
}Request cancellation of all open orders. Optionally filter by sport and/or event.
| 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) |
{ }{- "status": "ok",
- "data": [
- [
- 5001,
- 5002
]
]
}Calculate profit/loss position based on filtered orders. Accepts the same query parameters as the list orders endpoint.
| 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) |
{- "status": "ok",
- "data": {
- "sport": "fb",
- "event_id": "2026-05-18,53672,10058814",
- "payoff_grid": {
- "ccy_code": "USDT",
- "values": [
- [
- -115.38,
- -115.38,
- -115.38
], - [
- 173.07,
- -115.38,
- -115.38
], - [
- 173.07,
- 173.07,
- -115.38
]
]
}, - "totals": {
- "for,h": {
- "bet_type_description": "Home",
- "got_price": 2.5,
- "got_stake": [
- "USDT",
- 115.38
], - "unknown_price": null,
- "unknown_stake": [
- "USDT",
- 0
]
}
}, - "unknown_bets_num": 0,
- "unknown_grid": null
}
}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.
{- "status": "ok",
- "data": {
- "balance": [
- "USDT",
- 10000.1
], - "open_stake": [
- "USDT",
- 152.55
], - "smart_credit": [
- "USDT",
- 1000
]
}
}Heartbeat timers that automatically close all open orders when they expire. Use these as a dead-man's switch for automated trading.
Open a new heartbeat timer. When it expires all open orders are closed.
| timeout required | integer [ 10 .. 300 ] Seconds before the heartbeat expires |
{- "timeout": 60
}{- "status": "ok",
- "data": {
- "heartbeat_id": "hb-abc123",
- "expiry_time": "2026-06-15T12:01:00+00:00"
}
}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.
{- "status": "ok",
- "data": {
- "heartbeats": [
- {
- "heartbeat_id": "hb-abc123",
- "expiry_time": "2026-06-15T12:01:00+00:00"
}
]
}
}Returns information about a bet type including the win/loss payout grid.
| 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. |
| home_team | string Home team name for display labels |
| away_team | string Away team name for display labels |
{- "status": "ok",
- "data": {
- "sport": "Football",
- "bet_type_description": "Away",
- "winloss_grid": [
- [
- "l",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w"
], - [
- "l",
- "l",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w"
], - [
- "l",
- "l",
- "l",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w",
- "w"
], - "... 20×20 grid of w/l values"
]
}
}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 endpoint — upgrade an HTTP connection to receive real-time prices. This is a separate service from the REST API.
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 |
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.
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": …, …}].
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.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 messagesEach 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-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.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.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):
| api_key required | string API key (the |
| lang | string Default: "en" Enum: "en" "ko" "zh-hans" Language for event/competition names |