Foreline/Pricing Docs/API reference

API · v1 · reference

Endpoints, parameters, fields

Base URL https://api.foreline.io. Every request carries X-Foreline-Key. New to the API? Start with the overview and quickstart, then come back here: this page is the normative description of the contract — parameters, response fields, status codes and rate classes. The integration guide shows how to put them together; where the two can be read differently, this one is right.

Conventions

What holds for every call

  • Base URL: https://api.foreline.io, HTTPS only, version in the path (/v1/…).
  • Auth: X-Foreline-Key: <key> on every request, including the SSE stream. No query-string keys.
  • An empty parameter value is an error, not an omission. ?league= answers 400 {"error": "league was given with an empty value"} with a hint; drop the parameter instead of sending it empty. It applies to every parameter we recognise, and it is the single most common integration mistake — the rule and the full list are in Errors.
  • Timestamps come in two shapes, and the shape is fixed per field — see Time formats below. The short version: everything that is a machine instant (ts, server_ts, updated_ts, next_since, and the asof echoed by /v1/surface) is epoch seconds as a JSON number; the one calendar field is starts, an ISO-8601 UTC string with the Z suffix, on every endpoint that returns it (as is the asof echo of Parlay Protection). As a parameter, /v1/surface?asof= takes epoch seconds only. One exception, and it is a whole artefact rather than a field: inside a downloaded export file every timestamp is ISO-8601 with Zasof and updated_ts included, in CSV and JSONL alike. A parser written against the live payloads will not read a file without being told about that.
  • Paging is by cursor, and the cursor is next_cursor — on the bulk surface delta, the line-event feed and the alert journal alike. Feed it back in since and keep going while more is true. The numeric next_since in the same envelope is not a paging cursor and syncing on it loses rows or stalls: Cursors & paging is the normative description, and every endpoint below follows it.
  • Identifier lifecycle: an event_id is never reused and never changes the match behind it. What we do not promise: occasionally (≈0.5% of soccer fixtures) the upstream re-issues a match under a new id and trading moves to it — so re-read the catalog on your refresh cycle rather than matching once; a coupon with two legs on the same match under different ids is rejected explicitly.
  • Identifiers: event_id is a Foreline identifier — an integer, e.g. 1632432003 — stable for the life of the event. It is the only key any endpoint accepts, and there is no matching by team names. Pull the full list from /v1/catalog/events and map it to your own ids once; see the integration guide. Alongside it we return league_id and sport_id. Those are for filtering, never for matching: build your league and sport filters on them rather than on the league string, which the upstream feed may rewrite at any time. There are no team identifiers — the upstream data has none, so teams are named strings everywhere. You resolve team names once, at onboarding, against our catalog; everything after that is addressed by event_id and a team rename upstream never reaches your integration.
  • Prices are probabilities in [0, 1]; band widths are in percentage points (pp). Decimal odds are never returned — you apply your own margin. The one exception is Parlay Protection, where the point of the product is the comparison against the price you already gave: there the fair decimal odds of a coupon are returned next to the probability, still with no margin of ours in them.
  • Compatibility: within v1 changes are additive. New fields may appear; existing fields keep their meaning. Ignore fields you do not know.
  • Responses are application/json, UTF-8, except /v1/stream which is text/event-stream.
Conventions

Time formats: epoch in the payload, ISO on the calendar fields

Two shapes, fixed per field. Get this wrong on ?asof= and the answer is a 400, not a silent coercion — so it is worth reading before the first request rather than after it.

WhereShapeExample
?asof= on GET /v1/surface Epoch seconds only, integer or decimal. An ISO string here is 400. ?asof=1785448604
ts, server_ts, next_since, last_quote_ts, and the asof echoed back by /v1/surface Epoch seconds as a JSON number, not a string. Sub-second precision is carried, so compare with a tolerance rather than for equality. 1785448604.0
?since=, next_cursor on the bulk delta, the line-event feed and /v1/alerts An opaque cursor, and the number of components differs per endpoint. Its leading component happens to be epoch seconds; the rest is ours. Pass back exactly what we returned, do not parse it, and never assemble one from a clock on your side. See Cursors & paging. 1785448664.567259:1632595967
1785448664.567259:1632595967:2
starts — on catalog rows, on every surface object, on the legs and events of a priced coupon, and as a column in an export file ISO-8601 UTC with the Z suffix, the same shape on every endpoint that returns it. Parse as UTC, never as local time. Changed: the surface used to carry this field without the suffix while the catalog printed the same instant with it, and an export row could show both spellings side by side. It is one shape now, so a single parser covers every response — and a client that already tolerated the missing suffix needs no change. 2026-07-30T17:30:00Z
?asof= on /v1/parlay/price, placed_at on audit tickets, from / to on export jobs Either shape — epoch seconds or ISO-8601 UTC. 1785448604 or 2026-07-30T21:56:43Z
Every timestamp inside a downloaded export fileasof and updated_ts on surface_asof rows, ts on line_events rows, and starts on both ISO-8601 UTC with the Z suffix, in CSV and JSONL alike — the two formats carry the same fields in the same shapes, so switching format changes nothing here. This is the one place where asof and updated_ts are not epoch numbers: the same instant that arrives as 1787103289.9795551 from /v1/surface is written 2026-08-17T00:00:00Z in a file. Corrected: this table used to type updated_ts as epoch seconds everywhere and to name starts as the only calendar column in an export. Both were wrong about the files the API actually produces; the files were always ISO. 2026-08-17T00:00:00Z
asof / asof_used echoed by Parlay Protection ISO-8601 UTC with Z. Note the asymmetry: this endpoint takes either shape and always answers in ISO, while /v1/surface takes epoch and answers in epoch. 2026-07-30T21:58:00Z
# ISO on the surface as-of — rejected
curl "https://api.foreline.io/v1/surface?event_id=1633101469&asof=2026-07-30T21:56:43Z" \
  -H "X-Foreline-Key: $FORELINE_KEY"
→ 400 { "error": "bad numeric parameter: asof" }

# the same instant as epoch seconds — 200
curl "https://api.foreline.io/v1/surface?event_id=1633101469&asof=1785448603" \
  -H "X-Foreline-Key: $FORELINE_KEY"
→ 200 { "event_id": 1633101469, "asof": 1785448603.0, … }

If your language hands you an ISO string by default — most date libraries do — convert once at the edge of your client (int(datetime.timestamp()), Date.getTime()/1000) and keep the numeric form internally. The error text names the parameter, so a 400 bad numeric parameter: asof in your log is always this and never a key, scope or plan problem.

Conventions

Cursors & paging: one loop, one field

Three endpoints hand out pages — the bulk surface delta (/v1/surface?since=), the line-event feed (/v1/line-events) and the alert journal (/v1/alerts). All three page the same way, and this section is the normative description of it: where anything else on this site reads differently, this is the text that is right.

The loop. Send the cursor you were last given in since, read the page, take next_cursor from the response, and go straight to the next page while more is true. Stop when more is false; keep the last next_cursor and use it as since on your next poll. A first sync starts at since=0. On the line-event feed and the alert journal you may also omit the parameter, which means the same thing; on /v1/surface you cannot, because it is the presence of since that selects the delta rather than the coverage summary.

# the same loop on all three paged endpoints — catch up without losing rows
cursor = "0"
while True:
    r = GET "/v1/surface" | "/v1/line-events" | "/v1/alerts", params={"since": cursor, "limit": 500}
    process(r["events"])            # "alerts" on /v1/alerts
    cursor = r["next_cursor"]      # opaque string — store it as a string
    if not r["more"]: break    # never branch on len(rows)
  • Loop on more, never on the row count. A page can come back empty and still have more: true — the sport filter of your key is applied after the page is assembled, so a page whose events all belong to a sport you do not hold arrives empty with a valid cursor. Stopping on an empty page parks your mirror on that cursor forever.
  • The cursor is opaque. Do not parse it, do not build one. Today it is a string of colon-joined parts whose first component is an epoch instant, but the number of parts differs per endpoint and has already changed once: the bulk delta returns <ts>:<event_id>, the alert journal <ts>:<seq>, and the line-event feed <ts>:<event_id>:<n> — three components, because one event can change its handicap and its total in the same tick and the pair alone could not address a single row. Store it as a string and return it unchanged and none of that matters to you.
  • next_since is not a cursor and must not be used for paging. It is a plain epoch number kept for one purpose: a client written before cursors existed still gets a valid — if coarse — time boundary. It is not a paging position, and it does not always advance: everything one poll produces carries the same instant, so a page cut inside that batch leaves next_since where it was. Two measured consequences, on our own acceptance run: a bulk delta at limit=2 returned next_since: 0.0 on every page and the loop repeated the same two rows forever, and a full catch-up of the line-event feed driven by next_since read 231 of the 285 rows the feed held — 54 rows silently lost, where the same catch-up driven by next_cursor read 285 of 285 with no duplicates.
  • limit is a hard ceiling on all three endpoints: a page never contains more rows than you asked for, whatever falls on the boundary. Its defaults and ceilings are per endpoint and listed there.
  • Cursors are accepted back exactly as issued. A cursor from one endpoint belongs to that endpoint: the three-component form is only valid on the line-event feed, and the surface and alert endpoints answer 400 {"error": "bad numeric parameter: since"} to it rather than silently rewinding to zero. A bare epoch number is still accepted everywhere, and means the same coarse boundary next_since does — with the same losses.
  • Duplicates are possible, gaps are not. If your process dies mid-page and you resume from the last cursor you persisted, you may see a row you already had. We chose that side of the trade deliberately: deduplicate on (ts, event_id, market) if it matters to you.
Conventions

Naming a leg: one schema for /v1/score and /v1/parlay/price

Both endpoints take the same thing — a bet on one market of one event — and they now accept it written the same way. One vocabulary, described here once; the two endpoint sections below refer back to this one rather than restating it. Where they still differ is the sign of the handicap, and that difference is spelled out at the bottom of this section. Read it before you send your first coupon.

The side: side and selection are the same field

Send either name. /v1/score historically took side and /v1/parlay/price took selection; both names are now accepted by both endpoints, so one serialiser can feed either. Send one of the two — if you send both they must agree, because a disagreement is not rejected and you will not be told which one was used.

Market familyAccepted side / selectionline
1X2 / moneylinehome, draw, away Not applicable — /v1/parlay/price rejects a coupon that carries one (line is not applicable to market 1x2).
Totalsover, under Required. Steps of 0.25.
Asian handicap / spreadhome, away Required. Steps of 0.25. See the sign note below.

Those words are the whole vocabulary. Numeric or slip-style codes (1, X, 2) are not accepted and are not translated for you: /v1/score rejects the row with bad_side and names what it allows, /v1/parlay/price answers 400 unknown selection for market 1x2. Omitting the field entirely is the same rejection — there is no default side.

The market: one alias dictionary, on both endpoints

Every name in a row below selects the same market, on either endpoint, and matching is case-insensitive (OU and ou are one name). Previously each endpoint knew a different subset — ou, for one, was accepted when scoring a bet and refused on the coupon that contained it. That gap is closed.

MarketEvery accepted spellingEchoed back as
Match result 1x2 · moneyline · ml 1x2 on a coupon, moneyline on the surface and in an alert
Totals totals · total · ou · over_under · over/under totals
Asian handicap ah · spread · handicap · asian_handicap ah on a coupon, spread on the surface and in an alert

The list is exhaustive: a spelling that is not in it is rejected rather than guessed at, and near-misses are not repaired — over_under is an alias, over-under with a hyphen is not, and it comes back as unknown_market. The canonical name we answer with is the one in the last column; it depends on the endpoint you are reading, not on the spelling you sent, so key your own records on your own name and not on the echo. The live dictionary is also machine-readable at GET /v1/parlay under markets[].aliases.

The handicap sign is not shared between the two endpoints
On /v1/score — as on the surface ladder and on POST /v1/book-quotesline is the handicap of the home team, whichever side the bet is on. Home receiving half a goal is line: +0.5, and it stays +0.5 on a bet whose side is away.
On /v1/parlay/price the line is written on the side you selected. {"market":"ah","selection":"away","line":0.25} is away +0.25, not home +0.25.
So the same bet is {"side":"away","line":-0.5} when you score it and {"selection":"away","line":+0.5} when you price it as a coupon leg. Convert at the edge of your client, once. Both endpoints will happily accept the other endpoint's spelling and answer for a different bet — this is the one place in the API where a wrong sign is not an error but a wrong number.
Service

Health

GET/v1/health no key · no scope · no rate class

Liveness and data readiness of the service, and our clock. Takes no parameters. It does not check your key — the answer is produced before authentication, so a request with no key, or with a key that was never issued, gets the same 200. Use it as a liveness and readiness probe; to verify a key, call GET /v1/usage, which answers 401 on a bad one.

{ "status": "ok", "version": "foreline-gw/1.0", "ts": 1785450885.03,
  "registry": { "readable": true },
  "data": { "warm": true, "events": 684, "files_ingested": 1268,
            "evicted": 0, "last_tick_age_s": 41.2 } }

ts is epoch seconds, not ISO. data is the readiness of the price layer, and it is the field a load balancer should read: on a cold instance it is {"warm": false, "note": …} and the first /v1/surface will be slow while the feed is ingested. status is "ok" with 200, or "degraded" with 500 when the client registry cannot be read — in that state no key authenticates, so it is an outage rather than a per-client problem.

Identifiers

Catalog

The list of events we serve, with our ids. Everything else in this reference takes an event_id from here.

GET/v1/catalog/events bulk · 4/min · scope surface

Our event_id, sport, league, both team names, kick-off and the markets we price for each event, plus league_id and sport_id to filter on.

Parameters

ParameterTypeDescription
sportstring One of the sports on your key — in practice football, which is the only sport currently sold. Never an empty page: two different refusals, and they mean different things. A name the API does not know is 400 {"error": "unknown sport: cricket", "supported": [...]}. A sport it knows but your contract does not carry is 403 {"error": "sport not in your subscription", "sports": ["football"]}, listing what you do hold. Note that supported on the 400 is the list of sports the API recognises, not the list you can buy: it names basketball, which is in validation and is not on sale — asking for it is the 403, on every plan. Treat sports on the 403, or sports from GET /v1/usage, as the list you can actually call.
hoursnumber Time window around kick-off, in hours, 0 < hours ≤ 720 — anything else is 400. The window is not only forward. It runs from six hours ago to now + hours, so events that have already kicked off are included: on our own run ?hours=0.0001 returned 63 rows and all 63 had started. The back edge is stated in the response as live_window_s (21600); if your matching job wants pre-match only, filter on starts yourself.
limitinteger Rows returned. Default 2000, ceiling 20000 — above the ceiling the page is truncated, not rejected. limit=0 or a negative value is 400.
formatstring json (default) or csv. Anything else is 400 with supported. CSV carries a header row and a Content-Disposition filename, and column order matches the JSON key order. The one list-valued column, markets, is joined with a pipemoneyline|spread|totals — so that a comma inside a league name never makes you reason about quoting. Split it on |; an empty cell is a null, as in every other column.
league_idinteger Exact league id, as returned by /v1/catalog/leagues. This is the stable filter and the one to build on.
leaguestring League name, matched exactly — the same rule as POST /v1/exports uses. It is not a substring search: ?league=premier returns zero rows plus a note_league saying so. Sending both league and league_id is 400.
from / totimestamp Switches the endpoint to the historical slice — see the next block. Epoch seconds or ISO-8601 UTC; either one alone is allowed (from defaults to 24 h ago, to to now), and to ≤ from is 400.

Alongside the rows the response carries, at the top level: n (rows returned), n_unnamed, n_without_league_id, sports, sport, hours, live_window_s, league, league_id, note and server_ts. They are top-level keys, not nested under a meta object.

{ "events": [
    { "event_id": 1632737351, "sport": "football",
      "league": "UEFA - Champions League Qualifiers",
      "home": "Shamrock Rovers", "away": "Ararat-Armenia",
      "starts": "2026-07-28T19:00:00Z",
      "markets": ["moneyline", "spread", "totals"], "updated_ts": 1785262578.971,
      "league_id": 2632, "sport_id": 29 }
  ], "n": 1, "n_unnamed": 0, "n_without_league_id": 0, "server_ts": 1785264288.13 }

league_id and sport_id are the last two columns and were added after the first release; they are appended, so the earlier column order is unchanged. league_id may be null on an event whose fixture card predates them — the n_without_league_id counter tells you how many rows in the page that is, and the value fills in once the card is refreshed. In CSV a null is an empty field, as it is for every other column.

GET/v1/catalog/events?from=&to= bulk · 4/min · scope surface

The same catalog for events that have already finished — the ids you need to ask /v1/surface?asof= about the past. Give from and to as epoch seconds or ISO-8601 UTC; optional league (exact name) or league_id narrows it. The window may not reach further back than the as-of depth of your plan, otherwise the call returns 403 with your_plan_days. The same from / to switch works on /v1/catalog/leagues, which then counts the historical slice.

Rows carry asof_only: true: for a finished event /v1/surface answers only with ?asof=, and only where the archive covers that moment. markets and updated_ts are omitted — there is no live data behind a finished event.

{ "events": [
    { "event_id": 1632299675, "sport": "football",
      "league": "USA - USL League 2",
      "home": "Motown II", "away": "Long Island Rough Riders",
      "starts": "2026-07-11T00:00:00Z", "asof_only": true,
      "league_id": 214211, "sport_id": 29 }
  ], "mode": "history", "asof_only": true, "your_plan_days": 365, "n": 903 }

History rows carry the same league_id and sport_id, appended after asof_only. Coverage of league_id thins out the further back you go, and it is null where the archived fixture card does not have it; event_id and sport_id are always present.

GET/v1/catalog/leagues bulk · 4/min · scope surface

The same slice reduced to unique leagues, each with an event count, the next kick-off and the league_id to filter on. Same parameters as above, from / to included.

league,sport,events,next_start,league_id
Argentina - Liga Pro,football,4,2026-07-28T22:00:00Z,215171
USA - Major League Soccer,football,15,2026-07-29T00:00:00Z,214211

Rows are still grouped by league name, so the events counts always add up to the event catalog for the same slice. league_id is the id of that league where every event in the row agrees on one; in the rare case where they do not, or where none of the cards carries an id yet, it is null rather than a guess.

A sport that is not on your key returns 403 rather than an empty list. There is no resolve-by-name endpoint. Full worked examples are in the integration guide.

What is an id and what is not. event_id is the only matching key — every other endpoint takes it and nothing else. league_id and sport_id are filtering keys: use them to select the competitions and sports you care about, and treat league, home and away as display text. There are no team ids anywhere in the API, because the upstream data does not have any; teams reach you as names. That is why the team-level mapping is a one-off onboarding job against this catalog, and why everything after onboarding travels as an event_id.

Fair-Price Surface

Surface

One endpoint with four modes. The mode is selected by which parameter you pass; the parameters are mutually exclusive except asof, which qualifies event_id and cannot be sent without it.

GET/v1/surface bulk · 4/min · scope surface

No parameters: a summary of what is live right now — the events currently covered, the market keys we hold ticks for, and their freshness. Use it to discover coverage, not to read ladders. It is a full scan, so it sits in the bulk class (4/min) together with the catalog and the delta — not in point. Rows are the events whose last tick is under six hours old, newest first; limit defaults to 500 and is capped at 5000. The envelope is {"events": [...], "data_cadence_s": 30, "data_cadence_tiers_s": {...}}.

{ "events": [
    { "event_id": 1632447496, "sport": "football",
      "league": "Austria - Bundesliga", "starts": "2026-07-31T17:30:00Z",
      "markets": ["away_totals", "home_totals", "moneyline", "spread", "totals"],
      "updated_ts": 1785454018.867, "league_id": 1792, "sport_id": 29 }
  ], "data_cadence_s": 30,
    "data_cadence_tiers_s": { "lt_24h": 30, "24h_96h": 60, "gt_96h": 60 } }

The summary lists every market key we hold a tick for, which is wider than the set we price: home_totals and away_totals appear here and in the line-event feed, but the priced surface itself carries spread, totals and moneyline. There is no n and no server_ts on this response.

Every surface — the summary rows, the single-event object and each event in a ?since= bulk delta — carries the same event descriptor as the catalog: sport, league, home, away, starts, and the league_id / sport_id to filter on. You never have to hold a surface next to a catalog row to know which competition it belongs to.

Parameters

ParameterTypeModeDescription
event_idintegerpoint · 60/min The full ladder for one event: every market with its main_line, fair_market, vig / vig_tier and every rung with its band. An id we do not hold is 404 {"error": "no surface for event"}; an id in a sport outside your subscription is 403.
asofepoch secondsasof · 20/min The surface as it stood at that instant, not as later revised. This is the call to use for backtests and for settling a dispute about what was published when. It rebuilds the surface from the feed archive on disk, which is why it has its own, tighter rate class. It qualifies event_id and is only valid together with it — see the four rejections below. Epoch seconds only here — an ISO-8601 string is answered 400 {"error": "bad numeric parameter: asof"}, and the asof echoed in the response is the same number, not a date string. See Time formats; the parlay endpoint deliberately differs.
sincecursorbulk · 4/min Bulk delta: every surface that changed after that cursor, each returned in full. The page is filled from the oldest change forward — so a truncated page never skips anything — and its rows are then ordered newest updated_ts first. Page it with next_cursor and more, see Cursors & paging. limit defaults to 200 and is capped at 500.
limitinteger Page size for the summary and for the bulk delta. Above the ceiling the page is truncated silently; limit=0, a negative or an unparseable value is 400.

When ?asof= is refused

Four separate rejections, each with its own body — and not one of them costs you an as-of call. The three 400s are refused on the parameters alone; the 403 is refused on your contract. All four return the unit to the asof bucket, so neither a client failing validation nor a backtest walking off the end of your history can exhaust the 20-per-minute allowance on refusals. What every one of them does move is the client-wide rpm / rpd ceiling on your key, which counts requests rather than answers. Clamping the window to your_plan_days on your side is still worth doing — it now protects your key's overall ceiling rather than your asof budget. The whole rule, in one piece, is in Rate limits.

You sentAnswerBody
?asof= with no event_id400 {"error": "asof qualifies event_id: pass event_id together with asof", "hint": "the as-of view answers for one event; the live summary has no as-of form"}. Changed: this used to fall through to the live summary and answer 200 — silently ignoring the instant you asked about. If you have a backtest that reads the summary with an asof on it, it was never reading history, and it now tells you so.
An asof more than 5 s past our clock400 {"error": "asof is in the future", "server_ts": …}, with our clock beside it so you can measure the skew. The bound is not server_ts itself — there is a deliberate five-second allowance for clock skew on your side. An instant up to and including server_ts + 5 is answered 200 like any other as-of call; past that it is this 400. Measured against the live API: a gap of 5.0 s is served, a gap of 5.1 s is refused. The allowance exists so that a client whose clock runs a second or two fast is not punished for it — it is not a feature to build on. Inside it you are served the most recent surface we hold, never a future one, so asking for now + 4 gains you nothing over asking for now. Treat a refusal as a signal to fix your clock (NTP), not as a value to tune. Changed: an instant far ahead used to answer 404; every instant past the allowance is this 400 now.
An asof deeper than your plan403 {"error": "as-of depth is limited to N days on your plan", "your_plan_days": N, "requested_days_back": …}.
An ISO-8601 string400 {"error": "bad numeric parameter: asof"}. See Time formats.

Response — surface object

This is the object returned by ?event_id=, and the same object is what each element of a ?since= delta page contains. Prices live under markets, one entry per market — there is no market, main_line, fair_market or rungs key at the top level.

FieldTypeDescription
event_idintegerForeline event identifier, e.g. 1632432003. Never a string.
sportstringfootball (further sports appear here as they launch).
league, home, awaystringDisplay text from the fixture card; null until the card is in. Never a matching key.
startsstringKick-off, ISO-8601 UTC with the Z suffix ("2026-08-01T14:00:00Z") — the same shape the catalog prints, and the same shape everywhere else in the API. See Time formats.
league_id, sport_idintegerFiltering ids, the same ones the catalog returns. league_id may be null.
marketsobjectKeyed by market name: spread, totals, moneyline. A market the reference is not quoting right now is simply absent.
markets.spread / .totalsobjectmain_line, fair_market, vig, vig_tier, max_win, limit_tier, updated_ts, rungs[].
markets.moneylineobjectFootball: fair_market as the three-element array [home, draw, away], plus fair_calibrated, calib_layer, tier, tier_source, updated_ts. There is no ladder here, so no rungs.
data_cadence_sintegerThe fastest refresh interval in force right now: 30. Poll no faster than this and you will not miss a change.
data_cadence_tiers_sobjectThe same figure broken out by how far the event is from kick-off: {"lt_24h": 30, "24h_96h": 60, "gt_96h": 60}. We refresh events starting within 24 h twice as often as the rest, so a client that schedules per event rather than per listing spends half the calls on the far ones for the same freshness.
updated_tsnumberPer market inside markets; in a ?since= delta the row also carries a top-level updated_ts, the newest of its markets. Epoch seconds.

Bulk-delta envelope: events[], since (echo of what you sent), next_cursor, next_since, more, server_ts, data_cadence_s. Page on next_cursor — see Cursors & paging for why next_since is not the field to loop on.

Response — market fields

FieldTypeDescription
main_linenumberThe line the reference market currently treats as main.
fair_marketnumberDe-vigged fair probability at the main line — p_home on spread, p_over on totals. On football moneyline it is an array of three.
vignumberMargin measured on the reference quote before removal — what we took out, as a decimal (0.0214 = 2.14%). May be null where it cannot be measured.
vig_tierstringBand of that margin: low / mid / high.
max_win / limit_tiernumber / stringThe maximum win the reference quote carries, and its band. Redistribution of raw quotes is prohibited under all plans.
rungs[]arrayThe ladder. See below.

Response — rungs[]

FieldTypeDescription
linenumberThe handicap or total this rung prices.
p_homenumberOn spread: fair probability that home covers this line. null on totals rungs.
p_overnumberOn totals: fair probability of the over at this line. null on spread rungs.
band_pparray of two, or nullThe 80% uncertainty interval around this rung, as offsets in percentage points[low, high], e.g. [-2.73, 3.13]. It is not a single width and it is not symmetric. On the quoted rung it is [0.0, 0.0]: that rung is the reference price itself, not an extrapolation. Validated coverage is 78–80% at a nominal 80%. null where we hold no measured band for that rung — the price stands, the interval around it does not exist. See the note below.
srcstringquote — derived from a price the reference market actually quoted. table — extended from the ladder tables because the reference does not quote this rung.
monotonizedbooleanPresent only where a table rung had to be pulled back to keep the ladder monotone in line. Informational.
band_pp is always there, and is sometimes null
Changed: the key used to disappear from a rung that had no measured band, so "band_pp" in rung was the test for it. The key is now present on every rung and carries null in that case. A client that reads it with a default — rung.get("band_pp"), rung.band_pp ?? … — is unaffected; one that indexes straight into rung["band_pp"][0] now throws where it used to raise a missing-key error a line earlier.
It is a small minority of rungs: 5–6% across a full sweep of the live surface, and they cluster at the outer edge of a totals ladder — on our own sweeps of some 30,000 rungs every null sat a full goal away from the main line, none was on a spread ladder, and none was on a src: quote rung. Treat null itself as the contract and that position as an observation.
Read it as “no measured interval here”, not as “zero uncertainty here” — [0.0, 0.0] means the opposite and appears only on the quoted rung. If your risk rule needs a band on every rung, either skip these rungs or fall back to the widest band on the same ladder; do not substitute zero.
curl -s "https://api.foreline.io/v1/surface?event_id=1632432003" \
  -H "X-Foreline-Key: $FORELINE_KEY"

{
  "event_id": 1632432003,
  "sport": "football",
  "league": "England - EFL Cup",
  "starts": "2026-08-01T14:00:00Z",
  "home": "Tranmere Rovers", "away": "Rochdale",
  "league_id": 1982, "sport_id": 29,
  "markets": {
    "spread": {
      "main_line": 0.25, "fair_market": 0.5322,
      "vig": 0.0528, "vig_tier": "high",
      "max_win": 300.0, "limit_tier": "small",
      "updated_ts": 1785455338.5173044,
      "rungs": [
        { "line": -0.75, "p_home": 0.2376, "p_over": null, "src": "table",  "band_pp": [-2.73, 3.13] },
        … seven more rungs …
        { "line": 0.25,  "p_home": 0.5322, "p_over": null, "src": "quote", "band_pp": [0.0, 0.0] }
      ] },
    "totals": {
      "main_line": 2.5, "fair_market": 0.4782,
      "vig": 0.0574, "vig_tier": "high",
      "max_win": 225.0, "limit_tier": "micro",
      "updated_ts": 1785455338.5173044,
      "rungs": [
        { "line": 2.5, "p_home": null, "p_over": 0.4782, "src": "quote", "band_pp": [0.0, 0.0] }
      ] },
    "moneyline": {
      "fair_market": [0.3062, 0.29, 0.4039],
      "fair_calibrated": [0.3062, 0.29, 0.4038],
      "calib_layer": "x12_calib_layer (fit 2024, validated 2026)",
      "tier": "core", "tier_source": "league_id",
      "updated_ts": 1785455338.5173044 } },
  "data_cadence_s": 30,
  "data_cadence_tiers_s": { "lt_24h": 30, "24h_96h": 60, "gt_96h": 60 }
}

Numbers are from one real response and will not match yours. History depth available to ?asof= follows your contract; the archive reaches back to 2022 for football.

Line Radar

Line events

GET/v1/line-events?since= events · 12/min · scope surface

Main-line changes as typed facts, in chronological order. This is the catch-up channel: after a restart, replay from your last cursor and you have lost nothing — provided you replay from next_cursor. For live delivery use the stream (push delivery is not available — see Webhooks).

Parameters

ParameterTypeDescription
sincecursor Return changes after this position. Send back the next_cursor of your previous page, verbatim. Omit it (or send 0) to read the feed from the beginning. An unparseable value is 400, never a silent rewind to zero. The history depth of your plan applies here, exactly as it does on /v1/surface?asof= — see below.
limitinteger Rows per page, a hard ceiling. Default 1000, capped at 5000. A page is never longer than limit, including when a group of rows shares one timestamp — that is what the third cursor component is for.
event_idinteger Return only changes for this event — our id, from /v1/catalog/events. An id we do not hold is not an error: it returns an empty page.
Your plan's history depth applies to this feed too
Changed: the depth gate used to sit only on /v1/surface?asof=. A since pointing further back than your contract allows now answers 403, with the numbers you need to correct it: {"error": "history depth is limited to N days on your plan", "your_plan_days": N, "requested_days_back": …, "earliest_allowed_since": …}.
earliest_allowed_since is the epoch second to start from, so the fix is mechanical: clamp your stored cursor to it and replay forward. Reading the feed from the beginning is unaffected — since=0 is not a dated request and is never refused; it simply starts at the oldest row your plan can see.

Response

FieldTypeDescription
events[]arrayThe changes, oldest first.
events[].tsnumberWhen we observed the change, epoch seconds. Detection latency is p50 1 min, p95 2 min. Rows of one polling batch share this instant — which is exactly why it cannot be a paging cursor on its own.
events[].event_idintegerThe event whose line moved.
events[].marketstringMarket the move happened in: spread, totals, home_totals or away_totals. Moneyline has no line and therefore never appears here.
events[].old_line / events[].new_linenumberPrevious and new main line.
morebooleanThere is at least one more page behind this one. Your loop condition — not the row count.
next_cursorstringThe cursor. Opaque; send it back in since. It is null only when the feed holds nothing at or after the position you asked for — keep your previous cursor in that case. On this endpoint it has three components (<ts>:<event_id>:<n>) because one event can change its handicap and its total inside a single tick — do not parse it, just return it.
next_sincenumberLegacy epoch boundary. Not a paging cursor — a catch-up driven by this field measured 231 of 285 rows against 285 of 285 by next_cursor. See Cursors & paging.
sincestringEcho of what you sent.
data_cadence_sintegerHow often new rows can appear, at the fastest: 30. data_cadence_tiers_s alongside it breaks the figure out by distance to kick-off.
curl -s "https://api.foreline.io/v1/line-events?since=0&limit=3" \
  -H "X-Foreline-Key: $FORELINE_KEY"

{ "events": [
    { "ts": 1785432672.737, "event_id": 1632820926,
      "market": "spread", "old_line": -0.25, "new_line": 0.0 }
  ],
  "more": true,
  "next_cursor": "1785432672.7369776:1632820926:1",
  "next_since": 1785432672.7369776,
  "since": "0", "data_cadence_s": 30 }

# next page: the cursor goes back in `since`, unchanged
curl -s "https://api.foreline.io/v1/line-events?since=1785432672.7369776:1632820926:1&limit=3" \
  -H "X-Foreline-Key: $FORELINE_KEY"

A page can come back empty with more: true: the sport filter of your key is applied after the page is assembled, and the cursor still advances past it. Keep going while more is true. Full rules and the measured cost of getting this wrong: Cursors & paging.

Line Radar

Stream

GET/v1/stream server-sent events · stream · 4 connections/min · scope surface

A held HTTP connection emitting text/event-stream. Currently one event type, line_change, carrying exactly the row /v1/line-events returns — same field names, same types. The first frame is a comment naming the cadence, then frames as changes are detected, with a : ping comment as keepalive. The connection also has a maximum lifetime, and before closing it emits a : reconnect comment.

Two different stream limits, and they are routinely confused. GET /v1/usage returns both, side by side:

  • limits.stream — a [per minute, per day] pair, the rate at which you may open a connection. It is spent by connecting and it refills with time; holding a stream open costs nothing against it.
  • stream_concurrent_max — a plain integer, how many streams you may hold open at the same time. It is not a rate and it does not refill: it frees up when one of your streams closes.

They fail differently, which is the practical reason to keep them apart. Exceeding the connect rate is the ordinary 429 quota exceeded carrying class: "stream". Exceeding the concurrent cap is also a 429, but a different one — it names the cap instead of a quota, and waiting for the minute to roll over will not clear it:

# a fourth stream while three are held open, on a key whose cap is 3
→ 429 { "error": "too many concurrent streams", "class": "stream",
        "active_cap": 3, "retry_after": 10 }

Branch on active_cap: its presence means close a stream, not slow down. A worker pool that opens one stream per shard needs stream_concurrent_max shards at most, whatever the connect rate allows — and because a dropped connection frees its slot only once we notice it is gone, a client that reconnects instantly after a network blip can meet this while it still holds the dead slot. Reconnect on the : reconnect comment rather than racing it.

curl -N "https://api.foreline.io/v1/stream" \
  -H "X-Foreline-Key: $FORELINE_KEY"

: foreline sse | data_cadence_s=30

id: 1785432672.7369776
event: line_change
data: {"ts": 1785432672.7369776, "event_id": 1632820926, "market": "spread",
       "old_line": -0.25, "new_line": 0.0}

Resuming. The id: of a frame is its ts, and reconnecting with Last-Event-ID (or ?since=) resumes from that instant. That is a timestamp, not a cursor — rows that share the instant you resume from can arrive twice or not at all. If the gap must be exact, backfill it with /v1/line-events from the last next_cursor you stored, and use the stream for latency rather than for completeness.

Line Radar

Webhooks

Push delivery is not available yet. Pull both halves of Line Radar instead: GET /v1/stream for latency and GET /v1/line-events?since= for completeness — together they cover every case a webhook would, and neither needs an endpoint on your side that we can reach.

  • What exists today: nothing pushes. There is no endpoint for registering a URL, and no delivery runs against one if we set it for you. Anything you read elsewhere about signed push delivery describes a design, not a running service.
  • If you need push: tell us before you build against it. We will agree the contract — signature scheme, replay window, delivery id and retry policy — and give you a date. We would rather say this plainly than have you write a receiver against a promise.
  • Meanwhile: the stream holds open and delivers each change as it lands; the event feed is the same facts with a cursor, so a reader that was offline catches up without gaps. A client polling ?since= on the cursor it last stored loses nothing.
Player Radar

Score

POST/v1/score score · 120/min, 60 000/day · scope p1

Scores an account on closing-line value against our fair close, on the exact lines it took — including lines the reference market never quoted, which are repriced from the ladder tables. Input is an anonymised account identifier and bet parameters; no personal data is accepted or required.

Request body

FieldTypeDescription
account_idstringYour anonymised account identifier. Opaque to us; used only to group bets.
bets[]arrayThe bets to score.
sportstringOptional, defaults to football. A sport outside your subscription is 403 with the list you hold.
bets[].event_idintegerRequired. Foreline event identifier, from /v1/catalog/events. Team names are not accepted: a batch with a bet missing it is rejected whole, with 400, bets_without_event_id and the count.
bets[].marketstringRequired. The market, in any of its accepted spellings — spread / ah / handicap / asian_handicap, totals / total / ou / over_under / over/under, moneyline / 1x2 / ml. The full dictionary, shared with /v1/parlay/price, is in Naming a leg. A spelling outside it is unknown_market.
bets[].side
bets[].selection
stringRequired, under either name — the two are synonyms, see Naming a leg. home / draw / away on the match result, over / under on totals, home / away on the handicap. Anything else, or neither field, is bad_side.
bets[].linenumberThe line taken — any rung, not only the main one. On the handicap it is the handicap of the home team, the same sign convention as the surface ladder — not the side-relative convention a parlay leg uses. Read the sign note in Naming a leg: it is the one place where a wrong sign returns a number instead of an error.
bets[].oddsnumberDecimal odds the customer received.
bets[].stakenumberStake, in your accounting currency.
bets[].placed_attimestampWhen the bet was accepted. Pre-match only.
bets[].bet_idstringOptional, and the idempotency key for this endpoint. When present it is what we deduplicate on: re-sending a row with a bet_id already in the account is refused as duplicate and changes nothing, while two rows with identical content but different bet_ids are both accepted — which is the correct outcome for a customer who really did place the same bet twice. Omit it and we fall back to matching on content, and those two bets collapse into one. Send your own bet reference here and a retry after a timeout is safe.

Response

FieldTypeDescription
account_idstringEchoed back.
acceptedintegerBets taken into the account on this call.
rejected[]arrayRows we could not take, each with the row index i, a code from the table below, the event_id it was about and a note in words. Nothing is dropped silently, and one bad row never fails the batch.
n_bets / n_scored / n_pendingintegerBets seen, already repriced, and waiting for their event to close. A pre-match bet on an event we hold lands in n_pending — that is the healthy answer at acceptance time, not a rejection.
pending_notestringPresent whenever n_pending is non-zero. Bets on events that have already closed are repriced from the feed archive, which is slow on a cold account; a call spends at most 20 s on that and finishes the rest in the background. Call again in about a minute to collect it — nothing is lost and nothing is scored twice. Give a settled-history batch a client timeout to match.
n_submitted / n_rejected_total / n_duplicate / n_droppedintegerRows you sent, rows refused, rows refused specifically as duplicate, and rows dropped. These four are running totals for the account_id, not per-call countsaccepted and rejected[] describe only the call you just made, so on the second call for an account they will not add up to n_submitted and nothing is wrong. The invariants worth asserting are n_submitted == n_bets + n_rejected_total and n_bets == n_scored + n_pending + n_dropped; either failing means a row went missing on our side. A truncated or mis-serialised batch shows up as accepted lower than the number of rows you posted. A dropped row is not a rejected one: it was accepted, so it counted towards n_bets and does not appear in rejected[], but it could not be repriced — most often because no reference price existed at placed_at. dropped_reasons names the reason and the count for each; the complete set is no_price_at_placement, no_closing_price and price_not_finite. A bet is dropped only after 24 h past kick-off; until then it stays in n_pending and is retried. A dropped bet never scores and never sits in n_pending: do not wait for it.
scorenumberThe combined account score — normalised closing-line value over scored bets.
tstatnumberThe magnitude component: how far the account beats our fair close, relative to its own dispersion.
sign / sign_pThe consistency component: positive/total and its p-value against chance.
flag / flag_k / flag_srcWhether a flag is raised, at which scored-bet checkpoint, and by which component (clv, sign, both). Checkpoints are 5/10/20 scored bets for closing-line value and 10/20/30/50 for sign. Family-wise false-positive rate measured at 1.32% against a 2% budget. Kept for compatibility — the continuous fields below need no checkpoints.
confidencenumberContinuous, anytime-valid confidence that the account beats the closing line systematically, 0–1. The guarantee: a fair account has at most a 1−c chance of ever reaching confidence c, no matter how often you poll — so you may read it after every bet and set your own action threshold instead of using our flag. Sharper accounts reach high confidence in fewer bets; measured on 60,000 fair accounts the stated levels hold with margin at 90/95/99/99.9%.
e_valuenumberThe raw evidence ratio behind confidence (confidence = 1 − 1/max(1, e_value)). Use it directly if you prefer thresholds like “20:1”.
confidence_at_betintegerThe first scored bet at which confidence reached 0.95, or null if it has not.
bet_signals[]arrayNot returned today. It is populated only when our short-horizon forecast layer holds a value for the event and market, and that layer is switched off in production, so the field is absent from every response. It is not gated by plan and it is not part of what you bought: do not build a path that waits for it, and do not build one that depends on it appearing.

Reject codes

The full set, every row of it verified against the live API. Branch on code; the note beside it is a sentence for a human and is not an enum. Two of these codes are about the identity of the event and are easy to confuse, so they are written next to each other below — and the right response to one is the exact opposite of the right response to the other.

codeWhat it meansFix
duplicate The bet is already in this account — matched by bet_id, or by content when you send none. It was counted once; the retry changed nothing. Nothing. Re-posting a batch is safe by design; the response also carries n_duplicate, so a replay shows up in your numbers instead of passing silently.
unknown_event We hold no event card under that id at all. Both a number we have never issued and a value that is not a number land here — the id is simply not ours. The note names it: “event 999999999 is not one of our event ids — check it against GET /v1/catalog/events; do not resubmit this bet unchanged”. Do not resubmit. No amount of waiting turns this id into one of ours, so a retry queue is the wrong answer: fix the mapping against /v1/catalog/events and send the integer from there. Changed: an unrecognised numeric id used to come back as event_not_scorable, carrying the advice to resubmit after the close — advice that could only ever loop.
event_not_scorable The opposite case, and the only one left on this code: the event is ours, but there is no close to score against. The score is closing-line value, so a market that has not closed cannot contribute one. It no longer covers an id we do not hold — that is unknown_event above. Do resubmit, once the event's market has closed, or sweep the bet up in a nightly catch-up batch. Note what this is not: it is not the normal answer to a bet posted at acceptance time. A pre-match bet on an event we hold is accepted and counted in n_pending until its market closes, and needs no resubmission at all.
unknown_market The market is outside the alias dictionary in Naming a leg. Use one of the accepted spellings.
in_play placed_at falls after kick-off. Scoring covers pre-match bets only. Filter in-play bets out before sending, or accept the reject.
placed_at_in_future placed_at is ahead of our clock — a bet cannot have been accepted later than now. The note says by how many seconds. Usually a timezone bug: send UTC, or epoch seconds.
bad_side The side / selection is not one this market takes, or neither field was sent. The note names what is allowed. See the side vocabulary in Naming a leg.
bad_odds The decimal price is outside the accepted range — it must be greater than 1 and at most 10000. Send decimal odds, not fractional, American or implied probability.
bad_stake The stake is negative. Send the absolute stake in your accounting currency.
bad_line The line is present but not a number. Send a number. A line off the 0.25 step, or one no rung sits on, is not this error — it is repriced from the ladder tables.
bad_row The row is not an object at all, or placed_at is missing or unparseable. placed_at is required: ISO-8601, or epoch seconds or milliseconds.

One rejection is not a row-level code at all: a batch in which any bet is missing event_id is refused whole, with 400, bets_without_event_id (the offending indices), n_bets_without_event_id and a reason. That one is deliberate — matching by team name would silently score the wrong event, so we would rather fail your batch than guess.

Flag thresholds and the flag vocabulary are set per contract during onboarding, so the score can be tuned to your appetite without changing your integration. Bets on events outside your scope are reported as unscored rather than silently dropped.

Line Radar

Alerts

GET/v1/alerts alerts · 60/min, 40 000/day · scope p2

Gap warnings — where your posted price has drifted from fair — as a journal. It is append-only: every transition of an alert is its own record, so the lifecycle is read by following one key over time rather than by re-reading a mutable object. Page it with since / next_cursor, exactly as the other feeds (Cursors & paging).

Parameters

ParameterTypeDescription
sincecursor Records written after this position, boundary exclusive. Send back next_cursor; on this endpoint it is <ts>:<seq>, and the three-component form of the line-event feed is rejected with 400.
limitinteger Page size. Default 500, capped at 2000.
event_idinteger Return only records for this event — our id, from /v1/catalog/events. An id we do not hold is not an error: it returns an empty page.
statusstring Keep only records in that status. Unknown values are not an error — they simply match nothing.

Response

Envelope: alerts[], since, next_cursor, next_since, more, limit, server_ts. One record looks like this — the names below are the names in the payload:

FieldTypeDescription
statusstring The state this record puts the alert in. Open: active (new warning), revised (the gap moved materially), corrected (the gap flipped to the other side). Closed: confirmed (you moved your price and the gap went under threshold), withdrawn (the gap closed without you moving), expired (no confirming quote within alert_ttl_s). Closed records stay in the journal.
tsnumber When this record was written, epoch seconds. There is no opened_ts / resolved_ts pair: lead time is the distance between the ts of the opening record and the ts of the closing one for the same key.
event_id, market, lineinteger / string / number or null What the warning is about. Together with book these four are the identity of an alert — there is no alert_id. market is our canonical name (moneyline, spread, totals) whatever spelling you sent us. line is null on moneyline, which has no ladder — so the identity key of a moneyline alert is a three-tuple with a null in it, not a missing field.
bookstringWhich book the price came from; for your own quotes it is client:<your id>.
side / sportstringThe side the headline gap_pp is measured on (home, draw, away, over, under) and the sport of the event.
fair / clientnumberOur fair probability at that rung and the probability implied by your price, after removing your margin.
gap_ppnumberclient − fair in percentage points, on the side named by side. Positive means your price is shorter than fair — you are offering less than the fair price on that side. Negative means your price is longer than fair. The direction field says the same in words (short / long); branch on it rather than on the sign if you can. Which side the headline is reported on is fixed, not chosen: on a two-way market (spread, totals) it is always home / over, so a negative gap_pp there means the other side is the short one; on moneyline it is the side with the largest |gap|. The side actually at risk is exposed_side, and every side's gap is in gaps_pp — triage on those rather than reading side as “the side that is wrong”.
band_pparray of two, or nullThe uncertainty interval of that rung, [low, high] in percentage points — read gap_pp against it. Same shape, and the same null case, as on the surface. Always null on a moneyline alert: there is no rung to carry a band.
direction / exposed_side / gaps_ppstring / string / objectWhich way your price is off, which side of the market carries the exposure, and the gap on each side.
book_price / fair_price / book_overroundnumberYour decimal price, the decimal price our fair probability implies, and the overround measured on the quotes you sent.
main_linenumber or nullThe market's main line at the time. null on moneyline, which has no line.
fair_srcstringWhere the fair value we compared against came from. Four values, and which two you can see depends on the market: on spread and totals it names the rung — quote (the reference actually quoted this rung) or table (extended from the ladder tables). On moneyline there is no rung, so it names the vector instead — fair_calibrated (the calibrated three-way probabilities) or fair_market (the de-vigged market ones, where no calibration layer applies). A client branching on quote / table alone will fall through on every moneyline alert.
fair_updated_tsnumberWhen that fair value was computed. Epoch seconds.
limit / prioritynumber or nullThe reference max_win on that rung, and |gap_pp| / 100 × limit — the reference max-win at that rung scaled by the gap expressed as a probability, not as points, so a 3.07 pp gap on a 450 limit gives 13.82. A triage order, not a recommendation. Both are null on moneyline, where there is no rung to read a limit from: sorting a mixed journal by priority must handle the null rather than assume a number.
reason / last_quote_ts / stale_sstring / number / numberOn closures only: why it closed (ttl, line_gone, market_gone, event_gone), the last real quote we saw on that rung, and how long ago that was. See the note below.
alert_threshold_pp / alert_threshold_notenumber / stringThe threshold applied to your key, restated on every record, with its disclaimer.
notestringHuman-readable remark; empty on an ordinary record.
A moneyline alert carries five nulls, by construction
The ladder fields have nothing to describe on a market that has no ladder. On every market: "moneyline" record, line, main_line, band_pp, limit and priority are null together — not sometimes, always. On spread and totals records the same five are populated.
They are typed as nullable above for that reason. The failure this prevents is a triage screen that sorts on priority or plots gap_pp against band_pp: on a moneyline alert both are absent, and a moneyline alert is the most common kind on a book that quotes 1X2.

Comparing your price with fair requires your prices. The optional POST /v1/book-quotes is how they reach us — a batch of your active quotes every 30 seconds, de-vigged and compared rung by rung, with the resulting alerts landing in this same journal and visible only to your key. Nothing is inferred about your book from third-party sources; if you do not send prices, we do not have them. Contract and reject codes: integration guide.

Your first read can return a burst of closures
Reading the journal does not only return records — it also brings the lifecycle up to date. An alert that is still open but has had no confirming quote for longer than your TTL (alert_ttl_s, 900 s by default, set per key at onboarding) is closed at that point with status: "expired" and reason: "ttl", and the closure is stamped with the current instant.
So the first read after a quiet period — an onboarding, a long gap in your quote feed, a journal that looked empty — can hand you N closures at once for alerts you never saw open. On our own acceptance run that was 11 records in a single response, each with stale_s around 199,000 s against a 900 s TTL. Nothing raced and nothing was lost: the closures are the journal catching up in one step.
They are self-describing, so you can tell them apart without special cases: a reconciliation closure carries last_quote_ts (the last real quote we saw on that rung) and stale_s (how long ago that was). Treat stale_s much larger than your own alert_ttl_s as history being settled rather than as N things that just happened — page it, store it, but do not put it on a trader's screen as live. Read from since=0 and you also get the matching open records first, with their original older ts, so the full lifecycle is there. From the second read on, the journal is strictly incremental.
Enterprise

Batch exports

History in bulk, as a file. You create an export job, poll it, then download a gzipped CSV or JSONL. Included on Enterprise; limited on Pro; not available on Trial and Starter, where the endpoint answers 403 with "batch exports are not included in your plan".

POST/v1/exports export · 2/min, 20/day · scope surface

Creates a job and returns 202 immediately. Nothing is computed on this call: rebuilding one as-of surface reads the feed from disk, and an export is hundreds of those, so the work runs on a background worker and the file is fetched separately. Jobs run one at a time, in submission order, across all clients.

FieldTypeDescription
kindstringRequired. surface_asof — the full ladder of every event on a grid of past instants. line_events — every main-line change in the window.
from / totimestampRequired. Window bounds, epoch seconds or ISO-8601 UTC. to is clamped to now. from may not be deeper than the as-of history your plan holds — otherwise 403 with your_plan_days.
event_ids[]arrayForeline ids from /v1/catalog/events. Give exactly one selector — this, league_id or league.
league_idintegerLeague id, as returned by /v1/catalog/leagues. Selects every event of that league starting within the window ±24 h. This is the selector to build on: it is the stable key, while the league string is display text the upstream feed may rewrite between one export and the next. It was accepted before it was written down here — the error you get from a job with no selector already names it.
leaguestringLeague name, matched exactly, as returned by /v1/catalog/leagues — the same rule the catalog uses, not a substring search. Same selection as league_id, but a rename upstream silently empties your export where an id would not.
step_snumbersurface_asof only. Grid step in seconds, default 3600, minimum 30 — the fastest reference cadence. A finer grid returns identical snapshots. Note that 30 s only buys you extra resolution on events starting within 24 h; beyond that the reference refreshes every 60 s and the extra points repeat.
formatstringcsv (default) or jsonl. Both carry the same fields in the same order; the file is always gzipped.
curl -X POST "https://api.foreline.io/v1/exports" \
  -H "X-Foreline-Key: $FORELINE_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"surface_asof","format":"csv","event_ids":[1632003658],
       "from":"2026-08-17T00:00:00Z","to":"2026-08-17T05:00:00Z","step_s":3600}'

{ "export_id": "exp_257a206794d114ccd7853b21", "status": "queued", "kind": "surface_asof",
  "format": "csv", "from": "2026-08-17T00:00:00Z", "to": "2026-08-17T05:00:00Z",
  "events": 1, "progress": 0.0, "rows": 0, "bytes": 0,
  "truncated": false, "created": "2026-08-19T01:31:30Z",
  "started": null, "finished": null,
  "expires": "2026-08-21T01:31:30Z",
  "columns": [ "event_id", "asof", "sport", … ], "queue_position": 1 }

Note queue_position: jobs run one at a time across all clients, so a queued job may sit before it starts. from / to and every other timestamp in this response are ISO-8601 with Z, as are the timestamps inside the file itself. When the selector is a league rather than event_ids, events comes back null and an events_note explains that the count is resolved when the job runs.

GET/v1/exports status · 60/min, 20 000/day · scope surface

Your jobs, newest first, plus the current limits object. Only your own jobs are listed — there is no way to see or address another client's export.

GET/v1/exports/<export_id> status · 60/min, 20 000/day · scope surface

Job status. Poll this; a job takes minutes, so once every 10–30 s is plenty.

FieldTypeDescription
statusstringqueuedrunningdone, or failed. A job never ends silently: failed always carries error.
progressnumber0.01.0, by events for surface_asof and by days for line_events.
rows / bytesintegerRows written and the gzipped size on disk.
truncatedbooleantrue when the row cap was reached and the file stops short; truncated_reason says so in words. Everything that can be checked before the run (event count, grid size, jobs in flight, storage used) is rejected at creation instead, with numbers.
columns[]arrayThe exact column order of the file — the CSV header, and the key order of each JSONL object.
expirestimestampWhen the file is deleted. Default lifetime is 48 h from completion.
downloadstringPresent once status is done.
GET/v1/exports/<export_id>/download export_dl · 10/min, 200/day · scope surface

The file itself: application/gzip with a Content-Disposition filename. Re-downloadable until the job expires. Before the job is done this answers 409 with the current status; after the file is gone, 410. Its own rate class, so a burst of downloads never eats the budget your live surface calls run on.

curl -OJ "https://api.foreline.io/v1/exports/exp_4f1c9a02b7d35e60c8a11d42/download" \
  -H "X-Foreline-Key: $FORELINE_KEY"
DELETE/v1/exports/<export_id> status · 60/min, 20 000/day · scope surface

Removes the job and its file at once, freeing your export storage, and answers {"export_id": …, "deleted": true}. A job that is still running may be deleted, and deleting it is final: the worker stops, the partial file goes with the job, and from that moment the id answers 404 everywhere — on the status endpoint, on the download endpoint, and by its absence from GET /v1/exports. Fixed: a running job used to come back roughly twenty seconds after it was deleted, when the worker wrote its state back over the deletion, so a client that polled after deleting saw the job return and its storage stay claimed. It no longer returns — checked against the live API by deleting a job mid-run and polling it for two minutes afterwards. An export_id that is not yours — or no longer exists — answers 404 on every one of these endpoints, identically, so the API never confirms that someone else's export exists.

Columns

kindOne row is…Columns
surface_asofone ladder rung of one market of one event at one instant (moneyline has no ladder and contributes a single row) event_id, asof, sport, league, home, away, starts, market, main_line, line, src, p_home, p_draw, p_away, p_over, band_lo_pp, band_hi_pp, fair_market, vig, vig_tier, max_win, limit_tier, updated_ts, league_id, sport_id
line_eventsone main-line change ts, event_id, sport, league, home, away, starts, market, old_line, new_line, league_id, sport_id

Timestamps in the file are ISO-8601 UTC with Z, all of them. That covers asof and updated_ts on a surface_asof row and ts on a line_events row — fields that are epoch numbers when the same data comes back from a live endpoint. The two formats agree; only the container differs:

# surface_asof, csv (header + first row)
event_id,asof,sport,league,home,away,starts,market,main_line,line,src,…,updated_ts,league_id,sport_id
1632003658,2026-08-17T00:00:00Z,football,Italy - Serie A,Atalanta,Sassuolo,2026-08-23T18:45:00Z,moneyline,…,2026-08-16T23:54:54Z,2436,29

# surface_asof, jsonl (same row)
{ "event_id": 1632003658, "asof": "2026-08-17T00:00:00Z", … ,
  "starts": "2026-08-23T18:45:00Z", … , "updated_ts": "2026-08-16T23:54:54Z", … }

# line_events, csv and jsonl
ts,event_id,sport,league,home,away,starts,market,old_line,new_line,league_id,sport_id
2026-08-18T15:01:11Z,1633801653,football,CONCACAF - Central American Cup,…,spread,-4.0,-3.75,227146,29
{ "ts": "2026-08-18T15:01:11Z", "event_id": 1633801653, … }

So a loader that casts updated_ts to a number because the live surface returns one will fail on the file, and a loader that parses every timestamp column as ISO will read both files correctly. Parse as UTC; the offset is always Z.

Field meanings are identical to the live endpoints — see the glossary. Empty cells are nulls: p_draw and p_away are only filled on football moneyline rows, p_over only on totals, p_home on spread and moneyline.

Column order is stable and both schemas end with league_id and sport_id — the same ids as /v1/catalog/events, so a league dictionary built from the catalog joins onto a downloaded file by id and never by league name. They were appended after the first release; nothing before them moved. league_id is empty on an event whose fixture card predates it.

Ceilings

CeilingDefaultExceeded →
Snapshots per job (events × as-of points)2 400413 with max_snapshots and requested_snapshots
Events per job200413 with max_events
As-of points per job24413 with max_points
Rows per file5 000 000job completes with truncated: true
Jobs in flight per client3409 listing the active ids
Export storage per client2 GB413 with used_bytes
File lifetime48 hfile and job are deleted; the id then answers 404

Live values are on every list response under limits. Need a wider window than these allow? Split it into several jobs, or talk to us — Enterprise ceilings are contractual, not hard-coded.

Parlay Protection

Joint parlay prices

A bet-builder that multiplies its legs prices them as if they were independent. On one match they are not: the result and the goal volume move together. These endpoints return the joint probability of a coupon from a single score matrix, the naive product of the same marginals next to it, and the distance between the two in percentage points — which is the number the product exists for. Add-on: calls need the parlay scope, and without it every endpoint here answers 403. Football only.

GET/v1/parlay status · 60/min · scope parlay

What the product does, before you call it: supported leg types and their aliases, the families we deliberately do not serve and why, the pricing conventions, the measured accuracy and the live ceilings. Read it once at integration time.

POST/v1/parlay/price parlay · 120/min · ?asof= → parlay_asof · 4/min

Prices one coupon. Send the legs; get back the joint probability, the fair decimal odds, the naive product and the correlation gap, plus a breakdown by leg and by event. Optionally send your own odds and stake and the response also carries the edge and the expected value on that single coupon.

FieldTypeDescription
legs[]arrayRequired. 1 to 12 legs; at most 6 of them on one event. Exact duplicates are rejected rather than collapsed. The legs come back ordered by event and market, not in the order you sent them — each returned leg carries leg_index, its position in your request (0-based, the same index the 400 texts name). Join on leg_index, or on (event_id, market, selection, line); never on array position.
legs[].event_idintegerRequired. A Foreline id from /v1/catalog/events. Team names are never matched: an id we do not know answers 400 with a pointer to the catalog.
legs[].marketstringRequired. 1x2, totals or ah, or any of their accepted spellings — the dictionary is shared with /v1/score and is listed in Naming a leg. The leg comes back echoed as 1x2 / totals / ah whichever spelling you sent.
legs[].selection
legs[].side
stringRequired, under either name — side is accepted as a synonym of selection, see Naming a leg. home/draw/away for 1x2, over/under for totals, home/away for ah. Nothing else is normalised for you: a missing or unknown value is 400, naming the leg index.
legs[].linenumberRequired for totals and ah, rejected on 1x2. Steps of 0.25. The handicap is written on the side you selected: {"market":"ah","selection":"away","line":0.25} is away +0.25.
oddsnumberOptional. The decimal price you gave. Present ⇒ the response adds edge_pp, margin_pp and expected_value.
stakenumberOptional, default 1. Only used for expected_value.
?asof=timestampQuery parameter, not body. Prices the coupon on the marginals of a past instant, epoch or ISO-8601 UTC (unlike /v1/surface, which takes epoch only — see Time formats). Subject to the as-of depth of your plan. Its own, much tighter rate class: this rebuilds the surface from the archive on disk, which also makes it slow enough to need a client timeout of 30 s or more — see below.
curl -X POST "https://api.foreline.io/v1/parlay/price" \
  -H "X-Foreline-Key: $FORELINE_KEY" -H "Content-Type: application/json" \
  -d '{"legs":[{"event_id":1632003658,"market":"1x2","selection":"home"},
              {"event_id":1632003658,"market":"totals","selection":"over","line":2.75}]}'

{ "priced": true, "n_legs": 2, "n_events": 1,
  "joint_probability": 0.376328, "fair_odds": 2.6573,
  "naive_probability": 0.325497, "naive_odds": 3.0722,
  "correlation_gap_pp": 5.0831, "correlation_gap_pct": 15.62,
  "push_probability": 0.112021,
  "uncertainty_pp": 5.3,
  "uncertainty_basis": "measured calibration maxGap; the wider value applies
                        because this coupon touches the \"home & over\" family",
  "legs": [ { "leg_index": 0, "market": "1x2", "selection": "home", "probability": 0.632412,
               "fair_odds": 1.5812, … },
             { "leg_index": 1, "market": "totals", "selection": "over", "line": 2.75,
               "probability": 0.514692, "push_probability": 0.112021, … } ],
  "events": [ { "event_id": 1632003658, "n_legs": 2, "joint_probability": 0.376328,
               "correlation_gap_pp": 5.0831, "family": "home & over" } ],
  "cross_event": { "n_events": 1, "assumption": "independent" } }

Read that example as the product in one line: a builder multiplying its own marginals would post 3.072 on this coupon. The honest price is 2.657. The 5.08 pp between them is what the multiplication gives away, every time this family is built.

On the uncertainty_pp in that response. It is 5.3, not the 2.1–2.9 headline band, and the uncertainty_basis beside it says why: this coupon is in the home & over family, the one family where our own measurement is worst and which we publish rather than patch — see How accurate this is. A coupon outside that family carries the narrower number. Read uncertainty_pp from the response rather than hard-coding it: it is per-coupon, and it is the number to price against.

POST/v1/parlay/audit parlay_asof · 4/min, 400/day · scope parlay

A batch of your own coupons, with the prices you actually gave, each priced on the marginals of the moment it was accepted. Per coupon: our joint probability, the probability implied by your price, the edge in points and the expected value in your currency. Plus a summary — which combination families cost you and how much. The report is stored under your account and can be re-read later.

FieldTypeDescription
tickets[]arrayRequired. 1 to 50 coupons per call.
tickets[].legs[]arrayRequired. Same leg schema as /v1/parlay/price.
tickets[].oddsnumberRequired here. The decimal price you gave the customer. Without it there is no edge to report, so the call is rejected rather than answered with half a number.
tickets[].stakenumberOptional, default 1. When it is missing the coupon is flagged stake_assumed and the summary says how many were counted per unit staked.
tickets[].placed_attimestampWhen the bet was accepted. Marginals are read at that instant, floored to a 30-second boundary (the fastest reference cadence, so a finer instant is the same snapshot); the instant used comes back as asof_used. Omit it and the coupon is priced on the live surface.
tickets[].ticket_idstringOptional, echoed back. Defaults to the position in the batch.
currencystringOptional, default EUR. Labels the money columns; no conversion is done.
{ "audit_id": "par_4f1c9a02b7d35e60c8a11d42",
  "summary": {
    "tickets": 1, "priced": 1, "unpriced": 0, "currency": "EUR",
    "turnover": 120.0, "expected_value": -23.1841,
    "expected_value_pct_of_turnover": -19.32,
    "money_note": "all money figures are from the operator perspective:
                    positive means you earn, negative means you pay out",
    "leaks": { "tickets": 1, "share_pct": 100.0, "expected_value": -23.1841 },
    "correlation_gap_abs_pp": { "mean": 5.41, "median": 5.41, "p90": 5.41 },
    "by_family": [ { "family": "1x2:home+totals:over", "tickets": 1,
                    "mean_correlation_gap_pp": 5.41, "mean_edge_pp": 6.04,
                    "expected_value": -23.1841 } ],
    "worst_tickets": [ { "ticket_id": "T-1001", "client_odds": 3.2,
                       "fair_odds": 2.6819, "edge_pp": 6.0375 } ] },
  "tickets": [ … one object per coupon, same shape as /v1/parlay/price … ] }

Signs. Money is always from your side of the table: positive means you earn, negative means you pay out. edge_pp is the customer's edge — above zero means your price is generous against our joint probability, and those are the coupons a professional selects. leaks aggregates exactly those.

GET/v1/parlay/audits status · 60/min · scope parlay

Your stored reports, newest first, as headers only (id, created, expires, ticket count, turnover, expected value) plus the current limits. Only your own reports are listed.

GET/v1/parlay/audits/<audit_id> status · 60/min · scope parlay

The full report again, byte for byte as it was computed — the numbers are not recomputed against a newer surface, so a report you show your risk desk next month is the one you were given.

DELETE/v1/parlay/audits/<audit_id> status · 60/min · scope parlay

Removes the report. An audit_id that is not yours — or no longer exists — answers 404 on every one of these endpoints, with an identical body, so the API never confirms that someone else's report exists.

Leg types

ServedselectionlineNote
1x2 (also moneyline, ml) home, draw, away One of the three markets the matrix is fitted to.
totals (also total, ou, over_under, over/under)over, under required, step 0.25 Any rung, not only the main line. Fitted to the main line; other rungs come out of the matrix.
ah (also spread, handicap, asian_handicap) home, awayrequired, step 0.25 The handicap ladder is the held-out market: the matrix is not fitted to it and still reproduces it with a median error of 0.83 pp.
Not servedWhy not
both teams to score · exact score · team totals · first- and second-half markets The score matrix could derive all of them. The reference market we price against quotes only 1X2, totals and asian handicap for the full match, so there is nothing to check these families against — no reference means no measured accuracy, and we would rather not serve a family than serve it with a number we cannot stand behind. Ask us if you need one: the answer is a measurement, not a switch.
player props (goals, shots, cards)
basketball A basketball joint model exists and is not released. A basketball event_id answers 400 with supported_sports, never a silent price.

Conventions that change the number

  • Legs of one event are priced jointly; legs of different events are multiplied. The joint part is the product. Across matches we multiply as independent and say so in cross_event.assumption — cross-match dependence is not measured on our data, and an assumption we state is worth more than a model we do not have.
  • Quarter lines are a mixture. A 0.25 line is half a stake on each adjacent half line, so the coupon becomes a mixture of two coupons and the probabilities are averaged. This is the same convention the single markets use, which is why a one-leg coupon on 1X2 or on a totals rung returns exactly the probability /v1/surface shows for that rung. A one-leg handicap coupon does not. The handicap is the held-out market: it is reconstructed from the score matrix rather than read off the ladder, and it misses the quoted rung by 0.83 pp at the median and 2.10 pp at p90 (200 settled 2025 events; the same figures are published as handicap_check_pp and handicap_check_note on GET /v1/parlay). Use /v1/surface when you want the quoted handicap, and /v1/parlay/price when you want the handicap that is coherent with the rest of the coupon.
  • Pushes are priced out and reported, not hidden. On a whole line a leg can push. The joint price is conditional on no leg pushing — the push is removed from the denominator, exactly as on the single markets — and the mass removed comes back as push_probability. What a push does to the payout is your own void-and-reprice rule, and we do not model it: you need that number to apply your rule, so you get it.
  • Mutually exclusive legs return zero, not an error. A coupon that cannot win gets joint_probability: 0, fair_odds: null and a note.
  • No marginals means no price. If the reference has nothing for an event at the requested instant, the coupon comes back 200 with priced: false, a reason and unpriced_event_ids. Nothing is extrapolated and no neighbouring instant is substituted.

How accurate this is

Measured, and published as a range rather than a point. maxGap is the largest deviation between predicted and realised frequency across calibration buckets — the worst bucket, not the average one. Every response carries this block, so it travels with the number instead of living in a footnote.

FamilymaxGapStatus
joint families overall2.1–2.9 pp Measured on settled football outcomes 2024–2026. The 2026 season was held out of every modelling decision and reproduced the same range. Four families sit inside this band; by_family_2026_holdout_pp in every response gives each one its own number, including the wider ones.
home & over4.4–5.3 pp Known limitation. The model underprices this family in the middle buckets. Four walk-forward correction attempts failed to hold out of sample, so the miss is published rather than patched. Coupons touching it come back with uncertainty_pp: 5.3 instead of 2.8, and the event breakdown names the family. Price it against the wider band, not the point number.
away-side and handicap & over families3.4–3.7 pp Above the headline band: the static score matrix underprices them systematically. Published rather than hidden — price these against their own number, not the headline range.

The band applies per event. Combining several events does not shrink it — uncertainty_pp on a multi-event coupon is the worst of its event groups, not a coupon-level confidence interval.

Ceilings

CeilingDefaultExceeded →
Legs per coupon12413 with max_legs and requested_legs
Legs on one event6413 with max_legs_per_event
Coupons per audit batch50413 with max_tickets
Line step0.25400 with line_step
Pricing budget per batch25 sthe batch returns with budget_exhausted: true; coupons that were not reached carry priced: false and reason: "time budget exhausted". Nothing is dropped silently
Stored audit reports500413 with max_stored_audits; delete the ones you have read
Report lifetime30 daysthe report is deleted; the id then answers 404

Live values are on /v1/usage and on every /v1/parlay/audits response under limits. The two rate classes are separate on purpose: pricing a live coupon is a matrix fit in single-digit milliseconds, while an as-of coupon rebuilds the surface from the archive on disk — measured at 88 ms one hour back and 1.15 s three days back. A burst of audits must not eat the budget your live builder runs on.

Give as-of coupons a client timeout of 30 s or more
An archival coupon is not a lookup. Every leg is repriced from the feed archive on disk, and the cost is dominated by how much history has to be replayed before the marginals for that instant are complete. End to end, over a full acceptance run: p50 2.1 s, p95 9.9 s, worst measured 18.0 s. Live coupons, in the same run, stayed in single-digit milliseconds.
The server carries its own budget of 25 s per batch and always answers within it. Over budget it still returns 200, with budget_exhausted: true and reason: "time budget exhausted" on the coupons it did not reach — nothing is silently dropped, and you learn exactly what to re-request.
A 10 s client timeout — the default in several HTTP clients — therefore cuts the connection before the server has finished a request it was going to answer honestly. You lose the price and the diagnosis, and a retry restarts the same work from scratch, so the load doubles at the worst moment. Set ≥ 30 s on every ?asof= call and on POST /v1/parlay/audit, and keep your short timeout for live coupons. The two rate classes are separate exactly so you can give them separate timeouts.

Errors

StatusWhen
400Unknown event_id (with a pointer to the catalog), unknown or unserved market, wrong selection for the market, missing or off-step line, a line on 1x2, duplicate legs, empty coupon, a basketball event, an audit ticket without odds, an unparseable placed_at.
403No parlay scope (the response names the add-on), an event in a sport outside your subscription (the response lists the sports you hold), or an ?asof= deeper than your plan (with your_plan_days).
404An audit_id that is not yours, malformed, expired or deleted — all four identical.
405Wrong method; the response carries allow.
413Any ceiling above, always with the number you sent and the number allowed.
Service

Usage

GET/v1/usage status · 60/min, 20 000/day · no scope

Your consumption against the quotas on the key: usage carries the requests you have spent this minute and this day, both against the class of this call and against the key as a whole, and limits carries the [rpm, rpd] pair of every call class on your key. Alongside them it returns stream_concurrent_max — how many SSE streams you may hold open at once, which is a different quantity from the connect rate in limits.stream and is explained by the stream_note in the same response. It needs no scope, only a valid key — which also makes it the honest way to check one. Poll it on a schedule and alert on your own headroom rather than discovering it as 429s.

It also tells you what your plan does not include, before you call it: closed_classes[] lists every call class whose limit is [0, 0] — those calls are rejected with 403, not queued — and exports carries enabled plus, when enabled, the live batch-export ceilings. The same holds for parlay: it says whether joint parlay pricing is on your key and, when it is, carries the supported markets, the ceilings and the measured accuracy — including the family where the model is weakest. Read it once at integration time and you will never discover a missing feature from a production error.

Getting a key (self-serve plans): you buy Starter or Pro in your account (sign-in by email code). After Stripe checkout you are redirected to a one-time page that shows the raw key exactly once — we keep only its sha256. Lost keys are reissued by support. A minimal account page (key → plan, limits, live usage) is at /v1/portal.

Errors

Status codes

StatusMeaningRetry?
401Missing, malformed, revoked or ambiguous X-Foreline-Key. Every 401 body carries a hint. Sending the header twice is its own answer — {"error": "more than one X-Foreline-Key header was sent"}; a proxy or a client that sets the header on both the session and the request is the usual cause. No — fix the header.
403Authenticated, but the call is outside the scope of your contract: a sport, product, call class or history depth you do not hold. Returned instead of an empty result so “not permitted” never looks like “nothing there”. The body says which of the four it is and names it in the words you bought it under — see Scopes and products. No — talk to us if the scope is wrong.
404No such object for you. On exports this covers an id that never existed, one that has expired, and one that belongs to another client — deliberately the same answer, so the API cannot be used to discover that someone else's export exists. No.
409The request is valid but the object is not in a state that allows it: downloading an export that is still running, or creating one while you already have the maximum number in flight. Yes — once the state changes.
413The request is inside your contract but over a size ceiling. The body names the ceiling and what you asked for, so you can split the job. We never silently return a smaller answer than you asked for. No — send a smaller request.
429Per-class limit or account quota exceeded. The response carries Retry-After in seconds. Yes — after Retry-After.

The shape of an error body. There is exactly one field on every error: error, and it is a human-readable sentence, not an enum ({"error": "bad numeric parameter: asof (NaN/inf)"}). There is no message field — a client branching on one will read undefined on every failure. Branch on the HTTP status, and on the machine-readable extras the body carries where the status alone is not enough:

WhereExtra fields
429class (the call class you exhausted), limited_by, retry_after (seconds, and the same value is in the Retry-After header), usage
403, sport outside your subscriptionsports — the ones you do hold
403, history deeper than your planyour_plan_days, requested_days_back, and on /v1/line-events also earliest_allowed_since — the epoch second to clamp your cursor to
403, class or feature not in your planclass. The class is closed, not exhausted — see Rate limits
403, product not on your keyhint — the product you would need, by name. See Scopes and products
400 on ?asof=hint when event_id is missing; server_ts when the instant is in the future
413the ceiling and what you asked for, named per endpoint (max_events, max_legs, used_bytes, …)
400often a hint, and on /v1/score also bets_without_event_id and reason

A parameter present but empty is a 400, not an omission

This is the most common integration error we see, and it is worth stating on its own because it never comes from reading the reference — it comes from a template or a string-built URL. ?league=, with nothing after the =, is not the same as leaving league out. The parameter is present and its value is empty, and rather than guess which of the two you meant we refuse the call and say so:

curl "https://api.foreline.io/v1/surface?league=" \
  -H "X-Foreline-Key: $FORELINE_KEY"
→ 400 { "error": "league was given with an empty value",
        "hint": "omit the parameter entirely, or give it a value — an empty one is
                 almost always a broken variable substitution" }

The error names the offending parameter, so the fix is usually one grep away in your client: an unset variable interpolated into the query string, an optional filter that serialises as "" instead of being dropped, or an HTTP library that keeps keys whose value is None / null. Filter empty values out where you build the URL, and the whole class of error disappears.

Which parameters it applies to. Every parameter the endpoint recognises — there is no exempt list, and it holds for filters, ids, cursors and page sizes alike. The table below is about this check only: it says which names are recognised well enough to be refused when empty, not which filters a given endpoint honours — for that, read the endpoint's own parameter table.

EndpointParameters that answer 400 when empty
GET /v1/surface event_id, asof, since, limit, league, league_id, sport, hours
GET /v1/catalog/events sport, league, league_id, limit, hours
GET /v1/catalog/leagues sport, league
GET /v1/line-events event_id, since, limit
GET /v1/alerts event_id, since, limit, status
GET /v1/stream since, event_id, sport

A parameter we do not recognise is a different matter and follows the ordinary rule for unknown fields: it is ignored, empty or not, and the call is answered normally. So ?league= is a 400 while a misspelt ?leage= is silently dropped — which is worth knowing, because a typo in a filter name returns 200 with an unfiltered answer rather than an error. Check that a filter actually narrowed the result before trusting it.

This costs you nothing: like every other 400, it is refused before it reaches a class bucket — see Rate limits.

Two more statuses you can meet: 405 and 503 with retry_after — the latter means our client registry is unreadable, which is an outage on our side and not a problem with your key. A 405 carries the permitted methods twice, as an HTTP Allow header and as an allow field in the JSON body, always identical. Read-only endpoints answer Allow: GET, HEAD, OPTIONS — including /v1/health and /v1/stream, so a POST to either is a 405, not a 404. POST /v1/exports/<id> is a 405 with Allow: GET, HEAD, DELETE, OPTIONS. Every error body is JSON and every response carries X-Foreline-Version.

Errors

Scopes and products: what a 403 is telling you

Your key carries a list of scopes, readable at GET /v1/usage under scopes. They are internal codes — p1 and p2 say nothing about what you bought — so a 403 now names the product instead, in the hint. This table is the translation, and it is the one to check before you write to support.

ScopeProductWhat it unlocks
surfaceFair-Price Surface /v1/catalog/*, /v1/surface in all four modes, /v1/line-events, /v1/stream and /v1/exports. The base of everything else.
p1Player Radar POST /v1/score.
p2Line Radar GET /v1/alerts and POST /v1/book-quotes.
parlayParlay Protection Every /v1/parlay/* endpoint.

GET /v1/usage needs no scope at all, which makes it the honest way to find out what a key holds without probing endpoints for 403s. Read scopes, sports and closed_classes there once at integration time and you will never meet one of these in production.

Rate limits

Call classes, shaped by the data cadence

The data changes every 30 seconds at its fastest, so polling faster returns the same bytes with the same updated_ts. The limits below are set around that, not around packaging.

ClassLimitEndpoints
bulk4 / min, 3 000 / day GET /v1/surface summary and ?since=, GET /v1/catalog/events, GET /v1/catalog/leagues. Both surface modes here are full scans, which is why the parameterless summary is not in point.
point60 / min, 30 000 / day GET /v1/surface?event_id= — and only that. One event out of a warm store, sized for a builder that reads a ladder per quote.
asof20 / min, 5 000 / day GET /v1/surface?asof=. Rebuilding a past instant reads the feed archive from disk, so it is an order of magnitude more expensive than point.
events12 / min, 10 000 / day GET /v1/line-events.
alerts60 / min, 40 000 / day GET /v1/alerts. This is the working loop of Line Radar — you post quotes and read the journal on the same cadence — so it is sized for it and kept out of events.
quotes4 / min, 4 000 / day POST /v1/book-quotes. One batch every 30 s is the recommendation, echoed as next_recommended_s on every response; 4/min is double that, so a retry after a network blip never costs you a cycle.
score120 / min, 60 000 / day POST /v1/score. Sized for a book that posts bets as it accepts them, one call per bet, on a peak weekend.
status60 / min, 20 000 / day Small service reads: GET /v1/usage, GET /v1/exports, GET / DELETE /v1/exports/<id>, GET /v1/parlay, GET / DELETE /v1/parlay/audits[/<id>]. Polling a job's status or re-reading a report you paid for is not rationed against your product calls.
stream4 connections / min, 100 / day GET /v1/stream. This limits how often you may connect, not messages and not how many streams you may hold. The count of simultaneous streams is a separate setting, stream_concurrent_max, reported next to this pair by GET /v1/usage — see the stream section for how the two fail differently.
export2 / min, 20 / day POST /v1/exports. The call is cheap; what it queues is not, and one worker serves every client — so the limit sits on the work, not on the request.
export_dl10 / min, 200 / day GET /v1/exports/<id>/download. Kept out of point on purpose: a burst of multi-megabyte downloads must not starve the budget your live surface calls run on.
parlay120 / min, 60 000 / day POST /v1/parlay/price without ?asof=. A live coupon is one matrix fit per event on a warm surface — single-digit milliseconds — so this is sized for a builder that calls it on every coupon it assembles.
parlay_asof4 / min, 400 / day POST /v1/parlay/price?asof= and POST /v1/parlay/audit. These rebuild the surface from the archive on disk: measured at 88 ms one hour back and 1.15 s three days back, per snapshot, and an audit batch is up to fifty coupons. Kept apart from parlay so an audit run never eats the budget your live builder runs on.
other30 / min, 5 000 / day The fallback for anything without a class of its own. Nothing you can call currently lands here; it exists so a new endpoint is rationed by default rather than unrationed.

That is the full set: GET /v1/health is the one endpoint outside it — it is answered before authentication, so it is charged to nothing.

  • Per-client quotas sit on top of the class limits: an rpm (requests per minute) and an rpd (requests per day) ceiling on the key, both readable from GET /v1/usage. The tighter of the two applies, and the 429 body says which one bit, in limited_by.
  • Class limits can be raised per contract. The numbers above are the defaults; your key's actual values are in limits on GET /v1/usage — read them there rather than hard-coding this table.
  • A class at [0, 0] is not a limit of zero — it is a service you did not buy. It is listed by name in closed_classes on GET /v1/usage, under a note that says it plainly: “these call classes are not included in your plan: their limit is [0, 0] and calls to them are rejected, not queued”. Such a call answers 403, saying the service is not in your plannot 429 quota exceeded with a retry_after, because there is no minute at which it starts working. Trial keys meet this on stream. Read closed_classes once at integration time and you will know which features to leave out of your client instead of discovering them as errors.
  • What a refusal costs: two meters, and they do not agree. Read this as one rule rather than status code by status code, because the two meters behave differently and most integration surprises come from conflating them.
    • The class bucket — the [rpm, rpd] pair of point, asof, parlay, export and the rest — is charged only for calls we actually answer. A 400 (malformed parameter, empty parameter value, bad body), a 403 (outside your contract: a sport, a product, a closed class, or a history depth you do not hold), a 405 (wrong method), a 409 (the object is not in a state that allows the call — an export still running, or one job too many in flight), a 410 (the file is gone) and a 413 (over a size ceiling) all return the unit to their class bucket. 409 and 410 are on that list deliberately: polling a running export is the normal way to use the API, and on a plan with a small export class four routine 409s must not cost you the day. A 404 is not refunded — work may have been done behind it. You can watch it two ways. Where the refusal still names a class — a 403 does — X-RateLimit-Remaining holds steady across a run of them instead of counting down. And the decisive test: a run of refusals longer than the class limit leaves the next genuine call answered normally rather than 429d, which it could not if the refusals had been charged. A 400 carries no X-RateLimit-* headers at all, for the reason given in the next bullet — it was charged to nothing.
    • The client-wide ceiling — the rpm / rpd on the key itself, reported as client_min / client_day by GET /v1/usagecounts requests, and it moves on every single one, refusals included: a 400, a 403 and a 404 each advance it exactly like a served call.
    In practice: a loop asking for a history depth or a sport you do not hold can no longer exhaust the budget your working calls run on, but it can still exhaust your key's overall ceiling and start collecting 429s. Changed: a 403 used to be charged to its class like a served call, and this page used to warn that a backtest would burn its as-of budget on refusals. It no longer does. Retrying a broken request is still never the fix — it is simply your key's ceiling it threatens now, not your class budget.
  • Every response that was charged to a class carries the X-RateLimit-* headers, including X-RateLimit-Class — the class this very call was charged to, plus the minute and day budgets of that class and of your key. If you are unsure which bucket a call falls in, read it there rather than inferring it. On a 429, X-RateLimit-Reset equals Retry-After.
  • Schedule off data_cadence_s. It is returned on every surface and line-event response and tells you when a new value can exist. Polling faster returns the same bytes with the same updated_ts.
  • The stream is not rate-limited per message — the class limits how often you may connect. If you need everything as it happens, hold the stream rather than raising your poll rate.
Glossary

Fields worth understanding before you build on them

TermMeaning
fair_marketThe de-vigged probability at the main line — the reference market's price with the margin removed by a documented methodology. It sits inside markets.<market>, not at the top level of a surface, and on football moneyline it is an array of three.
vig / vig_tierThe exact margin removed from the reference quote, as a decimal, and its band (low / mid / high). Alongside them, max_win and limit_tier carry the reference limit. Redistribution of raw quotes is prohibited under all plans.
band_ppThe 80% uncertainty interval of a rung as two offsets in percentage points, [low, high] — not one width, and not symmetric. [0.0, 0.0] on the quoted rung. Validated coverage is 78–80% at a nominal 80%. In batch exports the same value is split into the two columns band_lo_pp and band_hi_pp.
srcquote: the rung is derived from a price the reference actually quoted. table: the rung is extended from the ladder tables. Bands are typically wider on table rungs — as they should be.
data_cadence_sHow often the object you are reading can change: 30 seconds. Returned on surface and line-event responses so a poller can schedule off the data rather than off a guess. data_cadence_tiers_s alongside it breaks the figure out by distance to kick-off.
next_cursorThe paging cursor of the bulk delta, the line-event feed and the alert journal. Opaque: store it as a string, send it back in since, never parse or construct one. Cursors & paging.
next_sinceA plain epoch boundary returned next to next_cursor, kept for clients written before cursors existed. Not a paging cursor: it does not always advance, and a sync built on it loses rows or stalls. Measured cases are in Cursors & paging.
moreWhether another page exists behind this one. It, and never the row count, is the condition of a paging loop: a page filtered empty by the sports on your key still carries more: true and a valid cursor.
asofThe instant a surface is priced at. On /v1/surface it is epoch seconds only as a parameter and comes back as the same number; Parlay Protection takes either shape and always answers in ISO. Mixing the two is the single most common first-day 400Time formats has the full map.
ppPercentage points — an absolute difference between two probabilities, never a relative percentage.

← Back to the overview

Access

Request a trial key

Keys are issued per contract. Tell us which products, sports and history depth you need.

Email us

We usually reply within one business day. Pricing is quoted per scope — ask and we will send terms.

Request trial