Foreline/Docs /Integration guide

API · v1 · integration

Three 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.

Start with the catalog → Do the catalog first. Every other call takes our event_id, and there is no name matching to fall back on.
Event catalog & ids

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.

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

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

ParameterTypeDescription
sportstring 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”.
hoursnumber 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.
limitinteger Maximum rows returned. Default 2000, ceiling 20000.
formatstring 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_idinteger Restrict to one league by id — the filter to build on, since ids do not get rewritten upstream. Take it from /v1/catalog/leagues.
leaguestring 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 / totimestamp 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[]

FieldTypeDescription
event_idinteger Our identifier. Stable for the whole life of the event. This is the only key any other endpoint accepts.
sport, leaguestring Sport and league as we hold them. Display text: filter on the ids below instead.
home, awaystring 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.
startstimestamp 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.)
marketsarray 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_tsnumber When we last saw a tick for this event.
league_idinteger 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_idinteger Id of the sport (29 football, 4 basketball). Always present and always consistent with sport in the same row.
n_unnamedinteger 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_idinteger 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.

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

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.

WhereWhat 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_id and stays fully serviceable; the names may come back empty. The n_unnamed counter 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_id is 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 on league_id and sport_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 on GET /v1/surface and 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.
Player Radar

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.

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

Request body

FieldTypeDescription
account_idstring 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.
sportstring Optional; defaults to football.
bets[].event_idinteger Required. Our id, from the catalog.
bets[].marketstring 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[].linenumber 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[].side
bets[].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[].stakenumber Decimal odds the customer received and the stake in your accounting currency.
bets[].placed_attimestamp 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

FieldTypeDescription
acceptedintegerBets 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.
scorenumber 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

CodeMeaningWhat to do
duplicateThe 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_eventWe 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_scorableThe 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_marketMarket 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_playplaced_at is at or after kick-off. Expected — the score is a pre-match measure.
placed_at_in_futureplaced_at is ahead of our clock. The note says by how much. Almost always a timezone bug. Send UTC or epoch seconds.
bad_sideThe side / selection is not one this market takes, or neither field was sent. The note names what is allowed for that market.
bad_oddsThe price is not decimal odds in range — it must be above 1 and at most 10000. Convert fractional or American prices before sending.
bad_stakeThe stake is negative. Send the absolute stake.
bad_lineThe line is not a number. An unusual line is fine — it is repriced from the ladder. Only an unparseable one is rejected.
bad_rowThe 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:

ComponentCheckpoints (scored bets)What it measures
Closing-line value5, 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 consistency10, 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_k records 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.

Line Radar

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”.

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

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.

POST/v1/book-quotes optional · scope p2 · quotes · 4/min

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.

lineReads asFair p_homeWhy it moves that way
-0.50Home gives half a goal — home must win outright 0.4246The hardest of the three for home, so the lowest probability.
0.00Level — a draw voids the bet 0.5670Removing the half-goal burden lifts home by about 14 pp on this ladder.
+0.50Home receives half a goal — a draw wins 0.7015Higher 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

FieldDescription
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 codeMeaning
unknown_eventMissing or non-Foreline event_id, or an event with no priced surface right now.
unknown_marketMarket outside spread|totals|moneyline, or that event has no such market.
unknown_lineThe 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_priceA 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_subscriptionThat event's sport is not on your key.
bad_rowThe 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 withdrawn only when we see the corrected price.
  • alert_threshold_pp is 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_s are closed on that read as expired / reason: "ttl", stamped with the current instant. After onboarding or a long gap that can be a dozen records at once, each carrying stale_s — treat a stale_s far 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 200 on 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.
Do not let a failed read shorten a batch
The failure mode is quiet and it lands at the worst moment: a restart is exactly when lines are moving and you least want your own alert coverage thinned. Rows dropped before the request are invisible to us — accepted and rejected[] only ever describe what actually arrived.
Build the batch from your own state. You already know every price you have posted; that list is authoritative on your side and needs no call of ours to assemble. If you do have to read per row, retry the read — a restart window is seconds — and if it still fails, send your last cached value or skip the cycle entirely rather than posting a partial book as if it were complete. Cheap guard: assert 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.
Parlay Protection

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 in POST /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.
Staying in sync

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 feedsGET /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.

Do not sync on next_since
Every paged response also carries next_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.
Measured on our own acceptance run: a bulk delta at 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.
An empty page with more: true means keep going
Your key covers a subset of the sports we carry, and the sport filter is applied after a page is assembled. A page whose events all belong to a sport you do not hold arrives empty — with a valid next_cursor and more: true.
Treat 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-events as well as to ?asof=: resuming from a since further back than your contract allows answers 403 with your_plan_days, requested_days_back and earliest_allowed_since. Clamp your stored cursor to earliest_allowed_since and replay forward from there. Starting the feed from scratch with since=0 is never refused.
  • ?asof= on the surface takes epoch seconds, not ISO. Instants inside our live payloadsts, server_ts, next_since, updated_ts, the asof echo — are epoch seconds as JSON numbers; only calendar fields such as starts are ISO strings. An ISO value on /v1/surface?asof= comes back as 400 {"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 an event_id — on its own it is now 400 rather than a silent live answer — and an instant in the future is 400 {"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_asof and line_events files, and in CSV and JSONL alike, asof, updated_ts, ts and starts are all ISO-8601 UTC with Z"2026-08-17T00:00:00Z", never 1787011200. 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 omitting league: it answers 400 {"error": "league was given with an empty value"} with a hint, and it applies to every parameter we recognise — event_id, since, limit, asof, sport, league_id and 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 returns 200 with 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 same updated_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 the bulk class, so a catalog refresh in the middle of a sync loop competes with it — put the catalog job on a different minute.

Full API reference →

Access

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.

Email us

We usually reply within one business day.

Ask a question