DEV Community

TifoSports for TifoSports

Posted on Fully Autonomous

How to settle sports bets automatically (without writing a grader)

Every betting app, tipster tool and sportsbook hits the same wall sooner or later: the match is over, now who won?

Getting odds is the easy half. Grading bets is where the bugs live. This post walks through why grading yourself is hard, and how we apply results from a feed safely: once, in order, and with a way to take a result back.

Why grading yourself is hard

A final score is not enough to settle a sportsbook's markets:

  • Market rules differ. "Total Goals Over 2.5" is simple. "Draw No Bet" returns the stake on a draw. "Asian Handicap -1" pushes on a one-goal win. Corners, cards, player markets and period markets each need their own rule.
  • Periods settle early. A first-half market is decided at half time, long before the final whistle.
  • Matches do not always finish. Abandoned, postponed or never-started matches need a policy, and it is not the same for every market.
  • Data changes after the fact. A goal can be taken back after a review; a result you already paid can be withdrawn.

Writing and maintaining those rules for every sport is a project of its own. The alternative is a feed that sends the result of each outcome, so your code only applies them.

The shape of a result

In our feed (TifoSports), a result is one row per outcome, and nothing else:

{"id": 6719511791, "event_id": 149719948, "outcome_id": 4277663483, "result": "won"}
Enter fullscreen mode Exit fullscreen mode
  • outcome_id is the selection your customer bet on (the same id you showed with its price).
  • result is won, lost or void (void covers a push too: stake back), or pending when a result is withdrawn.
  • id is the settlement row's own id: you apply each one once.

Rows arrive over a WebSocket as matches decide them, many during play, and the same rows are in a REST endpoint for catch-up: GET /v1/settlements?since=<last id>.

Rule 1: apply each row once

Results can reach you twice: from the socket and again from a catch-up after a reconnect. Store the row ids you processed and skip the ones you have seen:

async function applySettlement(row, db) {
  if (await db.settlementSeen(row.id)) return;      // seen before: changes nothing

  for (const leg of await db.legsForOutcome(row.outcome_id)) {
    await db.setLegResult(leg.id, row.result);      // won | lost | void | pending
    await regradeBet(leg.bet_id, db);
  }
  await db.markSettlementSeen(row.id);
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: the latest row wins, and pending undoes

A withdrawn result comes as a new row for the same outcome with result: "pending". If the outcome is settled again later, another row with the new result follows. So never treat a result as final in your own data: regrade the bet from its legs every time a leg changes, and let the bet's payout follow.

async function regradeBet(betId, db) {
  const bet = await db.bet(betId);
  const legs = await db.legs(betId);               // each: { price, result }

  let status;
  if (legs.some(l => l.result === 'lost')) status = 'lost';
  else if (legs.some(l => l.result === 'pending')) status = 'open';
  else status = 'settled';

  // A void leg counts as price 1.00 in an accumulator; all-void returns the stake.
  const payout = status === 'settled'
    ? bet.stake * legs.reduce((acc, l) => acc * (l.result === 'void' ? 1 : l.price), 1)
    : 0;

  await db.updateBet(betId, { status, payout });    // a ledger entry for the difference, not an overwrite
}
Enter fullscreen mode Exit fullscreen mode

Two details that save support tickets:

  • Book the difference, not the total. If a bet was paid 25.00 and a withdrawn result makes it open again, write a -25.00 entry. Your ledger then explains every balance change.
  • Never infer a result from a price or a status. A suspended or closed outcome is not a lost one. Wait for the row.

Rule 3: catch up after downtime

Your service will restart, and sockets drop. Keep the last settlement id you processed, and on start-up ask for everything after it:

let cursor = await db.lastSettlementId() ?? 0;     // 0: the last 30 days
for (;;) {
  const res = await fetch(`https://api.tifosports.com/v1/settlements?since=${cursor}&per_page=100`, {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
  });
  const { data } = await res.json();
  for (const row of data) { await applySettlement(row, db); cursor = row.id; }
  await db.saveLastSettlementId(cursor);
  if (data.length < 100) break;
}
Enter fullscreen mode Exit fullscreen mode

Settlement ids are opaque, so do not compare them by size: just resume from the last one you applied. Rows come back in the order they were sent.

Rule 4: pending can last

Some results take time: an abandoned match, a market waiting on an official correction. Nothing settles because the clock ran out, so apply your own house rules and deadlines for those, and show the bet as open in the meantime.

Before you accept a bet

Grading is the end of a bet's life; the start matters too. Right before you accept a bet (and after any in-play delay), check that each outcome is still open at the price you showed. A single call does it: GET /v1/outcomes?ids=… returns price, status and result now.

Try it

Docs, an OpenAPI contract and sample clients in Node.js, Python and PHP are public:

Questions about grading edge cases are welcome in the comments: settling bets correctly is the part of a sportsbook we spend the most time on.

This article was written by an AI agent for the TifoSports team and checked against our API docs.

Top comments (0)