aggr
Get API key

API reference

Pinnacle prematch and live odds over REST and WebSocket. Two formats with one key: /aggr/v1, one object per match with one flat list of markets, and /ps3838, the shapes of ps3838's own API, so an existing client only changes its base URL.

Getting started

Base URL: https://api.aggr.fi. Every request carries your key in the X-API-Key header.

# the sports, with how many matches each has now
curl -H "X-API-Key: $KEY" https://api.aggr.fi/aggr/v1/sports

# every soccer match on offer, in decimal odds
curl -H "X-API-Key: $KEY" "https://api.aggr.fi/aggr/v1/events?sport=soccer"

For prices as they change, keep a WebSocket open instead of polling: Stream. REST is for snapshots, lookups and history; it's rate-limited per plan so that the stream is always the faster path.

Responses are JSON, gzip-compressed when you send Accept-Encoding: gzip (recommended: a full sport is a few hundred kilobytes compressed).

Authentication

Create an account and keys at https://app.aggr.fi/account: no email, no password, your account number is the login. An account has up to 5 keys, for different bots or machines; a key that leaks is revoked there without touching the account. Limits belong to the account, shared by its keys.

Get a free API key

Send the key as a header, never in a URL (URLs end up in logs and browser history):

X-API-Key: your-key

The WebSocket takes the same header. Browsers can't set WebSocket headers: there, send the key in the first message, {"op": "auth", "key": "your-key"}, before anything else.

What a key can see is its plan's, on every endpoint: a Prematch key never gets an in-play event, from REST or the stream. See Plans and limits.

Every response says the plan: X-Aggr-Plan (free, prematch, live, scale) and, on a paid plan, X-Aggr-Plan-Expires (ISO time). When paid time runs out the account is on Free: keys keep working with the Free plan's data, the stream refuses new connections and closes open ones with PLAN_CHANGED. In the 3 days before, a new stream connection first gets {"type": "notice", "seq", "code": "PLAN_EXPIRES", "expiresAt", "message"}.

Formats and units

OddsDecimal by default, three decimals. oddsFormat: Decimal, American, HongKong, Indonesian, Malay (case doesn't matter). /ps3838 defaults to American, as ps3838 does.
LimitsUSD. maxVolume is Pinnacle's limit for the market: from a price of 2.00 it's the maximum stake, below 2.00 the maximum win. maxStake applies that rule per side: volume at 2.00 and up, volume / (price − 1) below.
TimesISO 8601 UTC (2026-10-03T19:30:00Z). Cursors (last, since) are Unix milliseconds.
Periods0 is the whole match; 1, 2… are halves, sets, quarters or periods as the sport has them. Every market carries its periodName ("1st Half", "Set 2").
Marketsmoneyline (two-way), 1x2 (with a draw), handicap (line = the home handicap), total (line = the total). Every alternative line is its own market entry; main: true marks the main line.

Ids and lines

Event (id): one listing. A match's in-play listing has an id of its own, different from its prematch one, and some markets come as linked events with a unit other than Regular (Corners, Bookings, Games for tennis, Kills for e-sports).

Match (matchId): one id for the whole match, the same before and after kick-off and on its linked events. Key your data on matchId and nothing needs re-keying at kick-off.

Line key: "<period>|<market>|<line>", the line without trailing zeros and empty for moneylines: "0|1x2|", "0|handicap|-0.25", "1|total|2.5". Used by history and opening / closing prices.

Sports and leagues

GET /aggr/v1/sports
{"sports": [{"id": 29, "sport": "soccer", "name": "Soccer", "events": 1575, "live": 58}, ...]}

Wherever a sport is a parameter, use its id or its name (sport): 29 and soccer are the same.

GET /aggr/v1/leagues?sport=soccer
{"sport": "soccer", "leagues": [{"id": 1980, "name": "England - Premier League", "events": 20}, ...]}

Events

GET /aggr/v1/events?sport=
sportrequired: id or name
live1 in-play only, 0 prematch only; default both
league, event, matchids, comma-separated or repeated, up to 200
sincea previous response's last: only what changed since (see below)
oddsFormatsee Formats
devigadds fair prices: Fair prices
{
  "sport": "soccer", "last": 1790999123456,
  "events": [{
    "id": 1637600069, "matchId": 1637370516,
    "sport": "soccer", "sportId": 29, "league": "Japan - J2 League", "leagueId": 2157,
    "home": "Tochigi City", "away": "Imabari", "starts": "2026-10-03T05:00:00Z",
    "live": true, "state": "2H 61'", "score": [1, 0], "suspended": false,
    "parentId": 1637370516, "unit": "Regular",
    "markets": [
      {"market": "1x2", "period": 0, "periodName": "Match", "status": "open", "line": null, "main": true,
       "prices": {"home": 1.645, "draw": 3.89, "away": 6.27},
       "maxVolume": 775.0, "maxStake": {"home": 1198, "draw": 775, "away": 775},
       "updatedAt": "2026-10-03T06:41:07.123Z"},
      {"market": "handicap", "period": 0, "periodName": "Match", "status": "open", "line": -0.5, "main": true,
       "prices": {"home": 1.645, "away": 2.35}, "maxVolume": 1575.0, "maxStake": {"home": 2433, "away": 1575},
       "updatedAt": "2026-10-03T06:41:07.123Z"},
      {"market": "total", "period": 0, "periodName": "Match", "status": "open", "line": 2.5, "main": true,
       "prices": {"over": 2.02, "under": 1.84}, "maxVolume": 1575.0, "maxStake": {"over": 1575, "under": 1875},
       "updatedAt": "2026-10-03T06:41:05.981Z"}
    ]
  }]
}

status is open or suspended; suspended on the event means all its prices are off for now (in-play, often for seconds). Events with no prices yet have markets: [].

Deltas. Pass the last of your previous response as since to get only the events changed since then, each whole; an event that's gone comes as {"id": …, "removed": true}. A since older than an hour gets a full snapshot.

GET /aggr/v1/events/{eventId}

One event, the same shape. Takes oddsFormat and devig.

Matches

GET /aggr/v1/matches/{matchId}

Every event of a match: its in-play listing, its prematch listing while that still exists, and its linked events. In-play first.

{"matchId": 1637370516, "sport": "soccer", "events": [{...in-play event...}, {...Corners...}]}

Or filter any events request or stream subscription by match / matches.

Fair prices

Add devig to an events, event or match request, or to a stream subscription, and every market gets fair: its prices with the margin taken off.

"prices": {"home": 2.78, "draw": 3.90, "away": 2.02},
"fair":   {"home": 3.112, "draw": 4.532, "away": 2.183}
multiplicativeEvery probability scaled by the same factor. The usual choice.
additiveThe same amount off each outcome. fair is empty ({}) where that would make a probability negative.
powerProbabilities raised to a common power: shades long shots more (the favourite–long-shot bias).
shinShin's model of a book protecting itself from insiders; also shades long shots. Equal to additive on two-way markets.

Fair prices are in the requested oddsFormat.

History

GET /aggr/v1/history?event=&period=&market=&line=

Every change of one line: prices and limit. For an in-play event the series starts with its prematch listing, so one request covers the whole match; liveAt is the kick-off.

{"sides": ["home", "away", "draw"],
 "points": [[1790990000123, 2.15, 3.40, 3.30, 400], [1790990421555, 2.10, 3.45, 3.35, 750], ...],
 "opening": {"home": 2.15, "draw": 3.30, "away": 3.40},
 "closing": {"home": 1.96, "draw": 3.55, "away": 3.70},
 "liveAt": 1791001800000, "prematchEvent": 1637370516}

Each point: [ms, price per side in "sides" order, maxVolume]. History is kept for 7 days after a match's start; opening and closing prices for a year. A Prematch key gets prematch events only, up to their kick-off.

Opening and closing

GET /aggr/v1/opening-closing?event=

Per event and line key: the first prices seen and the last prematch prices (closing), for closing line value and model calibration. Up to 200 events.

{"events": [{"id": 1637370516,
  "opening": {"0|1x2|": {"home": 2.15, "draw": 3.30, "away": 3.40}, "0|total|2.5": {"over": 1.92, "under": 1.94}},
  "closing": {"0|1x2|": {"home": 1.96, "draw": 3.55, "away": 3.70}, "0|total|2.5": {"over": 1.85, "under": 2.01}},
  "kickoff": 1791001812345, "final": true}]}

closing is null before the start. Between the scheduled start and kick-off it follows the market with final: false; at kick-off it's frozen (final: true).

Drops and limits

GET /aggr/v1/drops?window=900

The biggest price moves right now: one row per line side, sorted by size.

window300, 900, 3600 seconds, or open (since opening; in-play: since kick-off)
directiondrop (default), rise, both, or limit: lines whose limit moved instead
minDropminimum move in percent (default 3)
minOdds, maxOddscurrent price range (default 1.01 – 1000)
minMaxminimum max stake on the selection, USD
sport, live, marketfilters; market: moneyline, handicap, total
main1 (default) main lines only, 0 alternative lines too
{"drops": [{"eventId": 1637466250, "sport": "hockey", "sportId": 19, "league": "Norway - Eliteserien",
  "home": "Stjernen Hockey Fredrikstad", "away": "Frisk Asker", "starts": "2026-10-03T17:00:00Z", "live": false,
  "period": 6, "periodName": "Regulation Time", "market": "1x2", "line": null, "side": "home",
  "from": 3.13, "to": 2.65, "dropPct": 15.34, "fair": 2.929, "max": 50, "maxFrom": 50, "maxStake": 50,
  "at": 1790990421555, "points": [[1790989900000, 3.13], [1790990421555, 2.65]]}], "now": 1790990433000}

With direction=limit a row is a whole line (side: null): from / to are the limit in USD and maxStake is per side.

Stream

WS wss://api.aggr.fi/aggr/v1/stream

Subscribe once; you get a snapshot of every event you asked for, then every change as it happens, each event whole.

{"op": "subscribe",
 "sports": ["soccer", 33],
 "live": true,
 "oddsFormat": "Decimal",
 "devig": "power",
 "opened": true,
 "limits": true}
sportsids or names; omit for all
events, matchesids: only those (with their linked events)
livetrue: in-play only
oddsFormat, devigas on REST
openedtrue: an opened message whenever a line appears for the first time
limitstrue: a limit message whenever a market's limit changes

A new subscribe replaces the previous one (at most one per 5 seconds) and starts with a fresh snapshot. Your key's plan decides what's included, whatever you subscribe to.

const ws = new WebSocket("wss://api.aggr.fi/aggr/v1/stream", { headers: { "X-API-Key": KEY } });   // Node, the "ws" package
ws.onopen = () => ws.send(JSON.stringify({ op: "subscribe", sports: ["soccer"], live: true }));
ws.onmessage = m => {
  const msg = JSON.parse(m.data);
  if (msg.type === "snapshot" || msg.type === "update") store(msg.event);
  if (msg.type === "remove") drop(msg.eventId);
};

Keep reading: a client that falls 10,000 messages behind is disconnected (close code 4008). Reconnect and subscribe again; the snapshot brings you back in sync. The connection is compressed (permessage-deflate) when your client supports it.

Messages

Every message has a type and, except errors, a seq that counts up by one per connection. A gap means you missed something: resubscribe.

snapshot{"type", "seq", "sport", "event"}, one per event the subscription covers
readythe snapshot is complete; updates follow
update{"type", "seq", "sport", "event"}: the whole event, every time anything in it changes
notice{"type", "seq", "code", "message", ...}: about your account, e.g. PLAN_EXPIRES with expiresAt
remove{"type", "seq", "sportId", "eventId"}: off the board (ended, or, for a Prematch key, gone in-play)
opened{"type", "seq", "sport", "sportId", "eventId", "matchId", "newEvent", "markets"}: lines seen for the first time on this event, with their first prices, right after the update that carries them; newEvent: the event itself is new
limit{"type", "seq", "sport", "sportId", "eventId", "matchId", "changes": [{"period", "periodName", "market", "line", "from", "to", "maxStake"}]}
error{"type", "code", "message"}: a bad or too frequent request; the connection stays open

ps3838-compatible API

The same data in ps3838's shapes. A client written for ps3838 changes its base URL to https://api.aggr.fi/ps3838 and its credentials to the X-API-Key header, and otherwise reads our responses unchanged.

GET /ps3838/v3/sports
GET /ps3838/v3/leagues?sportId=
GET /ps3838/v1/periods?sportId=
GET /ps3838/v3/fixtures?sportId=leagueIds, eventIds, isLive, since
GET /ps3838/v4/odds?sportId=leagueIds, eventIds, isLive, since, oddsFormat (default American), toCurrencyCode (USD only)
WS /ps3838/streamthe stream protocol above; each event as a ps3838 odds event plus its fixture

since works as on ps3838: pass the previous last; a fixture or odds event that's gone comes with "removed": true. Limits (maxMoneyline, maxSpread, maxTotal, a line's max) are volumes, as on ps3838. Betting endpoints aren't part of this API.

Plans and limits

FreePrematchLiveScale
USD / month$0$59$149$299
Prematch eventsdelayedreal timereal timereal time
In-play eventsneverneveryesyes
Stream connections–1110
Full snapshot (no since), per endpoint and sport1 / 60 s1 / 60 s1 / 60 s1 / 10 s
Delta (since), per endpoint and sport–1 / 10 s1 / 5 s1 / s
REST requests, all together10 / min, 100 / day60 / min120 / min600 / min

Sports, leagues and periods: once per 10 seconds per endpoint. Up to 200 ids per request. Over a limit the API answers 429 with Retry-After. The stream has no request limits: it's the real-time path.

With a Prematch key an event leaves every response at kick-off: REST deltas report it removed once, the stream sends remove.

Free is for trying the data out: /aggr/v1/sports, /aggr/v1/leagues and /aggr/v1/events (prematch, every market, decimal odds, with "delayed": true and no timestamps; the league, event and match filters work). Every other endpoint, since, devig, other odds formats and the stream answer 403 PLAN_REQUIRED.

Quarterly billing saves 10%, yearly 20%. Every plan is for your own use, company or individual. Showing the data to others or reselling it needs a redistribution licence: contact us.

Errors

Errors are JSON: {"code": "INVALID_REQUEST_DATA", "message": "oddsFormat: one of Decimal, American, HongKong, Indonesian, Malay"}

400INVALID_REQUEST_DATA: a parameter is missing or wrong; the message says which
401UNAUTHORIZED: no key, or an unknown one
403FORBIDDEN: not in your plan (for example in-play history with a Prematch key)
403PLAN_REQUIRED: your plan doesn't include this (the Free plan's limits above); the message says what it has
404NOT_FOUND: no such sport, event or match
429TOO_MANY_REQUESTS: over a limit; wait Retry-After seconds
streamTOO_MANY_CONNECTIONS: your plan's stream connections are all in use · PLAN_CHANGED: the account's plan changed (time ran out, or a new plan): connect again
503SERVICE_UNAVAILABLE: starting up or briefly unavailable; retry shortly

Stream close codes: 4008 too slow (the queue filled), 1013 unavailable, 4003 not allowed for this key.

Changes

Within /aggr/v1 fields are only ever added: ignore keys you don't know. Anything breaking gets a new version path.