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.

Two feeds, one shape. 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.
Integrating with an AI assistant? Point Cursor / Claude / ChatGPT at /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:

  1. x-portal-apikey header (recommended)
  2. x-api-key header
  3. ?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.

PlanPriceREST rateLive + prematchWebSocketConnections
Pro$99 / 30d10 req/sec— (add-on)1
Pro + WS$149 / 30d10 req/sec✓ Included1
Scale$229 / 30d30 req/sec✓ Included1
WebSocket add-on$99 / 30d1

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.

StatusError codeWhen it happens
400bad_feed?feed= set to something other than live or prematch. A missing feed is not an error — it defaults to live
401missing_keyNo API key was sent
401invalid_keyKey not in our DB (typo, deleted, or regenerated)
403no_betfair_planKey is valid but there is no active subscription on the account
403tier_lacks_capabilityYour tier doesn't grant what this call needs
403ws_addon_inactiveKey is valid but the WebSocket add-on isn't active on the account
404unknown_eventNo event with that id on the requested feed — ids are scoped per feed
429rate_limitedKey exceeded its per-second limit — honor the Retry-After header
500internalUnexpected server error — safe to retry with backoff
503no_data_yetA feed was requested before its first ingest landed
503auth_unavailableEntitlement 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.

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

Free 3-day trial. The full Scale tier including the WebSocket add-on, free for 3 days. Claim it yourself in one click from your dashboard — it activates instantly. Claimable once per account.

Feeds

Every endpoint takes ?feed=, which selects the data set:

FeedContents
liveOur normalised in-play livemap
prematchOur 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.
GET /betfair/v1/livemap API key

Full current snapshot of a feed. Returns { meta, liveMap }.

Query parameters

NameTypeDescription
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 */ }
}
This is the only Betfair endpoint that sends the 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.

GET /betfair/v1/events API key

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
    }
  ]
}
GET /betfair/v1/events/{eventId} API key

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.

Event ids are scoped per feed, so always pass ?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.
GET /betfair/v1/me API key

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
}
Authenticated but not plan-gated: it answers 200 even when your subscription has lapsed (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.
GET /betfair/v1/health Public

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": { }
}
Always HTTP 200, even when a feed is stale. 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:

FeedStale afterNotes
live15 sIn-play prices move constantly; a 15 s gap is abnormal.
prematch120 sPre-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.

WS /betfair/v1/stream API key $99/mo add-on

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.

Client messages are keyed by 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.

Authenticating proves your key is valid — not that you may read anything. Any valid account key authenticates successfully; 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.

Snapshots are chunked at 512 KB. A large snapshot arrives as multiple frames all typed 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
Note 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

The NEW socket is the one rejected. Every plan allows exactly one concurrent connection per API key. Opening a second rejects the incoming one with {"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

CodeReasonMeaning
1008no authenticate10-second authenticate deadline missed.
1008too_many_connectionsTier connection cap reached. The new socket is the one closed.
1008missing_key / invalid_keyAuthentication failed.
1001staleNo pong for 75 s.
1001server shutdownDeploy or restart — reconnect.
1011deregistered: <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.

REST bodies use the field 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": "…" }
CaseRESTWebSocket
Field carrying the codeerrorcode
Bad feed name400 bad_feedunknown_feed
Effect on the connectionn/a — one request, one responseNone. The socket stays open; keep reading
429 body{error, limit, retry_after_ms} plus a Retry-After headern/a — the stream is not rate-limited per message
503 no_data_yetCarries meta and no message fieldn/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 }
  ]
}
FieldTypeDescription
team1 / team2stringParticipants as the exchange names them. Not normalised across bookmakers — match on your side if you join other feeds.
sportstringLower-case sport name. Current values: soccer, tennis, basketball, cricket, ice hockey, american football, baseball. This is the value ?sports= filters on.
tournamentstringCompetition the event belongs to.
openTimestampstringScheduled start, passed through from the exchange.
marketsarrayFlat 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 fieldTypeDescription
marketIdstringBetfair market id (e.g. 1.234567890). Shared by every runner in that market.
namestringMarket name — Match Odds, Over/Under 2.5 Goals, and so on.
runnerstringSelection name within the market.
runnerIdnumberBetfair selection id — stable, and the right key to store against.
backOddsnumberBest available back price, decimal odds.
backSizenumberStake available at backOdds, in the exchange's currency.
layOddsnumberBest available lay price, decimal odds.
laySizenumberStake 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.

TopicDetail
Your linkShown in your dashboard. Format: https://betfair-odds.com/?ref=<8-char-code>. Copy + share anywhere.
Commission30% of every USD payment your referrals make. Compounds across renewals and upgrades — not a one-time bounty.
AttributionA 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.
TrackingYour 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).
PayoutsProcessed 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-referralBlocked. You can't sign up via your own link.
RefundsIf 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.

Need something we don't have? Historical archives, a binary WebSocket stream, custom rate above 30 req/sec, or a market we don't surface yet — email [email protected] and tell us what you need.