Contents
API Documentation
Live and prematch Betfair odds — back and lay prices with available sizes, normalised into one event map — delivered over a REST API, with an optional WebSocket push feed for clients that would rather be told than ask.
Introduction
Base URL for this deployment: https://betfair-odds.com. Every endpoint below is relative to that host, and all of them live under /betfair/v1/. All responses are JSON.
All requests are authenticated with an API key — your key is your login; there's no username or password. Get a free trial key in one click from the landing page.
live (in-play) and prematch (pre-game) are the
same event map with the same fields — you select one with ?feed= and reuse the same
parsing code for both. Start at /betfair/v1/livemap.
/llms-full.txt — the full Betfair Odds API reference in one plain-text fetch, optimized for LLM context windows. The shorter /llms.txt index is auto-discovered by tools that probe the root.
Authentication
Pass your API key in one of three ways, in priority order:
x-portal-apikeyheader (recommended)x-api-keyheader?key=query parameter (useful for browsers / one-offs)
# Header (recommended) curl -H "x-portal-apikey: YOUR_KEY" \ "https://betfair-odds.com/betfair/v1/livemap?feed=live" # Query string curl "https://betfair-odds.com/betfair/v1/livemap?feed=live&key=YOUR_KEY"
The WebSocket stream does not read these headers — it authenticates with an
{"action":"authenticate"} frame after the socket opens. See
/betfair/v1/stream.
Plans & rate limits
REST limits are enforced per-key, per-second. When you exceed one, you get 429 with a Retry-After header. There is no monthly request cap on any plan.
| Plan | Price | REST rate | Live + prematch | WebSocket | Connections |
|---|---|---|---|---|---|
| Pro | $99 / 30d | 10 req/sec | ✓ | — (add-on) | 1 |
| Pro + WS | $149 / 30d | 10 req/sec | ✓ | ✓ Included | 1 |
| Scale | $229 / 30d | 30 req/sec | ✓ | ✓ Included | 1 |
| WebSocket add-on | $99 / 30d | — | — | ✓ | 1 |
Both tiers include live and prematch REST access — the difference is rate, not data. The WebSocket feed is a separate entitlement: buy it bundled (Pro + WS, Scale) or add it to a plain Pro plan later for $99/30d. 90-day pricing and the free trial are on the plans table below. Every plan allows exactly one concurrent WebSocket connection.
# 429 response when you hit a limit HTTP/1.1 429 Too Many Requests Retry-After: 1 Content-Type: application/json { "error": "rate_limited", "limit": 10, "retry_after_ms": 420 }
Errors
Every REST error body carries an error field naming the condition. WebSocket error frames
use code instead — see error envelopes before you write a
shared handler for both transports.
| Status | Error code | When it happens |
|---|---|---|
| 400 | bad_feed | ?feed= set to something other than live or prematch. A missing feed is not an error — it defaults to live |
| 401 | missing_key | No API key was sent |
| 401 | invalid_key | Key not in our DB (typo, deleted, or regenerated) |
| 403 | no_betfair_plan | Key is valid but there is no active subscription on the account |
| 403 | tier_lacks_capability | Your tier doesn't grant what this call needs |
| 403 | ws_addon_inactive | Key is valid but the WebSocket add-on isn't active on the account |
| 404 | unknown_event | No event with that id on the requested feed — ids are scoped per feed |
| 429 | rate_limited | Key exceeded its per-second limit — honor the Retry-After header |
| 500 | internal | Unexpected server error — safe to retry with backoff |
| 503 | no_data_yet | A feed was requested before its first ingest landed |
| 503 | auth_unavailable | Entitlement store temporarily unreadable — retry |
Every 403 body also carries required_capability and current_tier,
so a client can tell "you didn't buy this" from "your plan lapsed" without guessing. Call
/betfair/v1/me to see exactly what your key holds — it answers
200 even when everything else is refusing you.
Feeds & plans
Live and prematch odds from the Betfair exchange — back and lay prices with the size available at each, per runner, per market — normalised into one event map and served over REST and WebSocket.
Base URL https://betfair-odds.com/betfair/v1/. One API key authenticates every
endpoint on this page: x-portal-apikey, x-api-key, or ?key=
(see Authentication).
Plans
Per-second rates for each tier are in Plans & rate limits.
| Tier | Price | Includes |
|---|---|---|
| Betfair Pro | $99 / 30d $249 / 90d |
REST live + prematch, 10 req/sec. No WebSocket. |
| Betfair Pro + WS | $149 / 30d $379 / 90d |
Most popular. Everything in Pro, plus the WebSocket push feed — $49 less than adding it to Pro separately. |
| Betfair Scale | $229 / 30d $599 / 90d |
Everything in Pro + WS, at 3× the request rate — 30 req/sec. |
| WebSocket add-on | $99 / 30d | For adding push to a plain Pro plan later. Already included in Pro + WS and Scale — you are never charged for it twice. |
Every plan allows one concurrent WebSocket connection. Open one socket and fan out inside your own process; a second connection on the same key is rejected rather than replacing the first.
Feeds
Every endpoint takes ?feed=, which selects the data set:
| Feed | Contents |
|---|---|
| live | Our normalised in-play livemap |
| prematch | Our normalised pre-game livemap |
feed is optional and defaults to live. Omitting it is not an
error — you silently receive the live feed. Only a value that is not one of the two returns
400 bad_feed. Always send it explicitly: a variable that evaluates to an empty
string will quietly serve you live data when you meant prematch.
Full current snapshot of a feed. Returns { meta, liveMap }.
Query parameters
| Name | Type | Description |
|---|---|---|
| feed | "live" | "prematch" | Defaults to live when omitted. Case-insensitive. An unrecognised value returns 400 bad_feed. |
| sports | string | Comma-separated sport names (e.g. soccer,tennis), case-insensitive, matched against each event's sport field. These are names, not numeric ids — passing sports=1 matches nothing and returns an empty map rather than an error. Sports carried by the feed: soccer, tennis, basketball, cricket, ice hockey, american football, baseball (URL-encode the two-word names, e.g. sports=ice%20hockey). |
curl -H "x-portal-apikey: $KEY" \
"https://betfair-odds.com/betfair/v1/livemap?feed=live&sports=soccer,tennis"
const res = await fetch( "https://betfair-odds.com/betfair/v1/livemap?feed=live&sports=soccer", { headers: { "x-portal-apikey": process.env.KEY } } ); const { meta, liveMap } = await res.json(); // meta.stale tells you if the feed has gone quiet console.log(meta.event_count, meta.stale);
import os, requests r = requests.get( "https://betfair-odds.com/betfair/v1/livemap", params={"feed": "live", "sports": "soccer"}, headers={"x-portal-apikey": os.environ["KEY"]}, timeout=30, ) r.raise_for_status() data = r.json() print(data["meta"]["event_count"], data["meta"]["stale"])
req, _ := http.NewRequest("GET", "https://betfair-odds.com/betfair/v1/livemap?feed=live", nil) req.Header.Set("x-portal-apikey", os.Getenv("KEY")) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close()
$ch = curl_init("https://betfair-odds.com/betfair/v1/livemap?feed=live"); curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-portal-apikey: " . getenv("KEY")]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = json_decode(curl_exec($ch), true);
Response
{
"meta": {
"feed": "live",
"receivedAt": "2026-07-28T12:00:00.000Z",
"seq": 1234,
"stale": false,
"age_ms": 850,
"event_count": 512
},
"liveMap": { /* eventId -> event */ }
}
X-Betfair-Seq and
X-Betfair-Stale response headers. Everywhere else, read meta.stale.
gzip is pre-computed once per update and served when Accept-Encoding includes gzip
and no sports filter is set — a filtered response is built per request and
cannot reuse the cached compressed copy. Before the first data arrives this returns
503 no_data_yet.
A cheap index of what is on the feed — poll this instead of pulling the whole snapshot when you
only need to know what changed. Returns { meta, events }.
Takes the same feed and sports parameters as /livemap.
Rows carry no prices — that is what makes it cheap. marketCount tells you how
many markets an event has; fetch them from
/events/{eventId} or take the whole book from
/livemap. See the
Event object for the full shape.
Response
{
"meta": { /* as above */ },
"events": [
{
"eventId": "1.234567890",
"team1": "Arsenal",
"team2": "Chelsea",
"sport": "soccer",
"tournament": "Premier League",
"openTimestamp": 1789000000000,
"marketCount": 42
}
]
}
One event, byte-identical to what we ingested. Returns { meta, event }.
curl -H "x-portal-apikey: $KEY" \
"https://betfair-odds.com/betfair/v1/events/1.234567890?feed=prematch"
An id that is not on the feed returns 404 unknown_event. That body still carries
meta, so a miss tells you how fresh the feed was when it missed.
?feed= explicitly here. It is not
enforced: omitting it defaults to live, so a prematch event id comes back as
404 unknown_event rather than telling you the feed was wrong — which sends you
hunting a data problem that does not exist.
What your key is entitled to on the Betfair product.
{
"tier": "scale",
"active": true,
"expires_at": 1789000000000,
"ws_active": true,
"ws_until": 1789000000000,
"is_admin": false,
"capabilities": ["rest_live", "rest_prematch", "ws_live", "ws_prematch"],
"limits": { "perSec": 30 },
"max_connections": 1
}
active: false, tier: null). That makes it the right call after any 403 —
every other endpoint refuses a lapsed key, so this is the only way to find out why.
Feed freshness and service state. No key required. The same handler answers /ping.
{
"ok": true,
"service": "betfair",
"feeds": { "live": { "stale": false, "age_ms": 850, /* ...meta */ } },
"senders": { }, "clients": 3, "ingest": { }, "ws": { }
}
ok flips to false and
feeds.<name>.stale says which one. Never use the status code as a freshness
check — a monitor that only watches HTTP status will never fire.
GET /betfair/v1/status returns the same information as a human-readable HTML page.
Staleness thresholds
Each feed has its own threshold:
| Feed | Stale after | Notes |
|---|---|---|
| live | 15 s | In-play prices move constantly; a 15 s gap is abnormal. |
| prematch | 120 s | Pre-game prices move slowly by nature. |
Transitions are detected by a 5-second polling loop, so the WebSocket stale frame can
lag the threshold by up to 5 s. meta.age_ms is the exact measure.
wss://betfair-odds.com/betfair/v1/stream — push instead of polling.
Included in Pro + WS and Scale; available on plain Pro as a $99/mo add-on.
action; server frames are keyed by type.
Everything you send uses action — including the pong. Everything you receive is
dispatched on type. Mixing the two is the single most common integration bug here:
the socket authenticates, then sits silent.
1. Authenticate — within 10 seconds
{ "action": "authenticate", "apiKey": "YOUR_KEY" }
The server replies:
{ "type": "authenticated", "tier": "scale", "is_admin": false,
"ws_addon": true, "capabilities": ["rest_live", "ws_live"] }
Miss the 10-second deadline and you receive {"type":"error","code":"auth_timeout"}
followed by close 1008.
tier comes back null and
capabilities empty when there is no Betfair subscription. Authorization happens at
subscribe time, so check ws_addon and capabilities on this frame rather
than assuming success means access.
2. Subscribe
{ "action": "subscribe", "feeds": ["live"], "sports": ["soccer"] }
The key is action (not type) and feeds (not
streams). Omitting feeds defaults to ["live"].
You get an acknowledgement first, then the snapshot:
{ "type": "subscribed", "feed": "live", "sports": [] }
A feed you are not entitled to does not close the socket — you receive
{"type":"error","code":"...","feed":"...","required_capability":"..."} and the other
feeds in the same request still subscribe.
Unsubscribe with {"action":"unsubscribe","feeds":["live"]}, acknowledged as
{"type":"unsubscribed","feeds":[...]}.
3. Data frames
{ "type": "snapshot", "feed": "live", "meta": {…}, "liveMap": {…} }
{ "type": "update", "feed": "live", "meta": {…}, "changed": {…}, "removed": […] }
{ "type": "stale", "feed": "live", "stale": true }
The snapshot payload lives under liveMap.
snapshot, each carrying seq and final. A snapshot that
fits in one chunk carries neither field at all — so a client that waits unconditionally for
final: true hangs forever on small feeds. Treat "no final field" as a
complete snapshot.
4. Heartbeat
{ "type": "ping", "ts": 1789000000000, "buffered_max_bytes": 0 } // every 30s
{ "action": "pong" } // your reply
action, not {"type":"pong"} — a type-keyed pong
is ignored, and the socket dies 75 s later as if you had never replied. No pong for 75 s closes it
with 1001 stale. A protocol-level WebSocket ping is also sent, so libraries that
auto-pong at the protocol layer keep the connection alive regardless of the JSON frame.
5. One connection per key
{"type":"error","code":"too_many_connections"} and close 1008, leaving your existing
connection untouched. The upside: a reconnect on a flaky link can never kill your own healthy
feed. The trade-off: a half-dead socket holds its slot until the 75 s heartbeat reaps it — so on
reconnect, close your old socket first rather than racing it.
Example clients
npx wscat -c wss://betfair-odds.com/betfair/v1/stream
> {"action":"authenticate","apiKey":"YOUR_KEY"}
> {"action":"subscribe","feeds":["live"]}
# reply to each ping with: {"action":"pong"}
import WebSocket from "ws"; const ws = new WebSocket("wss://betfair-odds.com/betfair/v1/stream"); const book = new Map(); ws.on("open", () => ws.send(JSON.stringify({ action: "authenticate", apiKey: process.env.KEY }))); ws.on("message", (buf) => { const m = JSON.parse(buf); switch (m.type) { case "authenticated": // ws_addon:false means the tier is active but the add-on is not if (!m.ws_addon) throw new Error("WebSocket add-on inactive"); ws.send(JSON.stringify({ action: "subscribe", feeds: ["live"] })); break; case "snapshot": // single-chunk snapshots have NO seq/final — never wait for final for (const [id, ev] of Object.entries(m.liveMap ?? {})) book.set(id, ev); break; case "update": for (const [id, ev] of Object.entries(m.changed ?? {})) book.set(id, ev); for (const id of m.removed ?? []) book.delete(id); break; case "ping": ws.send(JSON.stringify({ action: "pong" })); // action, not type break; case "error": console.error(m.code, m.message); // WS uses `code`, REST uses `error` break; } });
import asyncio, json, os, websockets async def main(): url = "wss://betfair-odds.com/betfair/v1/stream" async with websockets.connect(url) as ws: await ws.send(json.dumps({"action": "authenticate", "apiKey": os.environ["KEY"]})) book = {} async for raw in ws: m = json.loads(raw) t = m.get("type") if t == "authenticated": if not m.get("ws_addon"): raise RuntimeError("WebSocket add-on inactive") await ws.send(json.dumps({"action": "subscribe", "feeds": ["live"]})) elif t == "snapshot": book.update(m.get("liveMap") or {}) elif t == "update": book.update(m.get("changed") or {}) for i in m.get("removed") or []: book.pop(i, None) elif t == "ping": await ws.send(json.dumps({"action": "pong"})) asyncio.run(main())
Close codes
| Code | Reason | Meaning |
|---|---|---|
| 1008 | no authenticate | 10-second authenticate deadline missed. |
| 1008 | too_many_connections | Tier connection cap reached. The new socket is the one closed. |
| 1008 | missing_key / invalid_key | Authentication failed. |
| 1001 | stale | No pong for 75 s. |
| 1001 | server shutdown | Deploy or restart — reconnect. |
| 1011 | deregistered: <cause> | Dropped for backpressure. Match the deregistered: prefix, not the full string. Causes: slow_consumer, send_err, send_threw, not_open, socket_error. Retryable — reconnect immediately. |
Backpressure eviction fires on a sustained backlog above 1 MB for 60 s, or above 128 MB instantly.
The buffered_max_bytes field on each ping is the high-water mark since the previous
ping, so it measures your headroom rather than only reporting breaches.
Compression (permessage-deflate) is always enabled server-side above 1 KB — there is nothing to
configure on your side.
Error envelopes & WebSocket codes
The status codes themselves are listed under Errors. What follows is the part that bites: the two transports do not shape an error the same way.
error; WebSocket error frames use code.
A client that reads the wrong one sees undefined on one of the two transports.
// REST { "error": "no_betfair_plan", "message": "…", "required_capability": "rest_live", "current_tier": null } // WebSocket { "type": "error", "code": "unknown_feed", "message": "…" }
| Case | REST | WebSocket |
|---|---|---|
| Field carrying the code | error | code |
| Bad feed name | 400 bad_feed | unknown_feed |
| Effect on the connection | n/a — one request, one response | None. The socket stays open; keep reading |
| 429 body | {error, limit, retry_after_ms} plus a Retry-After header | n/a — the stream is not rate-limited per message |
| 503 no_data_yet | Carries meta and no message field | n/a — you simply wait for the first snapshot |
Every 403 body also carries required_capability and current_tier. Call
/betfair/v1/me to see exactly what you hold.
WebSocket-only codes, none of which close the socket — handle an error frame on a
still-open connection: invalid_json, invalid_message,
not_authenticated, unknown_action, unknown_feed (the
WebSocket equivalent of REST's bad_feed), and ws_addon_inactive when
you subscribe without the add-on.
Ready to start? Claim the free 3-day Scale trial — WebSocket included — in one click from your dashboard, or pick a plan there. Questions? Telegram @ArbitrageXpro.
Event object
The value stored against every key in liveMap, and the body of
/events/{eventId}. The same shape on both feeds — a
prematch event and a live event differ only in their contents, never their fields.
{
"team1": "Arsenal",
"team2": "Chelsea",
"sport": "soccer",
"tournament": "Premier League",
"openTimestamp": "2026-07-28T19:00:00.000Z",
"markets": [
{ "marketId": "1.234567890", "name": "Match Odds",
"runner": "Arsenal", "runnerId": 1,
"backOdds": 2.00, "backSize": 100,
"layOdds": 2.02, "laySize": 90 },
{ "marketId": "1.234567890", "name": "Match Odds",
"runner": "Chelsea", "runnerId": 2,
"backOdds": 3.80, "backSize": 50,
"layOdds": 3.90, "laySize": 45 }
]
}
| Field | Type | Description |
|---|---|---|
| team1 / team2 | string | Participants as the exchange names them. Not normalised across bookmakers — match on your side if you join other feeds. |
| sport | string | Lower-case sport name. Current values: soccer, tennis, basketball, cricket, ice hockey, american football, baseball. This is the value ?sports= filters on. |
| tournament | string | Competition the event belongs to. |
| openTimestamp | string | Scheduled start, passed through from the exchange. |
| markets | array | Flat list of runner rows — see below. |
markets is a flat array of runners, not a list of markets. Every row is one
runner in one market, so a three-way market appears as three rows sharing the same
marketId. Group by marketId before you price anything — code that assumes
one row per market silently reads only the first runner.
| Runner row field | Type | Description |
|---|---|---|
| marketId | string | Betfair market id (e.g. 1.234567890). Shared by every runner in that market. |
| name | string | Market name — Match Odds, Over/Under 2.5 Goals, and so on. |
| runner | string | Selection name within the market. |
| runnerId | number | Betfair selection id — stable, and the right key to store against. |
| backOdds | number | Best available back price, decimal odds. |
| backSize | number | Stake available at backOdds, in the exchange's currency. |
| layOdds | number | Best available lay price, decimal odds. |
| laySize | number | Stake available at layOdds. |
Code defensively: a field is present only when the exchange published a value for it. A
runner with no offers on one side arrives without that side's price and size rather than with a
null or a zero — treat every field above as optional.
/events rows are the exception: they always carry the
same seven fields, and never carry prices.
Affiliate program
Every account gets a unique referral link. Anyone who signs up via your link and becomes a paying customer earns you 30% of every payment they make, for as long as their subscription stays active — first payment, renewals, upgrades, all of it.
| Topic | Detail |
|---|---|
| Your link | Shown in your dashboard. Format: https://betfair-odds.com/?ref=<8-char-code>. Copy + share anywhere. |
| Commission | 30% of every USD payment your referrals make. Compounds across renewals and upgrades — not a one-time bounty. |
| Attribution | A signup is attributed if the visitor lands on betfair-odds.com with your ?ref= parameter and then signs up + verifies email in the same browser session. We don't use cross-device cookies — straight session-based attribution. |
| Tracking | Your panel shows a live list of every signup attributed to you, whether they're on a paid plan, and how much you've earned (unpaid + paid out). |
| Payouts | Processed manually. Once your unpaid balance reaches $50+ we'll reach out to arrange transfer (crypto, bank transfer, or PayPal — whichever you prefer). Faster payouts are available on request — email [email protected]. |
| Self-referral | Blocked. You can't sign up via your own link. |
| Refunds | If a referred customer's payment is refunded by us (rare), the corresponding commission is voided. We don't claw back already-paid commissions for routine churn — only for actual refunds. |
Privacy: in your panel, referred users' emails are masked (e.g. j***@gmail.com) so you can recognize friends you invited without exposing full addresses if you screenshot the dashboard. Admins see unmasked emails in our internal tooling.