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.
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:
- Act on revision, not arrival order. Duplicates and stale redeliveries stop existing as a category of problem.
- Append settlement entries, never update them. Corrections become reversals, and the audit trail is a by-product rather than an additional feature.
- 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.
- 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.