Every global odds API skips Australian racing. Here is what a complete racing data API actually needs — fields, fixed odds, exchange, tote, sectionals, connections — and who carries it.
Backtest your betting model against 232K+ historical odds records. Step-by-step Python tutorial with real data from the Krok Odds archive.
Every Australian racing site has a next-to-go widget in the top-right corner, and they all look roughly the same for a reason: it is the component that answers a racing punter's only real-time question. With races jumping every couple of minutes across thoroughbred, harness and greyhound meetings, "what is next" is the whole navigation model.
It is also a deceptively good engineering exercise. You have to merge multiple race types, handle timezone-correct countdowns without hydration errors, deal with scratchings, and keep it fresh without hammering an API. This tutorial builds one end to end.
Racing data is three levels deep — meeting, race, runner — and each level carries fields you need.
| Level | Key fields | Used for |
|---|---|---|
| Meeting | venue, venue_slug, race_type, jurisdiction | Display name, state filter, code badge |
| Race | race_number, start_time, distance, name | Sorting, countdown, subtitle |
| Runner | number, name, barrier, jockey, scratched, odds | The actual list of things to bet on |
Note race_type: the Krok Odds racing family normalises to T (thoroughbred), H (harness) and G (greyhound). Some upstream feeds use R for thoroughbreds; the API maps it so you do not have to.
// lib/racing-types.ts
export type RaceType = 'T' | 'H' | 'G';
export interface Runner {
number: number;
name: string;
barrier?: number;
jockey?: string;
trainer?: string;
scratched?: boolean;
odds?: { bookmaker: string; price: number }[];
}
export interface Race {
race_number: number;
name?: string;
start_time: string; // ISO 8601 UTC
distance?: number;
runners: Runner[];
}
export interface Meeting {
venue: string;
venue_slug: string;
race_type: RaceType;
jurisdiction?: string;
races: Race[];
}
/** Flattened row the widget actually renders. */
export interface NextToGoRace {
id: string;
venue: string;
raceType: RaceType;
raceNumber: number;
startTime: string;
distance?: number;
topRunners: { number: number; name: string; price: number | null }[];
}The API gives you a meeting-shaped tree. The widget wants a flat, time-sorted list. Do the flattening once, on the server.
// lib/next-to-go.ts
import type { Meeting, NextToGoRace, Runner } from './racing-types';
const BASE = 'https://krokodds.com.au/api/v1';
function bestPrice(runner: Runner): number | null {
if (!runner.odds?.length) return null;
return runner.odds.reduce((max, o) => (o.price > max ? o.price : max), 0) || null;
}
function todayInMelbourne(): string {
// Racing days are local dates, not UTC dates. Getting this wrong drops the
// first two hours of the card every single evening.
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Australia/Melbourne',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
export async function fetchNextToGo(limit = 8): Promise<NextToGoRace[]> {
const date = todayInMelbourne();
const res = await fetch(`${BASE}/racing/meetings?date=${date}&limit=100`, {
headers: { 'X-API-Key': process.env.KROK_API_KEY ?? '' },
next: { revalidate: 45 },
});
if (!res.ok) throw new Error(`racing meetings ${res.status}`);
const { data } = (await res.json()) as { data: Meeting[] };
const now = Date.now();
return data
.flatMap((meeting) =>
meeting.races.map((race) => ({
id: `${meeting.venue_slug}-${race.race_number}`,
venue: meeting.venue,
raceType: meeting.race_type,
raceNumber: race.race_number,
startTime: race.start_time,
distance: race.distance,
topRunners: race.runners
.filter((r) => !r.scratched)
.map((r) => ({ number: r.number, name: r.name, price: bestPrice(r) }))
.sort((a, b) => (a.price ?? 999) - (b.price ?? 999))
.slice(0, 3),
}))
)
.filter((race) => new Date(race.startTime).getTime() > now)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.slice(0, limit);
}Two details there are load-bearing. todayInMelbourne() exists because a racing day is a local calendar date — computing the date in UTC drops the entire evening card once you pass 10am UTC. And sorting runners by price before slicing gives you the market's three most likely winners rather than saddlecloths 1, 2 and 3.
This is where most implementations break. If the server renders "2m 40s" and the client hydrates a beat later rendering "2m 39s", React logs a mismatch and in some cases discards the server HTML entirely.
The fix: render nothing time-relative until after mount.
// components/Countdown.tsx
'use client';
import { useEffect, useState } from 'react';
function format(ms: number): string {
if (ms <= 0) return 'JUMPED';
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`;
}
export default function Countdown({ startTime }: { startTime: string }) {
const target = new Date(startTime).getTime();
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
const tick = () => setLabel(format(target - Date.now()));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [target]);
// Server render and first client render both produce the placeholder,
// so hydration matches exactly.
if (label === null) {
return <span className="tabular-nums text-zinc-500">--:--</span>;
}
const urgent = target - Date.now() < 120_000;
return (
<span className={urgent ? 'tabular-nums text-amber-400' : 'tabular-nums text-zinc-300'}>
{label}
</span>
);
}// components/NextToGo.tsx
import Countdown from './Countdown';
import type { NextToGoRace, RaceType } from '@/lib/racing-types';
const TYPE_LABEL: Record<RaceType, string> = { T: 'Gallops', H: 'Harness', G: 'Greyhounds' };
const TYPE_CLASS: Record<RaceType, string> = {
T: 'bg-emerald-900 text-emerald-300',
H: 'bg-sky-900 text-sky-300',
G: 'bg-violet-900 text-violet-300',
};
export default function NextToGo({ races }: { races: NextToGoRace[] }) {
if (races.length === 0) {
return <p className="text-sm text-zinc-500">No races remaining today.</p>;
}
return (
<ul className="divide-y divide-zinc-800 rounded-lg border border-zinc-800">
{races.map((race) => (
<li key={race.id} className="flex items-center gap-3 p-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${TYPE_CLASS[race.raceType]}`}>
{TYPE_LABEL[race.raceType]}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
{race.venue} R{race.raceNumber}
{race.distance ? <span className="text-zinc-500"> · {race.distance}m</span> : null}
</div>
<div className="truncate text-xs text-zinc-500">
{race.topRunners
.map((r) => `${r.number}. ${r.name}${r.price ? ` $${r.price.toFixed(2)}` : ''}`)
.join(' ')}
</div>
</div>
<Countdown startTime={race.startTime} />
</li>
))}
</ul>
);
}// app/page.tsx
import { fetchNextToGo } from '@/lib/next-to-go';
import NextToGo from '@/components/NextToGo';
export const revalidate = 45;
export default async function Page() {
const races = await fetchNextToGo(8);
return (
<main className="mx-auto max-w-md p-6">
<h2 className="mb-3 text-lg font-bold text-white">Next to go</h2>
<NextToGo races={races} />
</main>
);
}The server list is 45 seconds stale at worst, which means a jumped race can linger. Two options, and the cheap one is usually right.
router.refresh() to backfill.'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export function useLiveRaces(initial: NextToGoRace[]) {
const [races, setRaces] = useState(initial);
const router = useRouter();
useEffect(() => setRaces(initial), [initial]);
useEffect(() => {
const id = setInterval(() => {
const now = Date.now();
setRaces((prev) => {
const live = prev.filter((r) => new Date(r.startTime).getTime() > now);
// Ran low on races — pull a fresh page from the server.
if (live.length < 4) router.refresh();
return live;
});
}, 5000);
return () => clearInterval(id);
}, [router]);
return races;
}| Trap | Symptom | Fix |
|---|---|---|
| UTC date for the racing day | Evening card vanishes after 10am UTC | Compute the date in Australia/Melbourne |
| Server-rendered relative time | Hydration mismatch warnings | Placeholder until useEffect runs |
| Scratched runners in the top three | Widget shows a $1.80 favourite that is not running | Filter on the scratched flag at render |
| Stale prices from a broken scraper | A dead book keeps "winning" best price | Discard prices older than ~15 minutes |
| Cross-code venue name collisions | Sale gallops and Sale greyhounds merge into one meeting | Key on venue_slug + race_type, never venue alone |
| Fetching per-race instead of per-day | Quota gone by lunchtime | One wide request, cached, flattened locally |
That venue-collision one is genuinely nasty and specific to Australian racing: several towns host both thoroughbred and greyhound meetings under the same name, and any code that keys on venue slug alone will silently merge them. Always carry race type in the key.
/v1/racing/movers flags runners whose price has firmed or drifted sharply since market open./v1/racing/runner-form gives recent starts, and it pairs well with a hover card./v1/racing/arbs returns cross-book opportunities already computed.For a reference implementation of all of the above running live across every Australian meeting, the Krok Odds racing board is the same data model with full fields, movers and exchange prices layered on. The endpoint reference is at /api-docs.
Meetings, races, runners, form, sectionals and per-book prices across every Australian thoroughbred, harness and greyhound meeting. Free tier available.
Get an API key →
David has been running advantage betting strategies across Australian bookmakers since 2023 and contributes long-form retrospectives, case studies, and operational pieces drawn from years of running real bets in AU markets. His writing focuses on the realities of running a sustainable AU advantage operation — what works, what fails, and the operational details most blogs gloss over.
Racing arbitrage between Betfair and fixed-odds bookmakers is real but underused. Here's how it works, the measured data, and how to do it.