How to connect to the Betfair Exchange Streaming API from Java, construct market and order subscriptions, and handle the initial image before processing delta updates.
The Betfair Exchange Streaming API is a persistent TCP connection over TLS that pushes market changes to your application in near real-time. Unlike the REST API — where you poll for prices — the streaming API sends updates only when something changes, typically within milliseconds of the exchange receiving a bet. For any trading system that needs to act on price movement, this is the only viable interface.
Understanding the connection lifecycle, subscription model, and message structure before writing a single line of trading logic saves hours of debugging later.
The streaming endpoint is stream-api.betfair.com:443. All traffic is TLS — you must present a valid application key and session token on every connection.
Open a TLS socket and authenticate with the authentication operation before sending any subscriptions:
public class BetfairStreamClient {
private static final String HOST = "stream-api.betfair.com";
private static final int PORT = 443;
private final SSLSocket socket;
private final BufferedWriter writer;
private final BufferedReader reader;
private int connectionId;
public BetfairStreamClient(SSLContext sslContext) throws IOException {
SSLSocketFactory factory = sslContext.getSocketFactory();
socket = (SSLSocket) factory.createSocket(HOST, PORT);
socket.setSoTimeout(30_000); // 30s read timeout
writer = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
}
public void connect(String appKey, String sessionToken) throws IOException {
// Read the connection message
String line = reader.readLine();
ConnectionMessage conn = objectMapper.readValue(line, ConnectionMessage.class);
this.connectionId = conn.connectionId();
// Send authentication
AuthenticationMessage auth = new AuthenticationMessage(1, appKey, sessionToken);
sendMessage(auth);
String response = reader.readLine();
StatusMessage status = objectMapper.readValue(response, StatusMessage.class);
if (status.statusCode() != 200) {
throw new BetfairStreamException("Authentication failed: " + status.errorMessage());
}
}
}
The connectionId from the initial connection message identifies this connection in logs — useful when Betfair support needs to investigate a specific session.
Subscribe to specific markets by sending a marketSubscription message with filter criteria:
public void subscribeToMarkets(List<String> marketIds) throws IOException {
MarketFilter filter = new MarketFilter(marketIds, null, null);
MarketDataFilter dataFilter = new MarketDataFilter(
List.of("EX_BEST_OFFERS", "EX_TRADED"), // what data to receive
3 // ladder levels (1–10)
);
MarketSubscriptionMessage sub = new MarketSubscriptionMessage(
2, // message ID (must be unique per connection)
"SUBSCRIBE",
filter,
dataFilter,
"CONFLATED", // clk — used for resubscription
null // initialClk — null for new subscription
);
sendMessage(sub);
}
EX_BEST_OFFERS gives you batb/batl (condensed best available) and bdatb/bdatl (full ladder). EX_TRADED gives you matched volume at each price. Add only the fields you need — each additional field increases message size and processing overhead.
After subscribing, Betfair sends a full market snapshot before any delta updates. This snapshot contains the complete current state of every subscribed market. Identify it by the img flag on the market change message:
private void processMarketChangeMessage(MarketChangeMessage mcm) {
for (MarketChange mc : mcm.mc()) {
if (Boolean.TRUE.equals(mc.img())) {
// Full snapshot — replace existing state entirely
marketStateMap.put(mc.id(), MarketState.fromSnapshot(mc));
} else {
// Delta — merge into existing state
MarketState state = marketStateMap.get(mc.id());
if (state != null) {
state.applyDelta(mc);
}
}
}
}
Never apply a snapshot as a delta — it contains the full state, not differences. Never apply a delta to an uninitialised state — if the market isn’t in your map yet, wait for the snapshot.
Messages arrive as newline-delimited JSON. Read lines continuously and deserialise:
public void startReadLoop() {
Thread.startVirtualThread(() -> {
try {
String line;
while ((line = reader.readLine()) != null) {
StreamMessage msg = objectMapper.readValue(line, StreamMessage.class);
dispatch(msg);
}
} catch (IOException e) {
log.error("Stream disconnected", e);
scheduleReconnect();
}
});
}
Virtual threads (Java 21) are ideal here — the read loop blocks on I/O and holds no CPU while waiting. One virtual thread per stream connection is clean and cheap.
Betfair sends a heartbeat message every few seconds if no market data arrives. If your read loop goes silent for more than your socket timeout, the connection has dropped without a clean close — TCP keepalive does not guarantee timely detection.
Track the last message timestamp and reconnect proactively:
private volatile Instant lastMessageAt = Instant.now();
private void dispatch(StreamMessage msg) {
lastMessageAt = Instant.now();
// ... process message
}
@Scheduled(fixedDelay = 10_000)
public void checkHeartbeat() {
if (Duration.between(lastMessageAt, Instant.now()).getSeconds() > 20) {
log.warn("No stream message for 20s — reconnecting");
reconnect();
}
}
On reconnect, you must re-authenticate and re-subscribe. To receive only the changes since your last connection (avoiding a full re-snapshot), pass the clk value from your last received message as initialClk in the new subscription:
private String lastClk;
public void subscribeToMarkets(List<String> marketIds) throws IOException {
MarketSubscriptionMessage sub = new MarketSubscriptionMessage(
2, "SUBSCRIBE", filter, dataFilter,
null, // clk — null for new subscription
lastClk // initialClk — resume from last known state
);
sendMessage(sub);
}
private void onMarketChangeMessage(MarketChangeMessage mcm) {
if (mcm.clk() != null) {
lastClk = mcm.clk();
}
// ...
}
If the reconnect happens quickly (before Betfair’s internal buffer window expires), the delta resume works cleanly. If too much time passes, Betfair sends a fresh snapshot regardless — your code must handle both paths.
The conflated field on a market change message is true when Betfair has combined multiple updates into one because your consumer was too slow. This indicates your processing pipeline cannot keep up with the stream rate. Common causes: blocking I/O on the read thread, heavy JSON deserialisation, or slow downstream writes. Conflation means you receive fewer messages but each message represents more state change — signals derived from tick frequency (like price velocity) become less accurate.
With connection, authentication, subscription, and reconnection handled correctly, the streaming API becomes the reliable foundation everything else builds on.
If you’re building Betfair trading infrastructure in Java and want a review of your streaming connection handling, get in touch.