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 URL | https://porqpine.cloud |
| WebSocket | wss://porqpine.cloud/ws |
| Health | https://porqpine.cloud/api/v1/admin/health |
| Auth | open (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
| Method | Path | Returns |
| GET | /api/v1/feed/sports | List 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/live | In-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.
| Method | Path | Returns |
| GET | /api/v1/feed/event/:id | Event metadata + live odds + market summary |
| GET | /api/v1/feed/event/:id/odds | All markets: match-odds + fancy + bookmaker |
| GET | /api/v1/feed/event/:id/fancy | Cricket fancy markets only |
| GET | /api/v1/feed/event/:id/bookmaker | Bookmaker markets only (with skeleton listing) |
| GET | /api/v1/feed/event/:id/scores | Live scores |
| GET | /api/v1/feed/event/:id/scorecard | Scorecard iframe URL (proxied — embeddable from any origin) |
| GET | /api/v1/feed/event/:id/stream | Live tracker + scorecard + HLS streams (proxied iframes) |
Bulk
| Method | Path | Body | Returns |
| POST | /api/v1/feed/odds/unified | {"matchIds":["id1","id2"]} | Per-event odds for many events at once (≤50 ids per call) |
Completed results
| Method | Path | Returns |
| 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/:id | Single completed match (internal id or bfi) |
Raw market frames
| Method | Path | Returns |
| 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.
| Method | Path |
| 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
| Method | Path | Returns |
| GET | /api/v1/admin/health | Single-call health: memory, sockets, accounts, ws clients, result-saver state |
| GET | /api/v1/admin/accounts | Per-account session state |
| GET | /api/v1/admin/socket-rooms | Subscribed per-event live rooms |
| GET | /api/v1/admin/cache | Cache size by prefix |
| GET | /api/v1/admin/results | Result-saver stats + recent records |
---
Two ID schemes
The aggregator exposes two parallel ID systems and accepts either anywhere :id appears:
| Field | Example | Where it shows up |
| Internal ID | 1610250 | event.id, /events, /events/live |
| bfi (Betfair event id) | 35575273 | event.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
| Frame | Effect |
{"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
| Endpoint | Server-side TTL | Suggested client poll |
/events, /events/live, /sports | 30 s | 30–60 s |
/event/:id, /event/:id/odds | 5 s | poll only on demand; prefer WebSocket |
/event/:id/fancy, /event/:id/bookmaker | 8 s in-play / 60 s pre-match | on render or via WebSocket |
/event/:id/scores | 10 s | use WebSocket event_scores instead |
/event/:id/scorecard, /event/:id/stream | 60 s / 30 s | once per event view |
/results | 12 h (persists across restarts) | once per session |
/socket-events, /socket-odds | live (no TTL) | ≤5 s if you must poll |
---
Caveats
- Fancy availability depends on time-to-match. A typical IPL fixture exposes ~90 fancy markets when the match is same-day or in-play; fixtures 2+ days out usually have 0. The API hides this difference behind the
count and source fields.
- Bookmaker rows are typically empty pre-match. The
skeleton field still lists the available bookmaker markets so your UI can render the structure even before prices flow.
lay_odds: null in /socket-events is normal for a thin lay book at that snapshot moment.
- Sport labels — filter case-insensitively. The API normalises capitalisation on the way out, but be lenient on input.
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
| Code | Meaning |
| 200 | OK |
| 400 | Bad request — usually missing required field (?u=, matchIds, etc.) |
| 403 | Forbidden — only seen on /proxy?u= when host isn't allow-listed |
| 404 | Not found — event id doesn't exist; static asset missing |
| 502 | Upstream 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