Foreline/Pricing Docs/API reference
API · v1 · referenceEndpoints, 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.
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=answers400 {"error": "league was given with an empty value"}with ahint; 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 theasofechoed by/v1/surface) is epoch seconds as a JSON number; the one calendar field isstarts, an ISO-8601 UTC string with theZsuffix, on every endpoint that returns it (as is theasofecho 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 withZ—asofandupdated_tsincluded, 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 insinceand keep going whilemoreistrue. The numericnext_sincein 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_idis 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_idis 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/eventsand map it to your own ids once; see the integration guide. Alongside it we returnleague_idandsport_id. Those are for filtering, never for matching: build your league and sport filters on them rather than on theleaguestring, 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 byevent_idand 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
v1changes 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/streamwhich istext/event-stream.
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.
| Where | Shape | Example |
|---|---|---|
?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:16325959671785448664.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 file —
asof 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.
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 havemore: 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_sinceis 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 leavesnext_sincewhere it was. Two measured consequences, on our own acceptance run: a bulk delta atlimit=2returnednext_since: 0.0on every page and the loop repeated the same two rows forever, and a full catch-up of the line-event feed driven bynext_sinceread 231 of the 285 rows the feed held — 54 rows silently lost, where the same catch-up driven bynext_cursorread 285 of 285 with no duplicates.limitis 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 boundarynext_sincedoes — 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.
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 family | Accepted side / selection | line |
|---|---|---|
| 1X2 / moneyline | home, draw, away |
Not applicable — /v1/parlay/price rejects a coupon that carries one
(line is not applicable to market 1x2). |
| Totals | over, under |
Required. Steps of 0.25. |
| Asian handicap / spread | home, 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.
| Market | Every accepted spelling | Echoed 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.
/v1/score — as on the surface ladder and
on POST /v1/book-quotes — line 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./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.{"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.Health
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.
Catalog
The list of events we serve, with our ids. Everything else in this reference
takes an event_id from here.
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
| Parameter | Type | Description |
|---|---|---|
sport | string | 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. |
hours | number | 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. |
limit | integer | Rows returned. Default 2000, ceiling 20000 — above the
ceiling the page is truncated, not rejected. limit=0 or a negative
value is 400. |
format | string | 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
pipe — moneyline|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_id | integer | Exact league id, as returned by
/v1/catalog/leagues. This is the stable filter
and the one to build on. |
league | string | 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 / to | timestamp | 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.
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.
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.
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.
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
| Parameter | Type | Mode | Description |
|---|---|---|---|
event_id | integer | point · 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. |
asof | epoch seconds | asof · 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. |
since | cursor | bulk · 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. |
limit | integer | — | 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 sent | Answer | Body |
|---|---|---|
?asof= with no event_id | 400 |
{"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 clock | 400 |
{"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 plan | 403 |
{"error": "as-of depth is limited to N days on your plan",
"your_plan_days": N, "requested_days_back": …}. |
| An ISO-8601 string | 400 |
{"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.
| Field | Type | Description |
|---|---|---|
event_id | integer | Foreline event identifier, e.g. 1632432003. Never a string. |
sport | string | football (further sports appear here as they launch). |
league, home, away | string | Display text from the fixture card; null until the card is in. Never a matching key. |
starts | string | Kick-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_id | integer | Filtering ids, the same ones the catalog returns. league_id may be null. |
markets | object | Keyed by market name: spread, totals, moneyline. A market the reference is not quoting right now is simply absent. |
markets.spread / .totals | object | main_line, fair_market, vig, vig_tier, max_win, limit_tier, updated_ts, rungs[]. |
markets.moneyline | object | Football: 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_s | integer | The fastest refresh interval in force right now: 30. Poll no faster than this and you will not miss a change. |
data_cadence_tiers_s | object | The 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_ts | number | Per 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
| Field | Type | Description |
|---|---|---|
main_line | number | The line the reference market currently treats as main. |
fair_market | number | De-vigged fair probability at the main line — p_home on spread, p_over on totals. On football moneyline it is an array of three. |
vig | number | Margin 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_tier | string | Band of that margin: low / mid / high. |
max_win / limit_tier | number / string | The maximum win the reference quote carries, and its band. Redistribution of raw quotes is prohibited under all plans. |
rungs[] | array | The ladder. See below. |
Response — rungs[]
| Field | Type | Description |
|---|---|---|
line | number | The handicap or total this rung prices. |
p_home | number | On spread: fair probability that home covers this line. null on totals rungs. |
p_over | number | On totals: fair probability of the over at this line. null on spread rungs. |
band_pp | array of two, or null | The 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. |
src | string | quote — derived from a price the reference market actually quoted. table — extended from the ladder tables because the reference does not quote this rung. |
monotonized | boolean | Present 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"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.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.[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 events
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
| Parameter | Type | Description |
|---|---|---|
since | cursor | 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. |
limit | integer | 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_id | integer | 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. |
/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
| Field | Type | Description |
|---|---|---|
events[] | array | The changes, oldest first. |
events[].ts | number | When 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_id | integer | The event whose line moved. |
events[].market | string | Market 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_line | number | Previous and new main line. |
more | boolean | There is at least one more page behind this one. Your loop condition — not the row count. |
next_cursor | string | The 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_since | number | Legacy 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. |
since | string | Echo of what you sent. |
data_cadence_s | integer | How 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.
Stream
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.
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.
Score
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
| Field | Type | Description |
|---|---|---|
account_id | string | Your anonymised account identifier. Opaque to us; used only to group bets. |
bets[] | array | The bets to score. |
sport | string | Optional, defaults to football. A sport outside your subscription is 403 with the list you hold. |
bets[].event_id | integer | Required. 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[].market | string | Required. 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[].sidebets[].selection | string | Required, 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[].line | number | The 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[].odds | number | Decimal odds the customer received. |
bets[].stake | number | Stake, in your accounting currency. |
bets[].placed_at | timestamp | When the bet was accepted. Pre-match only. |
bets[].bet_id | string | Optional, 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
| Field | Type | Description |
|---|---|---|
account_id | string | Echoed back. |
accepted | integer | Bets taken into the account on this call. |
rejected[] | array | Rows 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_pending | integer | Bets 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_note | string | Present 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_dropped | integer | Rows you sent, rows refused, rows refused specifically as duplicate, and rows dropped. These four are running totals for the account_id, not per-call counts — accepted 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. |
score | number | The combined account score — normalised closing-line value over scored bets. |
tstat | number | The magnitude component: how far the account beats our fair close, relative to its own dispersion. |
sign / sign_p | — | The consistency component: positive/total and its p-value against chance. |
flag / flag_k / flag_src | — | Whether 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. |
confidence | number | Continuous, 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_value | number | The raw evidence ratio behind confidence (confidence = 1 − 1/max(1, e_value)). Use it directly if you prefer thresholds like “20:1”. |
confidence_at_bet | integer | The first scored bet at which confidence reached 0.95, or null if it has not. |
bet_signals[] | array | Not 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.
code | What it means | Fix |
|---|---|---|
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.
Alerts
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
| Parameter | Type | Description |
|---|---|---|
since | cursor | 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. |
limit | integer | Page size. Default 500, capped at 2000. |
event_id | integer | 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. |
status | string | 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:
| Field | Type | Description |
|---|---|---|
status | string | 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. |
ts | number | 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, line | integer / 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. |
book | string | Which book the price came from; for your own quotes it is client:<your id>. |
side / sport | string | The side the headline gap_pp is measured on (home, draw, away, over, under) and the sport of the event. |
fair / client | number | Our fair probability at that rung and the probability implied by your price, after removing your margin. |
gap_pp | number | client − 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_pp | array of two, or null | The 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_pp | string / string / object | Which way your price is off, which side of the market carries the exposure, and the gap on each side. |
book_price / fair_price / book_overround | number | Your decimal price, the decimal price our fair probability implies, and the overround measured on the quotes you sent. |
main_line | number or null | The market's main line at the time. null on moneyline, which has no line. |
fair_src | string | Where 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_ts | number | When that fair value was computed. Epoch seconds. |
limit / priority | number or null | The 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_s | string / number / number | On 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_note | number / string | The threshold applied to your key, restated on every record, with its disclaimer. |
note | string | Human-readable remark; empty on an ordinary record. |
nulls, by constructionmarket: "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.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.
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.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.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.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".
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.
| Field | Type | Description |
|---|---|---|
kind | string | Required. surface_asof — the full ladder of every event on a grid of past instants. line_events — every main-line change in the window. |
from / to | timestamp | Required. 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[] | array | Foreline ids from /v1/catalog/events. Give exactly one selector — this, league_id or league. |
league_id | integer | League 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. |
league | string | League 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_s | number | surface_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. |
format | string | csv (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.
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.
Job status. Poll this; a job takes minutes, so once every 10–30 s is plenty.
| Field | Type | Description |
|---|---|---|
status | string | queued → running → done, or failed. A job never ends silently: failed always carries error. |
progress | number | 0.0–1.0, by events for surface_asof and by days for line_events. |
rows / bytes | integer | Rows written and the gzipped size on disk. |
truncated | boolean | true 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[] | array | The exact column order of the file — the CSV header, and the key order of each JSONL object. |
expires | timestamp | When the file is deleted. Default lifetime is 48 h from completion. |
download | string | Present once status is done. |
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"
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
| kind | One row is… | Columns |
|---|---|---|
surface_asof | one 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_events | one 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
| Ceiling | Default | Exceeded → |
|---|---|---|
| Snapshots per job (events × as-of points) | 2 400 | 413 with max_snapshots and requested_snapshots |
| Events per job | 200 | 413 with max_events |
| As-of points per job | 24 | 413 with max_points |
| Rows per file | 5 000 000 | job completes with truncated: true |
| Jobs in flight per client | 3 | 409 listing the active ids |
| Export storage per client | 2 GB | 413 with used_bytes |
| File lifetime | 48 h | file 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.
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.
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.
?asof= → parlay_asof · 4/minPrices 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.
| Field | Type | Description |
|---|---|---|
legs[] | array | Required. 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_id | integer | Required. 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[].market | string | Required. 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[].selectionlegs[].side | string | Required, 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[].line | number | Required 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. |
odds | number | Optional. The decimal price you gave. Present ⇒ the response adds edge_pp, margin_pp and expected_value. |
stake | number | Optional, default 1. Only used for expected_value. |
?asof= | timestamp | Query 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.
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.
| Field | Type | Description |
|---|---|---|
tickets[] | array | Required. 1 to 50 coupons per call. |
tickets[].legs[] | array | Required. Same leg schema as /v1/parlay/price. |
tickets[].odds | number | Required 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[].stake | number | Optional, 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_at | timestamp | When 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_id | string | Optional, echoed back. Defaults to the position in the batch. |
currency | string | Optional, 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.
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.
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.
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
| Served | selection | line | Note |
|---|---|---|---|
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, away | required, 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 served | Why 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.25line 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/surfaceshows 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 ashandicap_check_ppandhandicap_check_noteonGET /v1/parlay). Use/v1/surfacewhen you want the quoted handicap, and/v1/parlay/pricewhen 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: nulland a note. - No marginals means no price. If the reference has nothing for an event at the
requested instant, the coupon comes back
200withpriced: false, areasonandunpriced_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.
| Family | maxGap | Status |
|---|---|---|
| joint families overall | 2.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 & over | 4.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 families | 3.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
| Ceiling | Default | Exceeded → |
|---|---|---|
| Legs per coupon | 12 | 413 with max_legs and requested_legs |
| Legs on one event | 6 | 413 with max_legs_per_event |
| Coupons per audit batch | 50 | 413 with max_tickets |
| Line step | 0.25 | 400 with line_step |
| Pricing budget per batch | 25 s | the 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 reports | 500 | 413 with max_stored_audits; delete the ones you have read |
| Report lifetime | 30 days | the 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.
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.?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
| Status | When |
|---|---|
400 | Unknown 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. |
403 | No 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). |
404 | An audit_id that is not yours, malformed, expired or deleted — all four identical. |
405 | Wrong method; the response carries allow. |
413 | Any ceiling above, always with the number you sent and the number allowed. |
Usage
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.
Status codes
| Status | Meaning | Retry? |
|---|---|---|
401 | Missing, 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. |
403 | Authenticated, 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. |
404 | No 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. |
409 | The 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. |
413 | The 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. |
429 | Per-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:
| Where | Extra fields |
|---|---|
429 | class (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 subscription | sports — the ones you do hold |
403, history deeper than your plan | your_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 plan | class. The class is closed, not exhausted — see Rate limits |
403, product not on your key | hint — 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 |
413 | the ceiling and what you asked for, named per endpoint (max_events, max_legs, used_bytes, …) |
400 | often 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.
| Endpoint | Parameters 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.
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.
| Scope | Product | What it unlocks |
|---|---|---|
surface | Fair-Price Surface | /v1/catalog/*,
/v1/surface in all four modes,
/v1/line-events,
/v1/stream and
/v1/exports. The base of everything else. |
p1 | Player Radar | POST /v1/score. |
p2 | Line Radar | GET /v1/alerts and
POST /v1/book-quotes. |
parlay | Parlay 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.
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.
| Class | Limit | Endpoints |
|---|---|---|
| bulk | 4 / 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. |
| point | 60 / 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. |
| asof | 20 / 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. |
| events | 12 / min, 10 000 / day | GET /v1/line-events. |
| alerts | 60 / 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. |
| quotes | 4 / 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. |
| score | 120 / 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. |
| status | 60 / 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. |
| stream | 4 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. |
| export | 2 / 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_dl | 10 / 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. |
| parlay | 120 / 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_asof | 4 / 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. |
| other | 30 / 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 the429body says which one bit, inlimited_by. - Class limits can be raised per contract. The numbers above are the defaults; your
key's actual values are in
limitsonGET /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 inclosed_classesonGET /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 answers403, saying the service is not in your plan — not429 quota exceededwith aretry_after, because there is no minute at which it starts working. Trial keys meet this on stream. Readclosed_classesonce 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. A400(malformed parameter, empty parameter value, bad body), a403(outside your contract: a sport, a product, a closed class, or a history depth you do not hold), a405(wrong method), a409(the object is not in a state that allows the call — an export still running, or one job too many in flight), a410(the file is gone) and a413(over a size ceiling) all return the unit to their class bucket.409and410are 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 routine409s must not cost you the day. A404is not refunded — work may have been done behind it. You can watch it two ways. Where the refusal still names a class — a403does —X-RateLimit-Remainingholds 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 than429d, which it could not if the refusals had been charged. A400carries noX-RateLimit-*headers at all, for the reason given in the next bullet — it was charged to nothing. - The client-wide ceiling — the
rpm/rpdon the key itself, reported asclient_min/client_daybyGET /v1/usage— counts requests, and it moves on every single one, refusals included: a400, a403and a404each advance it exactly like a served call.
429s. Changed: a403used 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. - The class bucket — the
- Every response that was charged to a class carries the
X-RateLimit-*headers, includingX-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 a429,X-RateLimit-ResetequalsRetry-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 sameupdated_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.
Fields worth understanding before you build on them
| Term | Meaning |
|---|---|
fair_market | The 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_tier | The 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_pp | The 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. |
src | quote: 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_s | How 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_cursor | The 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_since | A 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. |
more | Whether 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. |
asof | The 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 400 — Time formats has the full map. |
| pp | Percentage points — an absolute difference between two probabilities, never a relative percentage. |
Request a trial key
Keys are issued per contract. Tell us which products, sports and history depth you need.
We usually reply within one business day. Pricing is quoted per scope — ask and we will send terms.
Foreline