How to extract runner metadata, interpret Starting Price fields, and use BSP data from the Betfair API in Java — with practical examples for pre-race and post-race analysis.
Every Betfair market has two layers of runner data: the catalogue layer that identifies who is running, and the market book layer that carries live prices, volumes, and the Starting Price fields. Conflating the two — or not knowing where BSP data actually lives — is one of the most common sources of confusion when building against the Betfair API for the first time.
This post covers both layers, explains the difference between SP and BSP, and shows how to work with all of it in Java.
The Betfair API models each runner across two separate objects that you join by selection ID.
RunnerCatalog comes back from listMarketCatalogue when you request the RUNNERS market projection. It carries the stable, pre-race metadata:
public class RunnerCatalog {
private long selectionId; // stable ID — same runner across all markets
private String runnerName;
private double handicap;
private String sortPriority; // cloth number / stall draw
private Map<String, String> metadata; // COLOURS_DESCRIPTION, TRAINER_NAME, etc.
}
Runner comes back from listMarketBook. It carries the live state: best available prices, matched volume, and SP fields. The selectionId on both objects is the join key.
RunnerCatalog catalogue = catalogueRunners.stream()
.filter(r -> r.getSelectionId() == runner.getSelectionId())
.findFirst()
.orElseThrow();
String name = catalogue.getRunnerName();
double bsp = runner.getSp().getActualSP();
The metadata field on RunnerCatalog is underused. It can contain:
| Key | Value |
|---|---|
COLOURS_DESCRIPTION |
Jockey silks description |
COLOURS_FILENAME |
Silks image filename |
TRAINER_NAME |
Trainer name |
JOCKEY_NAME |
Jockey name |
AGE |
Age of the horse |
FORM |
Recent form string |
OFFICIAL_RATING |
Official Racing Post rating |
WEIGHT_VALUE / WEIGHT_UNITS |
Carried weight |
RUNNER_ID |
Racing Post runner ID |
Map<String, String> meta = runner.getMetadata();
String trainer = meta.getOrDefault("TRAINER_NAME", "Unknown");
String form = meta.getOrDefault("FORM", "-");
String rating = meta.getOrDefault("OFFICIAL_RATING", "N/A");
Not every runner has every field — always use getOrDefault. Fields are absent rather than null when not populated, so a missing key is normal rather than an error.
These are different things and the terminology is inconsistent across the industry.
SP (traditional Starting Price) is the price returned by the racing press (Racing Post, Timeform) based on bookmaker prices at the off. It is calculated off-exchange, arrives as historical data, and is not available through the Betfair API directly.
BSP (Betfair Starting Price) is Betfair’s own mechanism. It matches unmatched BSP bets at a single algorithmically-determined price at the moment the market is turned in-play. BSP is native to the exchange and fully accessible via the API.
When Betfair documentation or third-party tools say “SP” in the context of the exchange, they almost always mean BSP.
Every Runner from listMarketBook carries an sp field of type StartingPrices:
public class StartingPrices {
private Double nearPrice; // BSP projection during pre-race betting
private Double farPrice; // removed — ignore this field
private List<PriceSize> backStakeTaken; // bets submitted as BSP backs
private List<PriceSize> layLiabilityTaken; // bets submitted as BSP lays
private Double actualSP; // final BSP — only populated after the off
}
nearPrice is the model’s best estimate of where BSP will settle, updated continuously during pre-race. It is available before the off and is useful as a late market indicator — it tends to converge toward the final BSP as the market approaches the jump.
actualSP is the final reconciled price. It is null until the market turns in-play (or is suspended at the off). Never assume it is populated — check for null before using it.
StartingPrices sp = runner.getSp();
if (sp.getActualSP() != null) {
// race is off — final BSP available
double bsp = sp.getActualSP();
} else if (sp.getNearPrice() != null) {
// pre-race — use projection
double nearBsp = sp.getNearPrice();
}
The backStakeTaken and layLiabilityTaken lists tell you how much money has been placed as BSP bets. These are cumulative and update throughout the pre-race window.
double totalBspBackStake = sp.getBackStakeTaken().stream()
.mapToDouble(PriceSize::getSize)
.sum();
double totalBspLayLiability = sp.getLayLiabilityTaken().stream()
.mapToDouble(PriceSize::getSize)
.sum();
High backStakeTaken relative to layLiabilityTaken suggests strong back interest — the BSP mechanism will need to find lay money at or near the near price to match it, which can pull the BSP down. The imbalance is a secondary market signal.
nearPrice is one of the more useful signals for detecting late money. Plotting it over the final five minutes before the off gives you a BSP trend:
record BspSnapshot(Instant at, long selectionId, Double nearPrice) {}
List<BspSnapshot> snapshots = new ArrayList<>();
// poll every 5 seconds in the pre-race window
for (Runner runner : marketBook.getRunners()) {
Double near = runner.getSp().getNearPrice();
if (near != null) {
snapshots.add(new BspSnapshot(
Instant.now(),
runner.getSelectionId(),
near
));
}
}
A nearPrice that shortens consistently from 6.0 to 4.2 in the final minute is a steam signal — the BSP mechanism is seeing more back money coming in and adjusting the projection downward.
A practical pattern: build a RunnerView that joins the two sources once and passes the combined object through your system.
record RunnerView(
long selectionId,
String name,
String trainer,
String form,
String officialRating,
double bestBackPrice,
double bestLayPrice,
Double nearBsp,
Double actualBsp,
double totalMatched
) {}
static RunnerView build(RunnerCatalog cat, Runner book) {
Map<String, String> meta = cat.getMetadata();
ExchangePrices ex = book.getEx();
StartingPrices sp = book.getSp();
double bestBack = ex.getAvailableToBack().stream()
.mapToDouble(PriceSize::getPrice).max().orElse(0);
double bestLay = ex.getAvailableToLay().stream()
.mapToDouble(PriceSize::getPrice).min().orElse(0);
return new RunnerView(
cat.getSelectionId(),
cat.getRunnerName(),
meta.getOrDefault("TRAINER_NAME", ""),
meta.getOrDefault("FORM", ""),
meta.getOrDefault("OFFICIAL_RATING", ""),
bestBack,
bestLay,
sp.getNearPrice(),
sp.getActualSP(),
book.getTotalMatched()
);
}
This keeps the BSP and catalogue concerns out of wherever you use the data.
If you are working with Betfair’s historical data files (the Streaming API NDJSON format), BSP appears in the RunnerChange delta once the market turns in-play:
{ "id": 12345678, "sp": { "p": 4.2 } }
The p field in the streaming sp object is actualSP. b and l are the back and lay BSP volumes. The n field is nearPrice. This maps directly to the StartingPrices object above — the REST API and the Streaming API use the same underlying model.
BSP is a settlement mechanism, not a trading price. For analysis:
getAvailableToBack().nearPrice trend in the final minutes.The two converge in liquid races (big fields, major meetings) and diverge in illiquid ones (small NH fields, Monday evening). That divergence itself is a signal worth modelling.
If you’re building Betfair tooling and need to work through the SP / BSP data model or integrate runner metadata into a trading signal, get in touch.