View raw: /README.md · JSON: /api/v1/feed/health · Dashboard: /

# Porqpine Aggregator API Real-time betting data feed — REST + WebSocket — for cricket, football, tennis, basketball, ice hockey, table tennis, handball, volleyball, horse racing, and greyhound. One endpoint surface for live match odds, fancy markets, bookmaker markets, scorecards, in-play scores, and completed-match results.
Base URLhttps://porqpine.cloud
WebSocketwss://porqpine.cloud/ws
Healthhttps://porqpine.cloud/api/v1/admin/health
Authopen (no API key required at this time)
CORS, methods GET, POST, OPTIONS
Versioning/api/v1/feed/...
---

Quick start

# All live events with real Back/Lay odds
curl https://porqpine.cloud/api/v1/feed/socket-events | jq

Full event catalogue

curl https://porqpine.cloud/api/v1/feed/events | jq '.count'

In-play events only

curl https://porqpine.cloud/api/v1/feed/events/live | jq '.data | length'

Single event detail (id can be internal or bfi — the API translates)

curl https://porqpine.cloud/api/v1/feed/event/1610350 | jq

All odds for an event — match, fancy, bookmaker

curl https://porqpine.cloud/api/v1/feed/event/1610350/odds | jq

Cricket fancy markets only

curl https://porqpine.cloud/api/v1/feed/event/1610350/fancy | jq

Bulk odds for a watch list

curl -X POST https://porqpine.cloud/api/v1/feed/odds/unified \ -H 'Content-Type: application/json' \ -d '{"matchIds":["1610350","1607150"]}' | jq

Search by name

curl 'https://porqpine.cloud/api/v1/feed/search?q=ipl' | jq

Completed match results

curl 'https://porqpine.cloud/api/v1/feed/results?limit=10' | jq

Health probe

curl https://porqpine.cloud/api/v1/admin/health | jq
---

Endpoint reference

Discovery

MethodPathReturns
GET/api/v1/feed/sportsList of sports with event counts and sample names
GET/api/v1/feed/events?sport={name}Full catalogue. Optional case-insensitive sport filter.
GET/api/v1/feed/events/liveIn-play / live events only
GET/api/v1/feed/socket-events?sport={name}Live events with real Back/Lay prices on every selection. Only place horse racing & greyhound appear.
GET/api/v1/feed/search?q={query}Search by event name

Single event

:id accepts either the internal id or the bfi (Betfair event id). The API translates between them automatically.
MethodPathReturns
GET/api/v1/feed/event/:idEvent metadata + live odds + market summary
GET/api/v1/feed/event/:id/oddsAll markets: match-odds + fancy + bookmaker
GET/api/v1/feed/event/:id/fancyCricket fancy markets only
GET/api/v1/feed/event/:id/bookmakerBookmaker markets only (with skeleton listing)
GET/api/v1/feed/event/:id/scoresLive scores
GET/api/v1/feed/event/:id/scorecardScorecard iframe URL (proxied — embeddable from any origin)
GET/api/v1/feed/event/:id/streamLive tracker + scorecard + HLS streams (proxied iframes)

Bulk

MethodPathBodyReturns
POST/api/v1/feed/odds/unified{"matchIds":["id1","id2"]}Per-event odds for many events at once (≤50 ids per call)

Completed results

MethodPathReturns
GET/api/v1/feed/results?sport={name}&limit={n}&since={iso}Completed events with final odds, scores, and inferred winner. Persists across restarts.
GET/api/v1/feed/result/:idSingle completed match (internal id or bfi)

Raw market frames

MethodPathReturns
GET/api/v1/feed/socket-odds?limit={n}Recent raw market frames
GET/api/v1/feed/socket-odds?market_id={id}Specific market frame
GET/api/v1/feed/socket-odds?bfi={bfi}All frames for a bfi
GET/api/v1/feed/socket-scores?event_id={bfi}Score frames for an event

Reverse-proxied iframes

These return iframes embeddable from any origin. The aggregator strips framing-blocking headers so clients can drop the URL straight into a <iframe> tag.
MethodPath
GET/proxy/scorecard/:hash/:channel
GET/proxy/tracker/:fixtureId?productName=v33-1-dark
/event/:id/scorecard and /event/:id/stream already return these proxy URLs in their _proxy fields — prefer those over the originals.

Health & diagnostics

MethodPathReturns
GET/api/v1/admin/healthSingle-call health: memory, sockets, accounts, ws clients, result-saver state
GET/api/v1/admin/accountsPer-account session state
GET/api/v1/admin/socket-roomsSubscribed per-event live rooms
GET/api/v1/admin/cacheCache size by prefix
GET/api/v1/admin/resultsResult-saver stats + recent records
---

Two ID schemes

The aggregator exposes two parallel ID systems and accepts either anywhere :id appears:
FieldExampleWhere it shows up
Internal ID1610250event.id, /events, /events/live
bfi (Betfair event id)35575273event.betfair_event_id, /socket-events, /socket-odds
Every event response carries both:
{
  "id": 1610250,
  "betfair_event_id": 35575273,
  "raw": { "betfair_event_id": 35575273, ... }
}
Live horse racing and greyhound events typically only have a bfi — they appear in /socket-events and respond to /event/<bfi>/odds, but won't show up in REST /events. ---

Sample responses

GET /api/v1/feed/sports

{
  "success": true,
  "data": [
    { "name": "Football", "count": 870, "events": [{"id":1572164,"name":"..."}] },
    { "name": "Tennis", "count": 410, "events": [...] },
    { "name": "Cricket", "count": 72, "events": [...] }
  ]
}

GET /api/v1/feed/events?sport=Football

{
  "success": true,
  "count": 870,
  "data": [
    {
      "id": 1572164,
      "name": "Real Sociedad vs Betis",
      "team_a": "Real Sociedad",
      "team_b": "Betis",
      "sport": "Football",
      "status": "LIVE",
      "in_play": true,
      "betfair_event_id": 35540213,
      "competition": { "id": 12018851, "slug": "..." }
    }
  ]
}

GET /api/v1/feed/socket-events

{
  "success": true,
  "count": 139,
  "data": [
    {
      "id": 35540213,
      "bfi": 35540213,
      "name": "Real Sociedad vs Betis",
      "sport": "Football",
      "status": "In Play",
      "in_play": true,
      "markets": [{ "market_id": "1.257422429", "type": "MATCH_ODDS" }],
      "selections": [
        { "name": "Real Sociedad", "market_id": "1.257422429", "back_odds": 2.58, "back_size": 151, "lay_odds": 2.62, "lay_size": 89 },
        { "name": "Betis",         "market_id": "1.257422429", "back_odds": 3.10, "back_size":  42, "lay_odds": null, "lay_size": null }
      ]
    }
  ]
}
lay_odds: null means a thin lay book at that snapshot moment. Sport labels: Football, Tennis, Cricket, Basketball, Ice Hockey, Table Tennis, Handball, Volleyball, Horse Racing, Greyhound. Filter is case-insensitive.

GET /api/v1/feed/event/:id

{
  "success": true,
  "data": {
    "event_id": "35540213",
    "internal_id": "1598914",
    "bfi": "35540213",
    "event": {
      "id": 1598914, "name": "Real Sociedad vs Betis",
      "sport": "Football", "in_play": true,
      "team_a": "Real Sociedad", "team_b": "Betis",
      "betfair_event_id": 35540213
    },
    "socket_odds": {
      "byMarketId": { "1.257422429": { / per-market frame / } },
      "byBfi":      { "35540213":    { / event-keyed frame / } }
    },
    "source": "live"
  }
}

GET /api/v1/feed/event/:id/odds (the richest endpoint)

Returns every market for the event, with multiple aligned views:
{
  "success": true,
  "data": {
    "event_id": "1610350",
    "internal_id": "1610350",
    "bfi": "35574416",
    "market": { "byMarketId": { "1.257823308": { ... } }, "byBfi": { ... } },
    "match_odds": [
      {
        "market_id": "1.257823308",
        "market_name": "Match Odds",
        "selections": [
          {
            "name": "Delhi Capitals",
            "back": [{ "level": 1, "odds": 1.93, "size": 500000 }],
            "lay":  [{ "level": 1, "odds": 1.94, "size": 500000 }]
          },
          {
            "name": "Kolkata Knight Riders",
            "back": [{ "level": 1, "odds": 2.06, "size": 500000 }],
            "lay":  [{ "level": 1, "odds": 2.08, "size": 500000 }]
          }
        ]
      }
    ],
    "fancy":     [...],   // populated when match is imminent / in-play
    "bookmaker": [...],   // populated when in-play
    "_meta":     { "matchoddsFound": 1, "marketIds": ["1.257823308"] },
    "source": "live"
  }
}

GET /api/v1/feed/event/:id/fancy

Cricket fancy ladder. Populates from same-day onwards through to in-play.
{
  "success": true,
  "data": {
    "event_id": "1610350",
    "internal_id": "1610350",
    "bfi": "35574416",
    "markets": [
      {
        "market_id": "c5bae77612b6d55d67af728b9e2ce0f5",
        "market_name": "2 Over Runs Mitchell Starc 'DC' ADV.",
        "selections": [{
          "name": "2 Over Runs Mitchell Starc 'DC' ADV.",
          "back": [{ "level": 1, "odds": 18, "size": 100 }],
          "lay":  [{ "level": 1, "odds": 16, "size": 100 }]
        }]
      }
    ],
    "count": 90,
    "source": "live",
    "best": [...]
  }
}
best is the most-populated source ready to render. Use that.

GET /api/v1/feed/event/:id/bookmaker

Bookmaker markets. Pre-match, the skeleton field lists the available markets even before prices have been pushed:
{
  "success": true,
  "data": {
    "event_id": "1610350",
    "skeleton": [
      { "market_id": "98d947a9...", "market_name": "Bookmaker 0 Commission", "selection_ids": ["47068057","47068058"] },
      { "market_id": "7e2a1f03...", "market_name": "To Win The Toss",       "selection_ids": ["47068059","47068060"] }
    ],
    "markets": [...],
    "count": 0,
    "source": "skeleton"
  }
}
When the match goes in-play, prices flow through and markets populates with full back/lay ladders.

GET /api/v1/feed/event/:id/scorecard (proxied iframe)

{
  "success": true,
  "data": {
    "event_id": "1610386",
    "scorecard_url":          "https://porqpine.cloud/proxy/scorecard/<hash>/<channel>",
    "iframe_html":            "<iframe src='https://porqpine.cloud/proxy/scorecard/<hash>/<channel>' width='100%' height='245px' frameborder='0'></iframe>",
    "channel_id": "1234",
    "hash": "8031746d39afeab07af7ce8351ab56d30d27cd50"
  }
}
Embed iframe_html directly. The proxy strips X-Frame-Options and Content-Security-Policy so the iframe renders from any origin.

GET /api/v1/feed/event/:id/stream

{
  "success": true,
  "data": {
    "event_id": "1598914",
    "team_a": "Real Sociedad",
    "team_b": "Betis",
    "streams": [
      {
        "type": "tracker",
        "url":         "https://porqpine.cloud/proxy/tracker/12562643?productName=v33-1-dark",
        "iframe_html": "<iframe src='https://porqpine.cloud/proxy/tracker/12562643?productName=v33-1-dark' width='100%' height='300px' frameborder='0'></iframe>"
      },
      {
        "type": "scorecard",
        "url":         "https://porqpine.cloud/proxy/scorecard/<hash>/<channel>"
      },
      { "type": "hls", "url": "https://.../stream.m3u8" }
    ]
  }
}
Each stream URL is already routed through the aggregator's proxy, so you can drop them straight into iframes from any origin.

POST /api/v1/feed/odds/unified

curl -X POST https://porqpine.cloud/api/v1/feed/odds/unified \
  -H 'Content-Type: application/json' \
  -d '{"matchIds":["1610250","1598914"]}'
{
  "success": true,
  "data": {
    "match_ids": ["1610250","1598914"],
    "market": {
      "1610250": { "internal_id":"1610250", "bfi":"35575273", "market":{"byMarketId":{...}}, "fancy":null, "bookmaker":null },
      "1598914": { "internal_id":"1598914", "bfi":"35540213", "market":{...}, ... }
    },
    "source": "live"
  }
}
Up to ~50 ids per call. Use this to refresh many tiles at once instead of N round trips.

GET /api/v1/feed/results

Completed events with final-state snapshots. Persisted across restarts.
{
  "success": true,
  "count": 50,
  "totals": { "saved": 12, "rest": 38 },
  "data": [
    {
      "internal_id": "1610350",
      "bfi": "35574416",
      "name": "Delhi Capitals vs Kolkata Knight Riders",
      "sport": "Cricket",
      "team_a": "Delhi Capitals",
      "team_b": "Kolkata Knight Riders",
      "completed_at": "2026-05-08T23:54:18.022Z",
      "winner": "Kolkata Knight Riders",
      "final_match_odds": { "1.257823308": { / last-seen frame / } },
      "final_scores":     { / last-seen score frame / }
    }
  ]
}
since=2026-05-08T00:00:00Z filters records completed after a timestamp.

GET /api/v1/feed/result/:id

Single completed match by internal id OR bfi.
curl https://porqpine.cloud/api/v1/feed/result/1610350
curl https://porqpine.cloud/api/v1/feed/result/35574416   # bfi works too
Returns the full record including final_match_odds, final_scores, and the inferred winner. Winner inference is best-effort: lowest-priced selection in the final main-market frame; tagged "<name> (favourite)" when not under 1.05.

GET /api/v1/admin/health

Single-call diagnostics. Treat as healthy when socket.connected === true && accounts.active >= 1.
{
  "success": true,
  "data": {
    "status": "ok", "version": "2.0.0", "uptime_s": 168,
    "memory":  { "rss_mb": 139, "heapUsed_mb": 24 },
    "cache":   { "entries": 700 },
    "socket":  { "connected": true, "rooms_subscribed": { "matchOdds": 7, "fancy": 7, "bookmaker": 7 } },
    "accounts":{ "total": 4, "active": 4 },
    "ws_clients": 3,
    "result_saver": { "total": 6, "snapshots": { "events_tracked": 1760 } }
  }
}
---

WebSocket protocol

Connect to wss://porqpine.cloud/ws for real-time push.
const ws = new WebSocket('wss://porqpine.cloud/ws');

ws.onopen = () => {
  ws.send(JSON.stringify({ action: 'subscribe', room: 'all' }));
  // Or scope by event / market:
  // ws.send(JSON.stringify({ action: 'subscribe', room: 'event:1610250' }));
  // ws.send(JSON.stringify({ action: 'subscribe', room: 'market:1.257422429' }));
};

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  switch (msg.type) {
    case 'connected':              break;  // clientId, stats
    case 'market_odds':            break;  // msg.marketId, msg.data
    case 'match_odds':             break;  // msg.bfi, msg.data
    case 'event_scores':           break;  // msg.data.event_id
    case 'fancy_update':           break;  // msg.matchId, msg.data
    case 'bookmaker_add':          break;  // msg.matchId, msg.marketId, msg.data
    case 'bookmaker_remove':       break;
    case 'matches_updated':        break;  // full match list (every 30s)
    case 'remove_match_from_list': break;  // match settled
  }
};

Client → Server actions

FrameEffect
{"action":"subscribe","room":"all"}Receive everything
{"action":"subscribe","room":"event:<id>"}Frames for that event (id can be internal or bfi)
{"action":"subscribe","room":"market:<market_id>"}Frames for that market
{"action":"subscribe","room":"odds"}Bulk odds polls
{"action":"subscribe","room":"matches"}matches_updated frames
{"action":"unsubscribe","room":"..."}Stop a subscription
{"action":"get_stats"}One-shot stats response
{"action":"get_matches"}One-shot full match list
{"action":"get_odds","matchId":"<id>"}One-shot cached odds
The aggregator activates per-event live data on first request to /event/:id/odds, /fancy, or /bookmaker. If you only consume the WebSocket, hit those endpoints once per event you care about so the aggregator engages the live feed for them. ---

Code samples

JavaScript — leaderboard of live events

const r = await fetch('https://porqpine.cloud/api/v1/feed/socket-events');
const { data } = await r.json();
data.forEach(ev => {
  const main = ev.markets.find(m => m.type === 'MATCH_ODDS');
  const sels = ev.selections.filter(s => s.market_id === main?.market_id);
  console.log(${ev.sport.padEnd(12)} ${ev.name});
  sels.forEach(s => console.log(   ${s.name.padEnd(28)} back ${s.back_odds ?? '-'}  lay ${s.lay_odds ?? '-'}));
});

Python — refresh odds for a watch list

import requests
ids = ["1610250", "1598914", "1607150"]
r = requests.post(
    "https://porqpine.cloud/api/v1/feed/odds/unified",
    json={"matchIds": ids},
    timeout=30,
)
payload = r.json()["data"]
for mid in ids:
    bucket = payload["market"].get(mid) or {}
    n = len((bucket.get("market") or {}).get("byMarketId", {}))
    print(mid, bucket.get("internal_id"), bucket.get("bfi"), n, "markets")

Node — live odds via WebSocket for one event

import WebSocket from 'ws';
const ws = new WebSocket('wss://porqpine.cloud/ws');
ws.on('open', () => {
  ws.send(JSON.stringify({ action: 'subscribe', room: 'event:1610250' }));
  // Engage the live feed for this event:
  fetch('https://porqpine.cloud/api/v1/feed/event/1610250/odds').catch(() => {});
});
ws.on('message', buf => {
  const msg = JSON.parse(buf.toString());
  if (msg.type === 'market_odds')  console.log(msg.marketId, msg.data.BF?.SL?.[0]);
  if (msg.type === 'event_scores') console.log('score', msg.data);
});

curl — embed a scorecard

URL=$(curl -s 'https://porqpine.cloud/api/v1/feed/event/35574416/scorecard' | jq -r '.data.scorecard_url')
echo "<iframe src=\"$URL\" width=\"100%\" height=\"245\" frameborder=\"0\"></iframe>"
---

Caching, polling, freshness

EndpointServer-side TTLSuggested client poll
/events, /events/live, /sports30 s30–60 s
/event/:id, /event/:id/odds5 spoll only on demand; prefer WebSocket
/event/:id/fancy, /event/:id/bookmaker8 s in-play / 60 s pre-matchon render or via WebSocket
/event/:id/scores10 suse WebSocket event_scores instead
/event/:id/scorecard, /event/:id/stream60 s / 30 sonce per event view
/results12 h (persists across restarts)once per session
/socket-events, /socket-oddslive (no TTL)≤5 s if you must poll
---

Caveats

  • Tournament-level markets (e.g. tournament-winner bets) report currently_live: 0 and won't have live odds even when the tournament itself is in progress.
  • ---

    Status codes

    CodeMeaning
    200OK
    400Bad request — usually missing required field (?u=, matchIds, etc.)
    403Forbidden — only seen on /proxy?u= when host isn't allow-listed
    404Not found — event id doesn't exist; static asset missing
    502Upstream error — temporarily unreachable; retry
    All error responses share the shape {"success": false, "error": "<message>"} (with optional extra fields). ---

    Versioning & stability

    The /api/v1/feed/... namespace is the stable contract. Response shapes are additive — new fields may appear, existing fields won't disappear. The source field on /event/:id/odds, /fancy, /bookmaker lets you tell which underlying layer produced the data so you can adapt without redeploying. ---

    License

    MIT