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.
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 | Only events kicking off within the next N hours. This is the parameter to build your daily mapping job on. |
limit | integer | Maximum rows returned. Default 2000. |
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. |
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. |
home, away | string | Team names as our reference names them — a matching aid, not an identifier. |
starts | timestamp | Kick-off, ISO-8601 UTC with a Z suffix. |
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. |
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. |
# 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.
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.
| 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.
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 | spread (aliases ah, handicap),
totals (ou, total) or moneyline
(1x2, ml). |
bets[].line | number | The line taken — any rung, not only the main one. Same sign convention as the surface; see the worked examples. |
bets[].side | string | home, away, over, under. |
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. |
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
| 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 |
|---|---|---|
unknown_event | No event_id, or not one of
ours. | Fix the mapping against the catalog. |
event_not_scorable | A 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_market | Market outside
spread|totals|moneyline. | Drop or map it on your side. |
in_play | placed_at is at or after kick-off. |
Expected — the score is a pre-match measure. |
bad_row | Malformed 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:
| 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. 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.
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, 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.
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–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
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.
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.
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.
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. 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 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