A practical guide to integrating with the Matchbook Exchange API in Java — authentication, REST client setup, placing and managing orders, reading market prices, and the key differences from the Betfair API.
Matchbook is the most serious competitor to Betfair in the European betting exchange market. It operates on a commission-free model with a per-market premium charge, which makes it particularly attractive for high-frequency traders who pay significant Betfair commission. I’ve integrated with Matchbook as part of multi-exchange trading setups, and while the API is less mature than Betfair’s, it’s well-documented and reliable once you understand its conventions.
This post covers everything you need to get a Java client talking to the Matchbook API — authentication, market data, order placement, and the abstractions that make a multi-exchange system manageable.
Matchbook’s API is REST-based with JSON payloads. There is no dedicated streaming API equivalent to Betfair’s — price updates are polled via REST. This is the single biggest architectural difference: Matchbook integrations are inherently polling-based, which means your update frequency is bounded by your request rate and Matchbook’s rate limits.
Base URL: https://api.matchbook.com/edge/rest
Key endpoints:
POST /security/session — authenticate and obtain a session tokenGET /markets — search and list marketsGET /markets/{id}/runners — get runners and prices for a marketPOST /bets — place ordersGET /bets — list open betsDELETE /bets/{id} — cancel an orderMatchbook uses session-based authentication. You POST credentials to obtain a session token, then include it in subsequent requests via a cookie or header:
@Component
@Slf4j
public class MatchbookAuthService {
private final RestClient restClient;
private volatile String sessionToken;
private volatile Instant tokenExpiry;
private static final Duration SESSION_DURATION = Duration.ofHours(6);
public MatchbookAuthService(RestClient.Builder builder,
@Value("${matchbook.base-url}") String baseUrl) {
this.restClient = builder.baseUrl(baseUrl).build();
}
public synchronized String getSessionToken() {
if (sessionToken == null || Instant.now().isAfter(tokenExpiry)) {
refreshSession();
}
return sessionToken;
}
private void refreshSession() {
MatchbookLoginRequest request = new MatchbookLoginRequest(username, password);
MatchbookLoginResponse response = restClient.post()
.uri("/security/session")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(MatchbookLoginResponse.class);
if (response == null || response.getSessionToken() == null) {
throw new MatchbookAuthException("Failed to obtain session token");
}
this.sessionToken = response.getSessionToken();
this.tokenExpiry = Instant.now().plus(SESSION_DURATION);
log.info("Matchbook session refreshed, valid until {}", tokenExpiry);
}
}
public record MatchbookLoginRequest(
@JsonProperty("username") String username,
@JsonProperty("password") String password
) {}
public record MatchbookLoginResponse(
@JsonProperty("session-token") String sessionToken,
@JsonProperty("user-id") long userId
) {}
The synchronized on getSessionToken() prevents concurrent threads from triggering multiple simultaneous session refreshes. For high-throughput systems consider a ReentrantReadWriteLock — many concurrent reads, exclusive write for refresh.
Build a reusable client component that handles authentication headers and error mapping automatically:
@Component
@RequiredArgsConstructor
public class MatchbookRestClient {
private final RestClient.Builder restClientBuilder;
private final MatchbookAuthService authService;
@Value("${matchbook.base-url}")
private String baseUrl;
private RestClient client;
@PostConstruct
public void init() {
this.client = restClientBuilder
.baseUrl(baseUrl)
.requestInterceptor((request, body, execution) -> {
request.getHeaders().set("session-token", authService.getSessionToken());
request.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return execution.execute(request, body);
})
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
String body = new String(response.getBody().readAllBytes());
throw new MatchbookApiException(
"Matchbook API error %d: %s".formatted(
response.getStatusCode().value(), body));
})
.build();
}
public <T> T get(String uri, Class<T> responseType) {
return client.get().uri(uri).retrieve().body(responseType);
}
public <T> T post(String uri, Object body, Class<T> responseType) {
return client.post().uri(uri).body(body).retrieve().body(responseType);
}
public void delete(String uri) {
client.delete().uri(uri).retrieve().toBodilessEntity();
}
}
The Matchbook market model differs from Betfair’s in some important ways. Matchbook uses runners where Betfair uses runners, but Matchbook’s price representation uses prices objects with separate back and lay sides per price point:
@Service
@RequiredArgsConstructor
public class MatchbookMarketService {
private final MatchbookRestClient restClient;
public MatchbookMarket getMarket(long marketId) {
return restClient.get("/markets/" + marketId, MatchbookMarket.class);
}
public List<MatchbookRunner> getRunnersWithPrices(long marketId) {
MatchbookRunnersResponse response = restClient.get(
"/markets/" + marketId + "/runners?include-prices=true",
MatchbookRunnersResponse.class);
return response != null ? response.getRunners() : List.of();
}
public Optional<BestPrices> getBestPrices(long marketId, long runnerId) {
List<MatchbookRunner> runners = getRunnersWithPrices(marketId);
return runners.stream()
.filter(r -> r.getId() == runnerId)
.findFirst()
.map(runner -> {
OptionalDouble bestBack = runner.getPrices().stream()
.filter(p -> "back".equals(p.getSide()))
.mapToDouble(MatchbookPrice::getOdds)
.max();
OptionalDouble bestLay = runner.getPrices().stream()
.filter(p -> "lay".equals(p.getSide()))
.mapToDouble(MatchbookPrice::getOdds)
.min();
return new BestPrices(bestBack.orElse(0), bestLay.orElse(0));
});
}
}
Note that Matchbook uses decimal odds throughout, same as Betfair. However, Matchbook’s minimum odds increment (tick size) differs from Betfair’s at certain price ranges — don’t assume price ladder logic you’ve written for Betfair will work unchanged on Matchbook.
Matchbook’s order placement uses a bets endpoint with a slightly different structure from Betfair:
@Service
@RequiredArgsConstructor
@Slf4j
public class MatchbookOrderService {
private final MatchbookRestClient restClient;
public MatchbookBet placeBet(long marketId,
long runnerId,
String side, // "back" or "lay"
double odds,
double stake) {
MatchbookPlaceBetRequest request = MatchbookPlaceBetRequest.builder()
.marketId(marketId)
.runnerId(runnerId)
.side(side)
.odds(odds)
.stake(stake)
.keepInPlay(false) // equivalent to Betfair's LAPSE persistence
.build();
MatchbookBetsResponse response = restClient.post(
"/bets", Map.of("bets", List.of(request)), MatchbookBetsResponse.class);
if (response == null || response.getBets().isEmpty()) {
throw new MatchbookOrderException("No bet returned from placement");
}
MatchbookBet placed = response.getBets().get(0);
log.info("Placed Matchbook bet {} — {} {} at {} for {}",
placed.getId(), side, runnerId, odds, stake);
return placed;
}
public void cancelBet(long betId) {
restClient.delete("/bets/" + betId);
log.info("Cancelled Matchbook bet {}", betId);
}
public List<MatchbookBet> getOpenBets() {
MatchbookBetsResponse response = restClient.get(
"/bets?status=open", MatchbookBetsResponse.class);
return response != null ? response.getBets() : List.of();
}
}
keepInPlay = false is the Matchbook equivalent of Betfair’s PersistenceType.LAPSE — unmatched orders are cancelled when the market goes in-play. Always use this unless you have a specific reason to hold orders in-play.
Without a streaming API, you need a polling loop. The key design decisions are polling frequency, thread model, and how to detect meaningful changes:
@Component
@RequiredArgsConstructor
@Slf4j
public class MatchbookPricePoller {
private final MatchbookMarketService marketService;
private final MarketSignalService signalService;
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(4, r -> new Thread(r, "mb-poller"));
private final Map<Long, ScheduledFuture<?>> activePollTasks = new ConcurrentHashMap<>();
public void startPolling(long marketId, Duration interval) {
ScheduledFuture<?> task = scheduler.scheduleAtFixedRate(
() -> pollMarket(marketId),
0, interval.toMillis(), TimeUnit.MILLISECONDS);
activePollTasks.put(marketId, task);
log.info("Started polling market {} every {}ms", marketId, interval.toMillis());
}
public void stopPolling(long marketId) {
ScheduledFuture<?> task = activePollTasks.remove(marketId);
if (task != null) {
task.cancel(false);
log.info("Stopped polling market {}", marketId);
}
}
private void pollMarket(long marketId) {
try {
List<MatchbookRunner> runners = marketService.getRunnersWithPrices(marketId);
runners.forEach(runner ->
signalService.onMatchbookUpdate(marketId, runner));
} catch (Exception e) {
log.error("Poll failed for market {}: {}", marketId, e.getMessage());
}
}
}
A polling interval of 500ms–1000ms is a reasonable starting point for pre-race markets. Tighter than 500ms risks rate limiting. Matchbook’s rate limits are documented in their API guide — respect them; breaches result in temporary bans.
If your system trades on both Betfair and Matchbook, an abstraction layer lets strategy code remain exchange-agnostic:
public interface ExchangeMarketService {
List<ExchangeRunner> getRunnersWithPrices(String marketId);
ExchangeOrder placeBet(String marketId, String runnerId,
Side side, double odds, double stake);
void cancelOrder(String orderId);
}
@Service("betfairMarketService")
public class BetfairMarketServiceAdapter implements ExchangeMarketService { ... }
@Service("matchbookMarketService")
public class MatchbookMarketServiceAdapter implements ExchangeMarketService { ... }
Strategy components depend on ExchangeMarketService and are wired to the appropriate implementation at runtime. The signal engine remains identical; only the data source and order routing differ.
| Betfair | Matchbook | |
|---|---|---|
| Data delivery | Streaming API (push) | REST polling |
| Commission | % of winnings | Per-market premium charge |
| Liquidity | Higher (primary market) | Lower but growing |
| API maturity | Very mature | Good, actively developed |
| Minimum stake | £2 | €1 |
| In-play | Yes, full streaming | Yes, via polling |
| Market IDs | String (e.g. 1.234567) | Long integer |
The liquidity difference is the most practically significant. For a pre-race strategy that relies on liquid order books (WoM, order flow imbalance), Betfair will be the primary venue and Matchbook a secondary one. For strategies that are less sensitive to liquidity — outright position taking on clear signals — Matchbook’s lower commission structure can meaningfully improve profitability.
If you’re building a multi-exchange trading system in Java and need an engineer who’s integrated with both Betfair and Matchbook in production, get in touch.