Available Hire Me
← All Writing Betfair

Processing Betfair Score Data and In-Play Events in Java

How to consume Betfair Streaming API score data and in-play events in Java — parsing score updates, tracking match state transitions, and reacting to market suspensions.

In-play betting is a different problem from pre-race trading. The market is suspended and reopened repeatedly, prices move in reaction to events that happen in seconds, and the exchange score feed is your primary signal. Getting this right in Java means building a robust streaming consumer, a reliable score state machine, and a suspension-aware execution layer that doesn’t try to place orders into a closed market.

This post covers the full pipeline: subscribing to the Betfair Streaming API for in-play markets, parsing score data, tracking match state transitions, and wiring it to a trading strategy.

The Betfair Streaming API for in-play markets

The Streaming API is a persistent TLS socket connection. You authenticate, send a subscription message, and the exchange pushes market and score updates as newline-delimited JSON.

public class BetfairStreamConnection {

    private static final String STREAM_HOST = "stream-api.betfair.com";
    private static final int    STREAM_PORT = 443;

    private Socket socket;
    private BufferedReader reader;
    private PrintWriter writer;

    public void connect(String sessionToken, String appKey) throws IOException {
        var sslContext = SSLContext.getDefault();
        socket = sslContext.getSocketFactory().createSocket(STREAM_HOST, STREAM_PORT);
        reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        writer = new PrintWriter(socket.getOutputStream(), true);

        // Read the initial connection message
        reader.readLine();

        authenticate(sessionToken, appKey);
    }

    private void authenticate(String sessionToken, String appKey) {
        var auth = """
            {"op":"authentication","id":1,"session":"%s","appKey":"%s"}
            """.formatted(sessionToken, appKey);
        writer.println(auth);
        reader.readLine();   // read auth response
    }

    public void subscribeToMarkets(List<String> marketIds) {
        var sub = """
            {"op":"marketSubscription","id":2,
             "marketFilter":{"marketIds":%s},
             "marketDataFilter":{"fields":["EX_BEST_OFFERS","EX_TRADED","SP_TRADED"]},
             "orderSubscription":null}
            """.formatted(marketIds.stream()
                .map(id -> "\"" + id + "\"")
                .collect(Collectors.joining(",", "[", "]")));
        writer.println(sub);
    }

    public void subscribeToScores(List<String> eventIds) {
        var sub = """
            {"op":"scoreSubscription","id":3,
             "scoreFilter":{"eventIds":%s}}
            """.formatted(eventIds.stream()
                .map(id -> "\"" + id + "\"")
                .collect(Collectors.joining(",", "[", "]")));
        writer.println(sub);
    }

    public String readLine() throws IOException {
        return reader.readLine();
    }
}

Score message format

Score update messages have op: "scoreDelta":

{
  "op": "scoreDelta",
  "clk": "AAAABBBB",
  "pt": 1716810000000,
  "sd": [
    {
      "id": "28762345",
      "score": {
        "homeScore": 1,
        "awayScore": 0,
        "matchStatus": "InProgress",
        "elapsed": 35,
        "elapsedIndicator": "35",
        "homeRedCards": 0,
        "awayRedCards": 0,
        "homeCorners": 3,
        "awayCorners": 1
      }
    }
  ]
}

id is the Betfair event ID. matchStatus transitions through: PreMatchInProgressHalfTimeInProgressComplete.

Score model

public record ScoreUpdate(
    String eventId,
    int homeScore,
    int awayScore,
    MatchStatus status,
    int elapsedMinutes,
    int homeRedCards,
    int awayRedCards,
    long publishTime
) {
    public boolean isGoal(ScoreUpdate previous) {
        return homeScore != previous.homeScore || awayScore != previous.awayScore;
    }

    public boolean isRedCard(ScoreUpdate previous) {
        return homeRedCards > previous.homeRedCards || awayRedCards > previous.awayRedCards;
    }
}

public enum MatchStatus {
    PRE_MATCH, IN_PROGRESS, HALF_TIME, EXTRA_TIME, PENALTIES, COMPLETE
}

Score state machine

A state machine tracks transitions and fires events only on meaningful changes — avoiding duplicate reactions to repeated score pushes with the same values:

@Component
public class MatchStateTracker {

    private final Map<String, ScoreUpdate> currentScores = new ConcurrentHashMap<>();
    private final List<MatchEventListener> listeners;

    public void onScoreUpdate(ScoreUpdate update) {
        var previous = currentScores.put(update.eventId(), update);

        if (previous == null) {
            listeners.forEach(l -> l.onMatchStart(update));
            return;
        }

        if (update.isGoal(previous)) {
            listeners.forEach(l -> l.onGoal(update, previous));
        }

        if (update.isRedCard(previous)) {
            listeners.forEach(l -> l.onRedCard(update, previous));
        }

        if (update.status() != previous.status()) {
            listeners.forEach(l -> l.onStatusChange(update, previous.status()));
        }
    }

    public Optional<ScoreUpdate> currentScore(String eventId) {
        return Optional.ofNullable(currentScores.get(eventId));
    }
}

Market suspension handling

When a goal is scored, Betfair suspends the market immediately and reopens it seconds later with new prices. Attempting to place orders during suspension wastes API calls and returns errors. Track suspension state from the market change messages:

@Component
public class MarketSuspensionTracker {

    private final Map<String, MarketStatus> statusByMarket = new ConcurrentHashMap<>();

    public void onMarketChange(String marketId, String status) {
        var parsed = switch (status) {
            case "ACTIVE"    -> MarketStatus.ACTIVE;
            case "SUSPENDED" -> MarketStatus.SUSPENDED;
            case "CLOSED"    -> MarketStatus.CLOSED;
            default          -> MarketStatus.UNKNOWN;
        };
        statusByMarket.put(marketId, parsed);
    }

    public boolean isActive(String marketId) {
        return statusByMarket.getOrDefault(marketId, MarketStatus.UNKNOWN)
            == MarketStatus.ACTIVE;
    }

    public boolean isSuspended(String marketId) {
        return statusByMarket.getOrDefault(marketId, MarketStatus.UNKNOWN)
            == MarketStatus.SUSPENDED;
    }
}

The market status is embedded in the streaming market change message under the status field of each mc object.

In-play execution layer

Orders gate on suspension state. When a strategy signals an order, it queues it — the execution layer holds it until the market reopens:

@Component
public class InPlayOrderExecutor {

    private final BettingApi api;
    private final MarketSuspensionTracker suspension;
    private final Queue<PendingOrder> queue = new ConcurrentLinkedQueue<>();

    public void submit(PendingOrder order) {
        if (suspension.isActive(order.marketId())) {
            executeNow(order);
        } else {
            queue.offer(order);
            log.debug("Market {} suspended — queued order", order.marketId());
        }
    }

    @EventListener
    public void onMarketResumed(MarketResumedEvent event) {
        var iterator = queue.iterator();
        while (iterator.hasNext()) {
            var order = iterator.next();
            if (order.marketId().equals(event.marketId())) {
                iterator.remove();
                if (!order.isExpired()) {
                    executeNow(order);
                } else {
                    log.info("Dropping expired order for market {}", order.marketId());
                }
            }
        }
    }

    private void executeNow(PendingOrder order) {
        try {
            api.placeOrders(order.toPlaceInstruction());
        } catch (ApiException e) {
            log.error("Order execution failed for market {}: {}", order.marketId(), e.getMessage());
        }
    }
}

isExpired() checks whether the order is still relevant — a bet triggered by a goal at 35 minutes is stale if the market doesn’t reopen for 90 seconds.

Strategy — reacting to goals

A simple strategy that backs the losing team immediately after a goal, anticipating a price correction:

@Component
public class GoalReactionStrategy implements MatchEventListener {

    private final InPlayOrderExecutor executor;
    private final MarketRegistry markets;

    private static final double STAKE = 20.0;
    private static final int MAX_PRICE = 8;

    @Override
    public void onGoal(ScoreUpdate current, ScoreUpdate previous) {
        var isHomeGoal = current.homeScore() > previous.homeScore();
        var losingTeam = isHomeGoal ? Team.AWAY : Team.HOME;

        markets.marketsForEvent(current.eventId()).forEach(market -> {
            var losingSelection = market.selectionFor(losingTeam);
            if (losingSelection == null) return;

            var price = losingSelection.bestBack();
            if (price == null || price > MAX_PRICE) return;

            var order = PendingOrder.builder()
                .marketId(market.marketId())
                .selectionId(losingSelection.id())
                .side(Side.BACK)
                .price(price)
                .stake(STAKE)
                .expiresAfterMillis(30_000)
                .build();

            executor.submit(order);
            log.info("Goal reaction: backing {} at {} on market {}",
                losingTeam, price, market.marketId());
        });
    }

    @Override
    public void onStatusChange(ScoreUpdate update, MatchStatus from) {
        if (update.status() == MatchStatus.HALF_TIME) {
            log.info("Half time — suspending strategy for event {}", update.eventId());
        }
    }
}

Heartbeat and connection resilience

The Streaming API sends a heartbeat every 5 seconds. If you stop receiving messages, the connection has dropped silently — common on long-running in-play sessions.

@Component
public class StreamHeartbeatMonitor {

    private volatile long lastMessageAt = System.currentTimeMillis();
    private static final long TIMEOUT_MS = 15_000;

    public void recordMessage() {
        lastMessageAt = System.currentTimeMillis();
    }

    @Scheduled(fixedDelay = 5_000)
    public void checkHeartbeat() {
        if (System.currentTimeMillis() - lastMessageAt > TIMEOUT_MS) {
            log.warn("Streaming connection silent for {}ms — reconnecting", TIMEOUT_MS);
            applicationEventPublisher.publishEvent(new StreamTimeoutEvent());
        }
    }
}

On a StreamTimeoutEvent, reconnect and resubscribe. The Streaming API supports a clk token that lets you resume from a specific point — include the last received clk value in the subscription to avoid replaying old messages after reconnection.

Parsing the stream in a dedicated thread

The stream reader runs on a single dedicated virtual thread, parsing and dispatching messages without blocking:

@Component
public class StreamDispatcher {

    private final BetfairStreamConnection connection;
    private final ScoreParser scoreParser;
    private final MarketParser marketParser;
    private final StreamHeartbeatMonitor heartbeat;

    public void start() {
        Thread.ofVirtual().name("betfair-stream").start(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    var line = connection.readLine();
                    if (line == null) break;
                    heartbeat.recordMessage();
                    dispatch(line);
                } catch (IOException e) {
                    log.error("Stream read error", e);
                    break;
                }
            }
        });
    }

    private void dispatch(String line) {
        var op = extractOp(line);
        switch (op) {
            case "scoreDelta" -> scoreParser.parse(line)
                .forEach(stateTracker::onScoreUpdate);
            case "mcm" -> marketParser.parse(line)
                .forEach(update -> {
                    suspensionTracker.onMarketChange(update.marketId(), update.status());
                    if ("ACTIVE".equals(update.status())) {
                        publisher.publishEvent(new MarketResumedEvent(update.marketId()));
                    }
                });
        }
    }
}

Parsing on a virtual thread keeps the dispatcher non-blocking and lets you process multiple streams (multiple events) in parallel without OS thread overhead.

What matters in production

Market suspension timing is imprecise. There’s a variable lag between the physical event (goal scored) and the API suspension. Prices spike for a fraction of a second before suspension — strategies that rely on catching the first tick after a goal need sub-100ms latency from score to order, which is only achievable with a co-located server.

Score data can arrive out of order. The pt timestamp is the exchange publish time, not the event time. Always compare pt values when merging updates from different sources.

Test with recorded stream data. The Betfair historical data files include score messages alongside market data. Build your test suite to replay real recorded streams so suspension transitions and goal events fire in exactly the order they did live.

Never assume score data is complete. Betfair’s score data is sourced from third-party providers and occasionally lags or misses events. A backup data source (e.g., a public sports data API) and cross-validation between the two is worth the engineering effort for any strategy that depends on it.

In-play trading is technically demanding — the latency requirements, suspension handling, and data reliability issues make it a different discipline from pre-event markets. If you’re building in-play systems and want experienced hands on the Java side, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.