Foreline/Docs /Integration guide
API · v1 · integrationThree products, one key, one identifier
This guide is the practical companion to the reference: what to send, in what order, and which mistakes cost you a day. Everything below assumes you have a key and have read the overview. Where the two documents can be read differently, the reference is the normative one — it lists every parameter, field and rate class, and this guide links into it rather than restating it.
event_id, and there is no name matching to fall back on.
Our identifiers, matched once, on your side
We do not guess which of your events one of ours is. You pull the catalog,
map it to your own ids once, and from then on every call carries our
event_id. That is a deliberate choice: a silent name match that is right 98% of
the time is a defect you discover in settlement, not in testing.
Every event we currently serve, with the fields you need to match it: our id, the sport, the league, both team names, kick-off, and which markets we price for it. JSON by default, CSV on request.
Parameters
| Parameter | Type | Description |
|---|---|---|
sport | string | Restrict to one sport on your key. A sport that is not on your key returns
403 with the list of sports you do hold — never an empty page that
could be mistaken for “no events”. |
hours | number | Kick-off window, 0 < hours ≤ 720. It is not forward-only:
the window runs from six hours ago to now + hours, so events already
under way come back too — the back edge is in live_window_s
(21600) on every response. This is still the parameter to build your
daily mapping job on; filter on starts if you want strictly
pre-match. |
limit | integer | Maximum rows returned. Default 2000, ceiling 20000. |
format | string | json (default) or csv. CSV comes with a header row and a
Content-Disposition filename, so it can go straight to a spreadsheet or a
matching script. |
league_id | integer | Restrict to one league by id — the filter to build on, since ids do not get
rewritten upstream. Take it from /v1/catalog/leagues. |
league | string | Restrict by league name, matched exactly, not as a substring:
?league=premier returns nothing and says so in
note_league. Sending it together with league_id is a
400. |
from / to | timestamp | Switches to the historical slice — the ids of events that have already finished,
which is how you address /v1/surface?asof=. Epoch seconds or ISO-8601
UTC, bounded by the as-of depth of your plan. Rows come back with
asof_only: true and without markets /
updated_ts. |
Response — events[]
| Field | Type | Description |
|---|---|---|
event_id | integer | Our identifier. Stable for the whole life of the event. This is the only key any other endpoint accepts. |
sport, league | string | Sport and league as we hold them. Display text: filter on the ids below instead. |
home, away | string | Team names as our reference names them — a matching aid, not an identifier. There is no team id, here or anywhere else in the API. |
starts | timestamp | Kick-off, ISO-8601 UTC with the Z suffix — the same shape here,
on a surface object, on a coupon leg and in an export file. Parse as UTC, never as
local time. (The surface used to omit the suffix while the catalog printed it; that
split is gone.) |
markets | array | What we actually price for this event: moneyline,
spread, totals. The catalog is never wider than the API — if
a market is listed here, GET /v1/surface?event_id= returns it. |
updated_ts | number | When we last saw a tick for this event. |
league_id | integer | Id of the league. Build your competition filter on this, not on the
league string, which the upstream feed may rewrite. null
on an event whose fixture card predates the field; it fills in when the card is
refreshed. |
sport_id | integer | Id of the sport (29 football, 4 basketball). Always
present and always consistent with sport in the same row. |
n_unnamed | integer | Response meta. How many rows in this page carry an id but no team names yet — see the note below. Zero on a normal pre-kick-off window. |
n_without_league_id | integer | Response meta. How many rows in this page have league_id: null,
so you can see at a glance how much of the page your league filter will not
classify. |
# the mapping job: everything kicking off in the next 48 hours, as CSV
curl -s "https://api.foreline.io/v1/catalog/events?hours=48&format=csv" \
-H "X-Foreline-Key: $FORELINE_KEY"
event_id,sport,league,home,away,starts,markets,updated_ts,league_id,sport_id
1632737351,football,UEFA - Champions League Qualifiers,Shamrock Rovers,Ararat-Armenia,2026-07-28T19:00:00Z,moneyline|spread|totals,1785262578.971,2632,29
# the same slice as JSON
curl -s "https://api.foreline.io/v1/catalog/events?hours=48" \
-H "X-Foreline-Key: $FORELINE_KEY"
In CSV, markets is pipe-joined
(moneyline|spread|totals) so a comma inside a league name never forces you to
parse quoting rules. Values in examples are illustrative.
The column order is stable and only ever grows to the right:
league_id and sport_id were appended after the first release, so a
script written against the earlier header keeps working and picks the new columns up when you
are ready for them. An empty CSV field is a null, which for
league_id means the fixture card predates the field rather than that the event
has no league.
The same slice collapsed to unique leagues, with an event count and the next
kick-off in each. Takes the same sport, hours and
format parameters. Use it to check coverage against your own competition list
before you map thousands of fixtures.
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 grouped by league name, so events always adds up to
the event catalog for the same slice. league_id is the id every event in that row
agrees on; where they disagree, or where no card carries an id yet, it is null
rather than a guess.
What happens when an id is not ours
Nothing is inferred and nothing is silently dropped. Every endpoint that takes an event rejects the row explicitly and tells you which one it was, so a bad mapping surfaces on the first call rather than in a reconciliation weeks later.
| Where | What you get back |
|---|---|
POST /v1/score |
rejected[] with unknown_event and the offending
event_id. The accepted count is returned separately, so
accepted + rejected always equals what you sent. |
POST /v1/book-quotes |
rejected[] with unknown_event, plus the index of the row
in your batch. The rest of the batch is still processed. |
GET /v1/surface?event_id= |
404 if we do not have that event, 403 if we do but it is
outside the sports on your key. |
- There is no resolve endpoint. We do not offer a “send us team names and we will find the event” helper in this release. A helper that is wrong some of the time moves our error into your settlement, and we are not prepared to publish one without measured accuracy figures alongside it.
- Pull the catalog before kick-off, not after. Team names and league come from the
fixture card, and a card can drop out of the live window once the match is under way. The row
keeps its
event_idand stays fully serviceable; the names may come back empty. Then_unnamedcounter in the response tells you how many rows in that page are affected, so schedule your mapping job on?hours=and it never sees them. - Our
event_idis an integer and it does not change when a line moves, a market is added, or the event is rescheduled. - Match on
event_id, filter onleague_idandsport_id. Those three are the only identifiers in the product. Everything else about an event — league, home, away — is display text, and text can be rewritten upstream without warning. If your integration compares any of those strings to decide which event or competition something is, that comparison is the part that will break. The same two ids come back onGET /v1/surfaceand in batch exports, so a league dictionary you build once from the catalog joins onto everything else by id. - There are no team ids, and this is upstream, not a choice. Our reference data
carries teams as names only — there is no team identifier for us to pass on, and inventing
one would mean publishing a mapping we cannot guarantee. So the team-level work is a one-off:
at onboarding you reconcile your team list against ours using the catalog, with a human
confirming the ambiguous cases. After that you never match teams again — you hold our
event_id, and a team renamed upstream cannot reach your integration. If you would rather not do even that once, ask us: we will do the reconciliation with you against your list before you go live.
Scoring accounts: POST /v1/score
You send bets an account took, addressed by our event_id; we
reprice each one against our fair close on the exact line taken and maintain a running score
for the account.
Request body
| Field | Type | Description |
|---|---|---|
account_id | string | Your own anonymised account key. Opaque to us — it is only used to group bets into one running score. No personal data is accepted or required. |
sport | string | Optional; defaults to football. |
bets[].event_id | integer | Required. Our id, from the catalog. |
bets[].market | string | Required. spread (also ah, handicap,
asian_handicap), totals (also total,
ou, over_under, over/under) or
moneyline (also 1x2, ml). Case does not matter.
This is the same dictionary /v1/parlay/price takes, so one
serialiser feeds both — the full list is in
Naming a leg. |
bets[].line | number | The line taken — any rung, not only the main one. On the handicap it is the home team's handicap, the same sign convention as the surface; see the worked examples. A parlay leg writes the handicap on the selected side instead, so convert if you feed both endpoints from one record. |
bets[].sidebets[].selection | string | Required, under either name — the two are synonyms.
home, draw, away, over,
under, per the market. Slip codes (1, X,
2) are not accepted. |
bets[].odds, bets[].stake | number | Decimal odds the customer received and the stake in your accounting currency. |
bets[].placed_at | timestamp | When you accepted the bet. Pre-match only — a bet timestamped after kick-off is
rejected as in_play. |
# 1. take an id from the catalog — the first event that has not kicked off yet.
# (the catalog window also reaches six hours back, so do not just take .events[0])
EVENT_ID=$(curl -s "https://api.foreline.io/v1/catalog/events?hours=48" \
-H "X-Foreline-Key: $FORELINE_KEY" \
| jq -r '[.events[] | select(.starts > (now|todate))][0].event_id')
# 2. score one bet on it, stamped now — which is before that kick-off
curl -s -X POST "https://api.foreline.io/v1/score" \
-H "X-Foreline-Key: $FORELINE_KEY" \
-H "Content-Type: application/json" \
-d '{"account_id": "a_7731",
"bets": [{"event_id": '$EVENT_ID', "market": "ah", "line": -0.25,
"side": "home", "odds": 1.95, "stake": 120,
"placed_at": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}]}'
→ 200 { "account_id": "a_7731", "accepted": 1, "rejected": [],
"n_bets": 1, "n_scored": 0, "n_pending": 1, "score": null, … }
Read that response before you conclude anything is wrong.
accepted: 1 with n_pending: 1 and score: null is the
correct answer for a bet posted at acceptance time: the bet is stored, and it is
waiting for its event to close, because the score is measured against the closing line.
Nothing is scored until then, and nothing needs re-sending. The example is written against a
live id on purpose — a bet posted against a hard-coded id copied from someone else's
documentation comes back unknown_event, which is a different problem with the
opposite fix: fix the id, do not wait and retry. See the reject codes below.
Response
| Field | Type | Description |
|---|---|---|
accepted | integer | Bets taken into the account. |
rejected[] | array | One entry per row we could not take, each with the row index i, a
code and a human-readable note. See the table below. |
n_bets / n_scored / n_pending |
integer | Seen, already scored, and waiting for their event to close. |
score | number | The running account score — normalised closing-line value across scored bets. |
tstat, sign, sign_p | — | The two components behind the score: magnitude (tstat) and
consistency (sign as positive/total with its p-value). |
flag, flag_k, flag_src | — | Whether the account is flagged, at which scored-bet count, and by which
component (clv, sign or both). |
bet_signals[] | array | Per-bet, when we hold a live forecast for that event and market: the expected closing-line value of the bet and whether it sits with our forecast. This is the stake-control signal — it exists at the moment of acceptance, before the account has a history. |
Reject codes
| Code | Meaning | What to do |
|---|---|---|
duplicate | The bet is already in this account, matched by
bet_id or by content. It was counted once and the retry changed
nothing. |
Nothing — replaying a batch is safe. n_duplicate in the response
tells you it happened. |
unknown_event | We hold no event card under that id. A number we never issued and a value that is not a number both land here — the id is not ours, whatever its shape. | Do not re-send. The id will not become ours later, so a retry queue only loops. Fix the mapping against the catalog. This is what a bet against a hard-coded id from someone else's documentation comes back as. |
event_not_scorable | The opposite case: the event is
ours, but no close exists to score against yet. It never covers an id we do not
hold — that is unknown_event above. |
Do re-send, after the event's market closes, or catch it in a nightly
batch. Do not expect it on a bet posted at acceptance time: a pre-match bet
on an event we hold is accepted and sits in n_pending until the
close. |
unknown_market | Market outside
spread|totals|moneyline and their aliases. |
Use a spelling from the dictionary. Near-misses
are not repaired: over_under works, over-under does
not. |
in_play | placed_at is at or after kick-off. |
Expected — the score is a pre-match measure. |
placed_at_in_future | placed_at is ahead of our
clock. The note says by how much. |
Almost always 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 for that market. |
bad_odds | The price is not decimal odds in range — it must
be above 1 and at most 10000. |
Convert fractional or American prices before sending. |
bad_stake | The stake is negative. | Send the absolute stake. |
bad_line | The line is not a number. |
An unusual line is fine — it is repriced from the ladder. Only an unparseable one is rejected. |
bad_row | The row is not an object, or
placed_at is missing or unparseable. |
Fix and re-send that row; the rest of the batch was processed. |
Checkpoints: when a flag can appear
The account is not re-judged on every bet. Two components are evaluated at fixed checkpoints, counted in scored bets, and a flag is raised the first time either one crosses its threshold:
| Component | Checkpoints (scored bets) | What it measures |
|---|---|---|
| Closing-line value | 5, 10, 20 | How far the account beats our fair close — the magnitude side, evaluated with its own dispersion so a single large outlier cannot carry it. |
| Sign consistency | 10, 20, 30, 50 | How often the account lands on the right side of the close, tested against chance. |
- Below 5 scored bets the score is informational. It is returned, and it is real, but no flag can be raised. Do not wire an automatic limitation to it.
- The flag is sticky.
flag_krecords the checkpoint at which it fired, so an account that clears the bar at 10 does not lose the flag at 20 — you keep the audit trail of why the decision was made. - Thresholds are set per contract during onboarding, so the score can be tuned to your appetite without changing a line of your integration. The false-positive budget behind them is stated in the reference.
Batch or live — pick per use case
Batch
Post an account's settled history in one call. This is the shape for onboarding an existing customer base and for the retro-audit.
⚠ A settled batch is slow the first time. A bet on an event that has
already closed is repriced from the feed archive on disk, not from memory. The call spends
at most 20 seconds on that work and returns what it has; whatever is left stays in
n_pending with a pending_note and is finished in the background.
Call again a minute later to collect the rest — nothing is lost and nothing is scored twice.
Re-reading the same account once it is warm takes milliseconds.
Live
Post each bet as you accept it. The account state updates immediately;
the closing-line component materialises after the event closes. A bet sent ahead of the close is accepted and
counted in n_pending — it is held for you and scored when the market closes,
so there is nothing to queue and nothing to re-send.
Both shapes reach the same state for the same bets. Sending the same bet
twice does not count it twice: the second copy is refused as duplicate
(matched by bet_id, or by content when you send none) and reported in
n_duplicate. Replaying a batch after a timeout is therefore safe — though
deduplicating on your side still saves the round trip.
Alerts, and the optional price feed
Line Radar works out of the box on our own detection: main-line moves and the alerts derived from them. Telling us your prices is a separate, optional step — it is what turns “the market moved” into “your price is off fair by this much”.
The alert journal — append-only, oldest first, paged by
since / next_cursor exactly like the other feeds
(Cursors & paging) and filterable by
status. Each record carries the state it puts the alert in
(active, revised, corrected, confirmed,
withdrawn, expired) under status, and its own write
time under ts; the lifecycle is read by following one
(event_id, market, line) key over successive records.
Alerts raised from your own prices are tagged to your key and are visible only to you —
another Line Radar subscriber never sees your book. Poll it on your own cadence or take the
same events over the stream. Field by field:
the reference.
This endpoint is a choice, not a requirement. You send the prices you
are currently showing; we de-vig them, compare each against fair at the same rung, and write
anything past your threshold into the same journal you already read from
GET /v1/alerts. Nothing about your book is inferred from third parties — if you
do not send prices, we do not have them.
Request body
{ "quotes": [
{ "event_id": 1632737351, "market": "spread", "line": -0.25,
"price_home": 1.95, "price_away": 1.95 },
{ "event_id": 1632737351, "market": "totals", "line": 2.75,
"price_over": 1.90, "price_under": 1.98 },
{ "event_id": 1632737351, "market": "1x2",
"price_home": 2.10, "price_draw": 3.40, "price_away": 3.60 }
] }
- All sides of a market are required. Your margin has to be removed before the comparison means anything, and that needs both (or all three) prices. A one-sided row is rejected.
- Prices are decimal odds, exactly as you show them. We never ask for your fair price or your margin — we derive the margin from the prices themselves.
- The line must exist on our ladder for that event and market: the main line plus the rungs we publish around it. A line we do not price is rejected rather than approximated.
The handicap sign convention — read this one twice
line on spread is the handicap of the home team, and
p_home rises as line rises — exactly as in
surface.markets.spread.rungs[].line. If your book stores handicaps against the
away team, or as a positive number attached to whichever side receives it, you must convert
before sending. This is the single most common integration defect on this endpoint.
line | Reads as | Fair p_home | Why it moves that way |
|---|---|---|---|
-0.50 | Home gives half a goal — home must win outright | 0.4246 | The hardest of the three for home, so the lowest probability. |
0.00 | Level — a draw voids the bet | 0.5670 | Removing the half-goal burden lifts home by about 14 pp on this ladder. |
+0.50 | Home receives half a goal — a draw wins | 0.7015 | Higher line, higher
p_home. Always. |
Worked conversion. Your trader posts “Ararat-Armenia −0.5” — the away
team giving half a goal. That is home receiving half a goal, so you send
line: +0.5, with price_home being the price on Shamrock Rovers and
price_away the price on Ararat-Armenia. Send it as -0.5 and we will
happily compare your price against a rung about 28 pp away and alert on a gap that does not
exist.
Sanity check before you go live: pull
GET /v1/surface?event_id= for one event, post the fair prices back as quotes, and
confirm the response contains no alerts. A correctly wired integration produces a zero gap on
its own numbers. Ladder values above are illustrative.
Response and reject codes
| Field | Description |
|---|---|
accepted / rejected[] |
Rows compared, and rows we would not compare — with the batch index
i, the code and a note. One bad row never fails the
batch. |
alerts[] / alerts_n |
Alerts raised by this batch, already written to the journal. Each carries
gap_pp, direction and exposed_side. |
next_recommended_s |
How long to wait before the next batch. Currently 30. |
alert_threshold_pp / alert_threshold_note |
The threshold applied to your key, and its disclaimer, returned on every response. |
snapshot_ts / server_ts |
When your last accepted batch was stored, and our clock now. |
| Reject code | Meaning |
|---|---|
unknown_event | Missing or non-Foreline
event_id, or an event with no priced surface right now. |
unknown_market | Market outside
spread|totals|moneyline, or that event has no such market. |
unknown_line | The line is not a rung on our ladder. The note carries the main line and the range we do publish, so you can see how far off you were. |
bad_price | A missing or non-numeric price, a price outside sane bounds, or a book sum that cannot be a real two- or three-way market. |
sport_not_in_subscription | That event's sport is not on your key. |
bad_row | The row is not an object. |
Cadence and the threshold
- Send a batch every 30 seconds. The underlying data moves every 30 seconds at
its fastest on events inside 24 h, so a tighter loop compares your prices against the same
fair numbers. The response tells you when to come back, in
next_recommended_s. The quota class allows four batches a minute — double the recommendation, so one retry after a network blip never pushes you out of your window. - One batch, many rows. Send your whole active book in a single POST rather than a request per quote; the batch limit is generous and the per-row rejects let you keep sending the rest while you fix a mapping.
- Nothing about a gap closing is lost. Send every active price each cycle, not only
the ones you changed: an alert whose gap has closed is resolved as
withdrawnonly when we see the corrected price. alert_threshold_ppis a setting, not a statistic. It defaults to 2.0 pp and that default is not statistically derived — it is calibrated against your own feed during onboarding, and the response repeats that disclaimer every time so nobody downstream mistakes it for a measured constant. Ask us to change it and it takes effect without a restart on your side.- The first read of the journal can arrive as a burst of closures. Reading also
settles the lifecycle: alerts left open without a confirming quote for longer than your
alert_ttl_sare closed on that read asexpired/reason: "ttl", stamped with the current instant. After onboarding or a long gap that can be a dozen records at once, each carryingstale_s— treat astale_sfar above your TTL as history settling, not as live events. What it looks like. - A batch assembled from live reads silently gets shorter when we restart. If your
cycle reads something from us per row before it posts — a surface point call per event, say
— then during a deploy or restart some of those reads fail while the POST that follows
succeeds. You get a clean
200on a batch that is short by exactly the rows you could not read, and nothing in the response can tell you: we never saw them. See the box below.
accepted and
rejected[] only ever describe what actually arrived.accepted + len(rejected) equals the row count you intended to send, and
log the difference; if the two disagree, the rows went missing on your side of the
wire.Two checks: one at quote time, one over history
Combination risk is not a feed you subscribe to and forget. It is a check you run before you return a price, and a periodic look back at what the ones you already returned actually cost you.
Per-quote check
At the moment a customer builds a combination, pull the marginals for its
legs with GET /v1/surface?event_id= — point class, 60 a minute, sized for
exactly this. Compare the price your builder is about to return against the fair marginals
and their bands. Legs from the same event are where a naive multiply-the-legs builder
leaks: outcome and goal volume are correlated in every league, and a joint price read off
one matrix is what keeps your combination consistent with the singles sitting next to it on
your own site.
Retro batch
Send a period of settled combination history and get back where the leakage actually sat — by family, by leg count, by league — rather than an aggregate margin that hides it. This is the same engine as the retro-audit, and it is how most customers start: a measurement before an integration.
- Address legs by our
event_id, exactly as everywhere else. A combination is only as well-matched as its worst leg. - Bands travel through. A combination built on
src: "table"rungs is visibly less certain than one built on quoted ones — read the joint number against its band rather than as a point estimate. - The known limitation is published, not buried: on the “home and over” family the model underprices the joint probability by 4.4–5.3 pp in the middle buckets, and the band is widened where the miss lives. See the product page before you take the number at face value on that family.
- Delivery is scoped by contract. Joint pricing arrives as an audit first and as a feed afterwards; the field specification for the retro batch is provided under NDA rather than published here.
- Give the historical calls a long client timeout — 30 s or more. A coupon priced
with
?asof=, and every ticket inPOST /v1/parlay/audit, is rebuilt from the feed archive rather than read from a cache: measured end to end at p50 2.1 s, p95 9.9 s, worst 18.0 s. The server has its own 25 s budget and always answers inside it, marking anything it could not reach rather than dropping it — a 10 s client timeout throws that answer away and makes the retry do the work twice. Keep the short timeout for the per-quote check, which is milliseconds. Details.
The cursor loop, and the empty page that is not the end
Keeping a local mirror is one call in a loop: GET /v1/surface?since= returns
every surface that changed after your cursor, in full, and hands back
next_cursor. Pass it back unchanged on the following call, and while
more is true, go straight to the next page instead of waiting for your
poll interval.
The same loop, unchanged, reads the other two feeds —
GET /v1/line-events for main-line changes and GET /v1/alerts for the
alert journal. Same since parameter, same next_cursor, same
more. Nothing else about a feed needs to be special-cased, and there is no second
way to page: the field to loop on is next_cursor, and only that field. The full
rules live in the reference, under
Cursors & paging.
next_sincenext_since, a plain epoch
number kept for clients written before cursors existed. It looks like a cursor and it is not
one: a whole polling batch shares a single instant, so when a page is cut inside that batch,
next_since stays where it was.limit=2
returned next_since: 0.0 on every page — a loop driven by it never advances —
and a full catch-up of the line-event feed read 231 of 285 rows by
next_since against 285 of 285, no duplicates, by
next_cursor. Read next_cursor, store it as an opaque string, send
it back in since.more: true means keep goingnext_cursor and more: true.more, never the row count, as your loop condition.
Stopping on an empty page parks your mirror on that cursor and it stops receiving updates.
The cursor always advances past a filtered page, by design — that is what stops you getting
stuck on it.- Never build a cursor yourself, and never parse one. It is a compound value whose shape differs per endpoint — the line-event feed already needs three components, because one event can move its handicap and its total inside the same tick. Store it as a string and pass back exactly what we returned.
- A cursor you stored months ago can now be refused. The history depth of your plan
applies to
/v1/line-eventsas well as to?asof=: resuming from asincefurther back than your contract allows answers403withyour_plan_days,requested_days_backandearliest_allowed_since. Clamp your stored cursor toearliest_allowed_sinceand replay forward from there. Starting the feed from scratch withsince=0is never refused. ?asof=on the surface takes epoch seconds, not ISO. Instants inside our live payloads —ts,server_ts,next_since,updated_ts, theasofecho — are epoch seconds as JSON numbers; only calendar fields such asstartsare ISO strings. An ISO value on/v1/surface?asof=comes back as400 {"error": "bad numeric parameter: asof"}, while the parlay endpoint accepts either. Convert once at the edge of your client and keep the numeric form inside it: which field is which shape. Two more rejections on that parameter: it must travel with anevent_id— on its own it is now400rather than a silent live answer — and an instant in the future is400 {"error": "asof is in the future"}once it is more than five seconds past our clock. That five-second allowance is deliberate, so that a client whose clock runs slightly fast still gets an answer; it is a tolerance for skew, not a window to build on, and inside it you are served the latest surface we hold rather than a future one.- Inside a downloaded export file, every timestamp is ISO — including the ones that are
epoch numbers live. This is the one exception to the rule above, and it bites loaders
written against the live endpoints. In both
surface_asofandline_eventsfiles, and in CSV and JSONL alike,asof,updated_ts,tsandstartsare all ISO-8601 UTC withZ—"2026-08-17T00:00:00Z", never1787011200. Parse every timestamp column in a file as ISO and the same loader reads both kinds. - A parameter present but empty is a
400.?league=is not the same as omittingleague: it answers400 {"error": "league was given with an empty value"}with ahint, and it applies to every parameter we recognise —event_id,since,limit,asof,sport,league_idand the rest. It is the most common integration error we see, and it is nearly always an unset variable interpolated into a URL or an optional filter that serialises as""instead of being dropped. Strip empty values where you build the query string. A parameter we do not recognise is ignored instead, so a misspelt filter name returns200with an unfiltered answer — assert that a filter narrowed the result before you trust it. - Schedule off
data_cadence_s, which is returned on surface and line-event responses, rather than off a fixed timer: it tells you when a new value can exist at all, so polling faster only re-reads the sameupdated_ts. - Refresh the catalog on its own schedule. Once a day covers a normal book; run it on
?hours=so new fixtures enter your mapping before anyone can bet on them — and remember that window also reaches six hours backwards, so it returns matches already under way. - Handle 429 by reading
Retry-After, not by backing off blindly. The catalog and the bulk delta share thebulkclass, so a catalog refresh in the middle of a sync loop competes with it — put the catalog job on a different minute.
Stuck on an integration?
Send us the request you posted and the response you got back. Reject codes are designed to be enough to answer in one reply.
Foreline