﻿# 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

```bash
# 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:

```json
{
  "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`

```json
{
  "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`

```json
{
  "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`

```json
{
  "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`

```json
{
  "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:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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)

```json
{
  "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`

```json
{
  "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`

```bash
curl -X POST https://porqpine.cloud/api/v1/feed/odds/unified \
  -H 'Content-Type: application/json' \
  -d '{"matchIds":["1610250","1598914"]}'
```

```json
{
  "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.

```json
{
  "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.

```bash
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`.

```json
{
  "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.

```js
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

```js
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

```python
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

```js
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

```bash
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
