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.

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 Only events kicking off within the next N hours. This is the parameter to build your daily mapping job on.
limitinteger Maximum rows returned. Default 2000.
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.

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.
home, awaystring Team names as our reference names them — a matching aid, not an identifier.
startstimestamp Kick-off, ISO-8601 UTC with a Z suffix.
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.
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.
# 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
1632737351,football,UEFA - Champions League Qualifiers,Shamrock Rovers,Ararat-Armenia,2026-07-28T19:00:00Z,moneyline|spread|totals,1785262578.971

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

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
Argentina - Liga Pro,football,4,2026-07-28T22:00:00Z
USA - Major League Soccer,football,15,2026-07-29T00:00:00Z

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.
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 · point · 60/min

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 spread (aliases ah, handicap), totals (ou, total) or moneyline (1x2, ml).
bets[].linenumber The line taken — any rung, not only the main one. Same sign convention as the surface; see the worked examples.
bets[].sidestring home, away, over, under.
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.
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": 1632737351, "market": "ah", "line": -0.25,
                 "side": "home", "odds": 1.95, "stake": 120,
                 "placed_at": "2026-07-28T14:02:11Z"}]}'

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
unknown_eventNo event_id, or not one of ours.Fix the mapping against the catalog.
event_not_scorableA valid id, but the event has not closed yet, so there is no close to score against. Re-send the bet after the event settles. This is the normal answer for a bet you post at acceptance time.
unknown_marketMarket outside spread|totals|moneyline.Drop or map it on your side.
in_playplaced_at is at or after kick-off. Expected — the score is a pre-match measure.
bad_rowMalformed row or timestamp. 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. Every bet whose event has closed scores immediately, and you get the full picture — score, checkpoints, flag — in the same response. This is the shape for onboarding an existing customer base and for the retro-audit.

Live

Post each bet as you accept it. The account state updates immediately, and the bet_signals entry is available at once; the closing-line component materialises after the event closes. Bets sent ahead of the close come back as event_not_scorable — queue them and re-send, or run a nightly catch-up batch.

Both shapes reach the same state for the same bets. Sending a bet twice is counted twice, so deduplicate on your side before you post.

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 · events · 12/min

The alert journal, newest first, filterable with since and status. 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.

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–60 seconds. The underlying data moves once a minute at its fastest, so a tighter loop compares your prices against the same fair numbers. 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.
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–5 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.
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.

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. It is a compound value, and a clock on your side cannot reproduce it. Pass back exactly what we returned.
  • Schedule off data_cadence_s, which is returned on every object, rather than off a fixed timer — it tightens automatically as events approach kick-off.
  • 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.
  • 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