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 - 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.
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. 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
}
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. 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.
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.
Stake fields are [currency, amount] tuples. Stakes in responses are
always returned in USDT:
["USDT", 115.38]
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.
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.
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.
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.
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 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 |
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 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
(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.
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).
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, 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.
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. |
LiveScore (object) or null The in-running score you are selecting against, e.g. | |
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)",
- "live_score": null,
- "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)",
- "live_score": null,
- "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
}, - "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": {
- "code": "string",
- "reason": "string",
- "response_pmm": {
- "effective": {
- "price": 0,
- "min": [
- "string",
- 0
], - "max": [
- "string",
- 0
]
}
}
}, - "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": true,
- "exchange_role": "maker"
}
], - "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": { },
- "current_score": [
- 0,
- 0
], - "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "live_score": {
- "home": 0,
- "away": 0
}, - "bet_type_description": "string",
- "price": 0,
- "outcome": "w"
}
]
}
]
}Places a new order on an existing betslip.
| 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. |
| 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
| |
Array of items Placement-time score assertion, | |
| 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 | |
| placer_type | string or null Optional caller-supplied tag recorded against the order. |
{- "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,
- "current_score": 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
}, - "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": {
- "code": "string",
- "reason": "string",
- "response_pmm": {
- "effective": {
- "price": 0,
- "min": [
- "string",
- 0
], - "max": [
- "string",
- 0
]
}
}
}, - "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": true,
- "exchange_role": "maker"
}
], - "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": { },
- "current_score": [
- 0,
- 0
], - "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "live_score": {
- "home": 0,
- "away": 0
}, - "bet_type_description": "string",
- "price": 0,
- "outcome": "w"
}
]
}
]
}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": "order_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,
- "current_score": 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": true,
- "exchange_role": "taker"
}
], - "user_data": null
}
}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).
| 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
}, - "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": {
- "code": "string",
- "reason": "string",
- "response_pmm": {
- "effective": {
- "price": 0,
- "min": [
- "string",
- 0
], - "max": [
- "string",
- 0
]
}
}
}, - "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": true,
- "exchange_role": "maker"
}
], - "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": { },
- "current_score": [
- 0,
- 0
], - "legs": [
- {
- "id": 0,
- "sport": "string",
- "event_id": "string",
- "bet_type": "string",
- "live_score": {
- "home": 0,
- "away": 0
}, - "bet_type_description": "string",
- "price": 0,
- "outcome": "w"
}
]
}
}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 [ 1 .. 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 <= 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) |
{ }{- "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 Accepted for compatibility. |
{- "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. 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.
| 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"
}
]
}
}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.
| heartbeat_id required | string |
{- "status": "ok",
- "data": null
}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.
| heartbeat_id required | string |
{- "status": "ok",
- "data": {
- "heartbeat_id": "hb-abc123",
- "expiry_time": "2026-06-15T12:02: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"
], - "... 20x20 grid of w/l values"
]
}
}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.
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.
| sport | Array of strings <= 100 items Example: sport=fb Sport codes to keep. Repeatable, up to 100 values; omit for every sport. Required alongside |
| competition_id | Array of integers <= 100 items [ items [ 0 .. 2147483647 ] ] Competitions to keep. Repeatable, up to 100 values; omit for every competition. Requires |
| 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 |
| 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 |
| 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 |
{- "status": "ok",
- "data": [
- {
- "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"
}
]
}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.
| 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 |
| 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 |
| min_liquidity | number >= 0 Example: min_liquidity=250 USDT. Keep markets whose |
| 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 |
{- "status": "ok",
- "data": [
- {
- "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,
- "min": null,
- "max": [
- "USDT",
- 150
]
}
}, - {
- "effective": {
- "price": 1.99,
- "min": null,
- "max": [
- "USDT",
- 80
]
}
}
]
}
]
}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 |
The server does not restrict the Origin header, so browser-based
clients - including pages opened from file:// - can connect
directly.
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], "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.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):
| api_key required | string API key (the |
| lang | string Default: "en" Enum: "en" "ko" "zh-hans" Language for event/competition names |