Grading Bets Programmatically: Scores, Settlement and Edge Cases
September 25, 2026

Pricing a bet is the easy half. Telling a user whether they won it is where most integrations get complicated, because the odds and the result usually arrive from two different places.
The conventional setup is an odds feed from one vendor and a results or stats feed from another, then a reconciliation layer that matches them on team names, start times and player identities. That layer is where the bugs live. A player traded mid-season, a postponed fixture, a book that spells a team differently — each one breaks the join, and the symptom is a bet that never settles.
If the result arrives on the same object as the price, that entire layer disappears. This post covers how to do that: which fields decide whether a market can be graded, the status field people grade on by mistake, and the cases that settle silently wrong.
Everything grading needs is on the odd
Request an event and each entry in odds carries its own settlement state alongside its price:
{
"oddID": "passing_yards-JOSH_ALLEN_1_NFL-game-ou-over",
"opposingOddID": "passing_yards-JOSH_ALLEN_1_NFL-game-ou-under",
"statID": "passing_yards",
"statEntityID": "JOSH_ALLEN_1_NFL",
"periodID": "game",
"betTypeID": "ou",
"sideID": "over",
"started": true,
"ended": true,
"cancelled": false,
"scoringSupported": true,
"score": 248,
"fairOverUnder": "231",
"bookOverUnder": "224.5"
}
Four fields do the work:
| Field | What it tells you |
|---|---|
scoringSupported | Whether this market can be graded at all |
started / ended | Whether the bet's period has begun and finished |
cancelled | Whether this specific market was voided |
score | The actual result value, once available |
No second request, no name matching, no separate results product.
Check scoringSupported before anything else
Not every market can be settled from the data. scoringSupported is a boolean on each odd, and it is the first thing your grader should read.
When it is false, there is no score coming and no amount of waiting will produce one. Treat that as a known state and route those bets to whatever you do for markets you cannot auto-settle — manual review, void, or excluding them from your product in the first place. What you must not do is leave them pending forever, which is what a grader that only looks at ended will do.
Reading it up front also tells you something useful at build time: if a market category you were planning a feature around comes back with scoringSupported: false, you have found that out before you shipped the feature.
ended is not finalized
This is the mistake that costs the most.
There are two levels of status. Each odd has started and ended, which describe the odd's own period. The event has a status object describing the whole game:
"status": {
"started": true,
"live": false,
"ended": true,
"completed": true,
"cancelled": false,
"delayed": false,
"finalized": true,
"currentPeriodID": "",
"periods": { "started": ["1q","2q","3q","4q"], "ended": ["1q","2q","3q","4q"] }
}
ended means play has stopped. finalized means the result is locked. Those are not the same moment, and the window between them is where stats get corrected — a reception reassigned, a yardage adjustment, a review that moves a touchdown.
Grade a player prop on ended and you will settle some bets on a number that changes ten minutes later. Grade on status.finalized and you settle once, correctly.
The practical rule: use ended on the odd to know a bet is no longer live, and status.finalized on the event to know it is safe to pay out. If you show users provisional results, read the first and label it as provisional; if you move money, wait for the second.
A grader, roughly
def grade(odd, event):
if odd.get("cancelled") or event["status"].get("cancelled"):
return "void"
if not odd.get("scoringSupported"):
return "manual"
if not odd.get("ended"):
return "pending"
if not event["status"].get("finalized"):
return "provisional"
score = odd.get("score")
if score is None:
return "pending"
bet_type, side = odd["betTypeID"], odd["sideID"]
if bet_type == "ou":
line = float(odd["bookOverUnder"])
if score == line:
return "push"
won = score > line if side == "over" else score < line
return "won" if won else "lost"
# sp and ml follow the same shape: compare score against the line
# (or against the opposing side's score) and branch on sideID
...
Two things about that sketch. The order of the guards matters — cancelled before scoring support, scoring support before ended, ended before finalized. Reversing any pair produces a bet stuck in the wrong state.
And the push check needs the line you actually took, not the consensus. bookOverUnder is the consensus line; the number your user was given came from a specific book, so store the overUnder from that book's byBookmaker entry at bet time and grade against that. A user who took 224.5 and a user who took 225 on the same market do not both push on 225.
Period markets settle early
A first-half bet is done at half time, not at the final whistle. The event's status tracks this:
"periods": { "started": ["1h","2h"], "ended": ["1h"] },
"currentPeriodID": "2h"
Read periodID on the odd, then check whether that period appears in status.periods.ended. A 1h market whose period has ended is settleable while the game is still live — which matters if your product tells users where they stand mid-game rather than only afterwards.
The periods reference lists every periodID, and it carries a warning worth repeating: full-game points are always present by the time an event finalizes, but breakdown periods appear most of the time rather than always. Null-check whichever period you depend on.
The three-way trap
This one mis-settles quietly, and only on soccer.
A two-outcome grader assumes every market has a winner and a loser. Three-way moneylines do not. The ml3way bet type carries six sides:
| sideID | Wins when | Opposite side |
|---|---|---|
home | Home wins | away+draw |
away | Away wins | home+draw |
draw | The match is drawn | not_draw |
home+draw | Home wins or draws | away |
away+draw | Away wins or draws | home |
not_draw | Any result except a draw | draw |
Look at the right-hand column. The opposite of home is away+draw, not away. A grader that pairs home against away will settle both as losers on a drawn match, which is wrong twice over on the same fixture.
This is also why you should follow opposingOddID rather than constructing the opposite oddID yourself. You would not guess away+draw. The same pairing matters when you are pricing rather than grading, which we cover in why devigging one book isn't a fair price.
The Premier League page shows where three-way lines appear in soccer coverage. They sit on the reg period rather than game, because regulation-time result and final result are different propositions once extra time exists.
Cancelled, postponed, and the difference
cancelled appears in two places and means two things. On the event, the fixture is off. On an individual odd, that market was voided while the rest of the event stands — a player scratched from the lineup voids their props without touching the game lines.
Grade both. A grader that only checks event-level cancellation will leave a scratched player's props pending until someone notices.
Postponement is a separate problem, and a more annoying one: a rescheduled fixture arrives as a new event rather than a revived one, so a bet stored against the original event ID will never settle against the new game. We wrote that up separately in postponed, delayed and cancelled events, including how to spot the pairing.
Why this is one integration rather than two
The argument for settling from the same feed is not that it is cheaper, though it is. It is that there is no join to get wrong.
When odds and results come from separate vendors you are matching on strings — team names, player names, timestamps — and every one of those is a place where a mid-season trade or a different spelling breaks a settlement silently. Here the oddID that priced the bet is the oddID that carries the score. The key is the same on both sides because there is only one side.
Scores and settlement data ship on every plan, including the free tier, so you can build and test a full grading path before paying anything. Bet tracking and settlement covers the wider workflow, the events endpoint covers the request itself, and the odds data type reference lists every field used above.
Frequently asked questions
How do I know whether a market can be graded?
What is the difference between ended and finalized?
Do I need a separate results or stats API to settle bets?
Which line should I grade an over/under against?
How do I settle a first-half or quarter bet before the game ends?
Why do three-way soccer markets settle wrong?
What happens to bets on a postponed game?
Can a single market be voided without the whole event?
Price it and settle it from one feed
Odds, scores and settlement state on the same oddID, on every plan including the free tier.