Skip to content
← All insights

Settling bets exactly once when the feed lies

10 min readOptima

Bet settlement looks like the simplest part of a betting platform. The match ends, you know who won, you pay the winners.

It is in fact the part most likely to lose money, because settlement sits downstream of a data source you do not control and cannot trust. Result feeds duplicate messages. They send corrections minutes or hours later. They deliver out of order. Occasionally they are simply wrong, and a human has to intervene.

Every one of those is normal operation, not an exception. A settlement engine that assumes a clean single delivery per market will double-pay in its first month.

What the feed actually sends

A realistic sequence for one market looks less like “Match ended 2:1” and more like this:

14:02  MATCH_END   fixture=8821  market=FT_RESULT  home=2 away=1  rev=1
14:02  MATCH_END   fixture=8821  market=FT_RESULT  home=2 away=1  rev=1   <- duplicate
14:09  CORRECTION  fixture=8821  market=FT_RESULT  home=2 away=2  rev=2   <- goal awarded on review
14:11  MATCH_END   fixture=8821  market=FT_RESULT  home=2 away=1  rev=1   <- stale redelivery

Four messages, three distinct claims, and one of the later ones is older than what you already processed. Handle these naively and you pay out on a 2:1 result, pay out again on the duplicate, then pay out a third time on the corrected 2:2 without reversing anything.

Revision, not arrival order

The first design decision is to stop treating arrival order as meaningful. It is not, because it reflects network conditions rather than reality.

Revision-gated settlement flowFour messages arrive from a result feed for the same market. A conditional update accepts only those carrying a revision higher than the stored one. Revision 1 is accepted and settled. A duplicate of revision 1 and a later stale redelivery of revision 1 both update zero rows and are discarded. Revision 2, a correction, is accepted and produces a reversal of the revision 1 settlement followed by a new settlement at revision 2.FEEDCONDITIONAL WRITELEDGERWHERE revision< @revisionstale and duplicatemessages update 0 rows14:02 MATCH_ENDrev 1settle @ rev 114:02 duplicaterev 114:09 CORRECTIONrev 2reverse @ rev 1settle @ rev 214:11 MATCH_ENDrev 1 (stale)append only, never updatedUNIQUE (bet_id, revision,entry_type)
Two of the four messages are discarded by the WHERE clause rather than by any special-case handling. The correction travels the same path as the original, which is why there is no correction-specific branch to get wrong.

Every result carries a revision. Settlement acts on the highest revision it has seen for a market, and ignores anything lower:

CREATE TABLE market_results (
    fixture_id   BIGINT       NOT NULL,
    market_id    VARCHAR(64)  NOT NULL,
    revision     INT          NOT NULL,
    outcome      NVARCHAR(MAX) NOT NULL,
    received_at  DATETIME2    NOT NULL,
    CONSTRAINT pk_market_results PRIMARY KEY (fixture_id, market_id)
);

One row per market, holding the current best-known result. The update is conditional:

UPDATE market_results
   SET revision = @revision, outcome = @outcome, received_at = @now
 WHERE fixture_id = @fixture
   AND market_id  = @market
   AND revision   < @revision;   -- stale and duplicate messages update 0 rows

The stale redelivery at 14:11 carries rev=1 against a stored rev=2, matches no rows, and is discarded without any special-case code. The duplicate at 14:02 does the same. Both problems are solved by the WHERE clause rather than by handling.

If your provider does not supply a revision number, derive one. A monotonic sequence per market from the message timestamp works, provided you are willing to treat equal timestamps as duplicates.

Settlement as an append-only ledger

The second decision is that settlement never overwrites. A bet’s settled state is derived from a ledger of settlement events, not stored as a mutable column on the bet.

CREATE TABLE settlement_entries (
    id             BIGINT IDENTITY PRIMARY KEY,
    bet_id         BIGINT       NOT NULL,
    fixture_id     BIGINT       NOT NULL,
    market_id      VARCHAR(64)  NOT NULL,
    revision       INT          NOT NULL,
    entry_type     VARCHAR(20)  NOT NULL,  -- settle | reverse
    outcome        VARCHAR(20)  NOT NULL,  -- won | lost | void | half_won ...
    amount_minor   BIGINT       NOT NULL,
    created_at     DATETIME2    NOT NULL,
    CONSTRAINT uq_settlement UNIQUE (bet_id, revision, entry_type)
);

That unique constraint is what makes settlement idempotent. Replaying the same revision for the same bet violates it and is rejected by the database, not by a check in application code that races under concurrency.

A bet’s current state is the fold of its entries. That sounds more expensive than a column, and it is, marginally. But it means the answer to “why was this bet paid 340 EUR?” is a query rather than an archaeology exercise across application logs.

Corrections become reversals

Once settlement is append-only, a correction stops being a special case. It is a reversal of the prior revision followed by a settlement at the new one, in a single transaction:

public async Task ApplyResultAsync(MarketResult result)
{
    await using var tx = await db.BeginTransactionAsync(IsolationLevel.ReadCommitted);

    var updated = await db.ApplyResultIfNewerAsync(result);
    if (updated == 0) return;              // duplicate or stale: nothing to do

    var bets = await db.LoadOpenAndSettledBetsAsync(result.FixtureId, result.MarketId);

    foreach (var bet in bets)
    {
        var priorEntry = bet.LatestSettlement();

        if (priorEntry is not null && priorEntry.Revision < result.Revision)
            await db.AppendAsync(Reversal.Of(priorEntry, result.Revision));

        var outcome = grader.Grade(bet, result);
        await db.AppendAsync(Settlement.For(bet, result.Revision, outcome));
    }

    await tx.CommitAsync();
}

The interesting property is that this code path is identical for a first settlement, a duplicate, and a correction. There is no if (isCorrection) branch, which means there is no correction-specific bug waiting to be found. Corrections are exercised by the same tests as everything else.

Reversals against spent balances

Reversing a payout is straightforward when the money is still in the account. It frequently is not: the customer withdrew it, or staked it on something else, within the nine minutes between the original settlement and the correction.

There is no purely technical answer here, and this is where engineering has to stop and ask the business. The options are to allow a negative balance and recover it from future deposits, to absorb the loss, or to hold the payout for a settlement confidence window before releasing funds.

What matters technically is that the system must represent the situation rather than fail on it. A reversal that cannot complete needs to produce a flagged, actionable record, not an exception in a log and not a silently skipped entry. Somebody in operations has to be able to find it and work it.

The confidence window

The most effective mitigation is also the least technical: do not release funds instantly on markets where corrections are common.

Correction rates are not uniform. A full-time result in a top-tier league is near-final on the first message. A player-props market, a contested goalscorer, or anything in a competition with video review is materially more likely to be revised. Holding withdrawals on high-correction markets for a short window converts an expensive recovery problem into a brief delay for a small subset of bets.

This is a policy decision expressed as configuration, not a code change. Traders should be able to adjust the window per market type without a deployment.

Grading belongs in one place

A last point that is less about correctness than about being able to sleep.

The logic that turns a result into an outcome for a bet should exist exactly once, as a pure function of (bet, result). That covers handicaps, over/under lines, each-way terms, dead heat reduction and void conditions. No database access, no clock, no service calls.

public interface IGrader
{
    Outcome Grade(Bet bet, MarketResult result);
}

A pure function is exhaustively testable against historical fixtures. Every settlement dispute you ever have becomes a test case you can add in a minute and run in milliseconds. When the same rules are spread across a stored procedure, a service method and a nightly job, they will drift, and the drift shows up as customers being paid different amounts for equivalent bets.

The shape of the thing

Four decisions carry nearly all the weight:

  1. Act on revision, not arrival order. Duplicates and stale redeliveries stop existing as a category of problem.
  2. Append settlement entries, never update them. Corrections become reversals, and the audit trail is a by-product rather than an additional feature.
  3. Let a unique constraint enforce exactly-once. The database is better at this than your application code, particularly under the concurrency a kick-off produces.
  4. Keep grading pure. It is the part most likely to be wrong and the part easiest to test properly.

None of this is exotic. It is mostly the discipline of assuming your inputs are unreliable, which for a result feed is not pessimism. It is an accurate description of Tuesday.

Working on something similar?

Tell us what you are building. We will come back with an honest view of scope, approach and timeline.

We reply within one business day.