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.
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
| Odds | Decimal by default, three decimals. oddsFormat: Decimal, American, HongKong, Indonesian, Malay (case doesn't matter). /ps3838 defaults to American, as ps3838 does. |
|---|---|
| Limits | USD. 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. |
| Times | ISO 8601 UTC (2026-10-03T19:30:00Z). Cursors (last, since) are Unix milliseconds. |
| Periods | 0 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"). |
| Markets | moneyline (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
{"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.
{"sport": "soccer", "leagues": [{"id": 1980, "name": "England - Premier League", "events": 20}, ...]}
Events
sport | required: id or name |
live | 1 in-play only, 0 prematch only; default both |
league, event, match | ids, comma-separated or repeated, up to 200 |
since | a previous response's last: only what changed since (see below) |
oddsFormat | see Formats |
devig | adds 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.
One event, the same shape. Takes oddsFormat and devig.
Matches
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.
Search
Finds matches by team or player names. Accents, punctuation, "FC"-style words and the order of home and away don't matter, so names from another bookmaker work as they are.
home, away | one or both; or q for free text |
sport | id or name |
starts, window | an ISO time or Unix ms, and hours either side (default 36) |
live, limit | 1 / 0; results, up to 50 (default 10) |
curl -H "X-API-Key: $KEY" "https://api.aggr.fi/aggr/v1/search?home=Banik%20Ostrava&away=Hradec&sport=soccer"
{"matches": [{"score": 0.981, "id": 1637412345, "matchId": 1637412345, "sport": "soccer", "sportId": 29,
"league": "Czech Republic - 1. Liga", "home": "Banik Ostrava", "away": "Hradec Kralove",
"starts": "2026-10-04T14:00:00Z", "live": false, "unit": "Regular"}]}
score runs 0 to 1; results below 0.5 aren't returned.
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}
multiplicative | Every probability scaled by the same factor. The usual choice. |
|---|---|
additive | The same amount off each outcome. fair is empty ({}) where that would make a probability negative. |
power | Probabilities raised to a common power: shades long shots more (the favourite–long-shot bias). |
shin | Shin'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
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
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
The biggest price moves right now: one row per line side, sorted by size.
window | 300, 900, 3600 seconds, or open (since opening; in-play: since kick-off) |
direction | drop (default), rise, both, or limit: lines whose limit moved instead |
minDrop | minimum move in percent (default 3) |
minOdds, maxOdds | current price range (default 1.01 – 1000) |
minMax | minimum max stake on the selection, USD |
sport, live, market | filters; market: moneyline, handicap, total |
main | 1 (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
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}
sports | ids or names; omit for all |
events, matches | ids: only those (with their linked events) |
live | true: in-play only |
oddsFormat, devig | as on REST |
opened | true: an opened message whenever a line appears for the first time |
limits | true: 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 |
|---|---|
ready | the 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/stream | the 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
| Free | Prematch | Live | Scale | |
|---|---|---|---|---|
| USD / month | $0 | $59 | $149 | $299 |
| Prematch events | delayed | real time | real time | real time |
| In-play events | never | never | yes | yes |
| Stream connections | – | 1 | 1 | 10 |
Full snapshot (no since), per endpoint and sport | 1 / 60 s | 1 / 60 s | 1 / 60 s | 1 / 10 s |
Delta (since), per endpoint and sport | – | 1 / 10 s | 1 / 5 s | 1 / s |
| REST requests, all together | 10 / min, 100 / day | 60 / min | 120 / min | 600 / 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"}
| 400 | INVALID_REQUEST_DATA: a parameter is missing or wrong; the message says which |
|---|---|
| 401 | UNAUTHORIZED: no key, or an unknown one |
| 403 | FORBIDDEN: not in your plan (for example in-play history with a Prematch key) |
| 403 | PLAN_REQUIRED: your plan doesn't include this (the Free plan's limits above); the message says what it has |
| 404 | NOT_FOUND: no such sport, event or match |
| 429 | TOO_MANY_REQUESTS: over a limit; wait Retry-After seconds |
| stream | TOO_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 |
| 503 | SERVICE_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.