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.
An AFL odds comparison dashboard is the perfect first project for anyone building with betting data. It is small enough to finish in an afternoon, and it forces you to solve the four problems that appear in every betting product: fetching, normalising, computing best price, and caching so you do not bankrupt yourself on API calls.
By the end of this tutorial you will have a Next.js App Router page that pulls AFL head-to-head prices across every Australian bookmaker, highlights the best available price per team, computes book percentage, and flags arbitrage — server-rendered, cached, and typed end to end.
npx create-next-app@latest afl-odds --typescript --app --tailwind
cd afl-oddsAdd your key to .env.local:
KROK_API_KEY=your_key_hereNo NEXT_PUBLIC_ prefix. That prefix ships the value to the browser, and an API key in the browser is an API key on someone else's bill. Grab a free key from Krok Odds API access if you do not have one.
Model the wire shape before you touch the network. It makes every subsequent step type-checked.
// lib/types.ts
export interface Price {
bookmaker: string;
price: number;
captured_at: string;
}
export interface Selection {
name: string;
prices: Price[];
}
export interface Market {
key: string; // 'h2h' | 'spreads' | 'totals'
selections: Selection[];
}
export interface OddsEvent {
event_id: string;
sport: string;
commence_time: string;
home_team: string;
away_team: string;
markets: Market[];
}
export interface ApiEnvelope<T> {
success: boolean;
data: T;
meta: { count: number; tier: string; timestamp: string };
}One module, one responsibility: talk to the API and hand back typed data. Caching lives here too.
// lib/odds.ts
import type { ApiEnvelope, OddsEvent } from './types';
const BASE = 'https://krokodds.com.au/api/v1';
const REVALIDATE_SECONDS = 60;
export async function fetchAflOdds(): Promise<OddsEvent[]> {
const res = await fetch(`${BASE}/odds-feed/sports/afl?limit=50`, {
headers: { 'X-API-Key': process.env.KROK_API_KEY ?? '' },
next: { revalidate: REVALIDATE_SECONDS },
});
if (!res.ok) {
// Fail loudly. An empty array here would look identical to "no games".
throw new Error(`odds feed returned ${res.status}`);
}
const json = (await res.json()) as ApiEnvelope<OddsEvent[]>;
return json.data ?? [];
}That next: { revalidate: 60 } is the most important line in the file. It turns unlimited page views into at most one upstream request per minute, shared across every visitor and every server instance that hits the same cache entry.
Keep the arithmetic in a pure module so it is trivially testable and reusable.
// lib/analysis.ts
import type { Market, Price } from './types';
export interface BestPrice {
selection: string;
price: number;
bookmaker: string;
capturedAt: string;
}
const STALE_MS = 15 * 60 * 1000;
function isFresh(p: Price): boolean {
return Date.now() - new Date(p.captured_at).getTime() < STALE_MS;
}
export function bestPrices(market: Market): BestPrice[] {
return market.selections.map((sel) => {
const fresh = sel.prices.filter(isFresh);
const pool = fresh.length > 0 ? fresh : sel.prices;
const best = pool.reduce((a, b) => (b.price > a.price ? b : a));
return {
selection: sel.name,
price: best.price,
bookmaker: best.bookmaker,
capturedAt: best.captured_at,
};
});
}
/** Sum of implied probabilities, as a percentage. */
export function bookPercentage(prices: number[]): number {
return prices.reduce((sum, p) => sum + 1 / p, 0) * 100;
}
export function marginPct(prices: number[]): number {
return Math.max(0, bookPercentage(prices) - 100);
}
export function arbProfitPct(prices: number[]): number | null {
const pct = bookPercentage(prices);
return pct < 100 ? (100 / pct - 1) * 100 : null;
}The staleness filter matters more than it looks. A bookmaker whose scraper broke forty minutes ago will still return its last price, and that price will frequently be the "best" one — because the market moved and it did not. Filtering on capture time keeps ghost prices out of your best-price column.
If you are going to show an arbitrage badge, you have to handle this. Ladbrokes and Neds run on the same Entain feed; a price gap between them is not tradeable.
// lib/groups.ts
const GROUPS: Record<string, string> = {
ladbrokes: 'entain', neds: 'entain', betstar: 'entain',
sportsbet: 'flutter',
betr: 'betr', betright: 'betr', boombet: 'betr',
tab: 'tabgroup', unibet: 'tabgroup', tabtouch: 'tabgroup',
};
export function sameGroup(a: string, b: string): boolean {
// The exchange is an independent price even under a shared owner.
if (a.includes('betfair') || b.includes('betfair')) return false;
const ga = GROUPS[a.toLowerCase()];
const gb = GROUPS[b.toLowerCase()];
return Boolean(ga) && ga === gb;
}// app/page.tsx
import { fetchAflOdds } from '@/lib/odds';
import { bestPrices, bookPercentage, marginPct, arbProfitPct } from '@/lib/analysis';
import { sameGroup } from '@/lib/groups';
import OddsTable from '@/components/OddsTable';
export const revalidate = 60;
export default async function Page() {
const events = await fetchAflOdds();
const rows = events
.map((ev) => {
const h2h = ev.markets.find((m) => m.key === 'h2h');
if (!h2h || h2h.selections.length < 2) return null;
const best = bestPrices(h2h);
const prices = best.map((b) => b.price);
const books = best.map((b) => b.bookmaker);
const crossGroup = !sameGroup(books[0], books[1]);
return {
eventId: ev.event_id,
commenceTime: ev.commence_time,
home: ev.home_team,
away: ev.away_team,
best,
bookPct: bookPercentage(prices),
margin: marginPct(prices),
arb: crossGroup ? arbProfitPct(prices) : null,
bookmakerCount: new Set(
h2h.selections.flatMap((s) => s.prices.map((p) => p.bookmaker))
).size,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null)
.sort((a, b) => a.commenceTime.localeCompare(b.commenceTime));
return (
<main className="mx-auto max-w-6xl p-6">
<h1 className="text-2xl font-bold mb-1">AFL Odds Comparison</h1>
<p className="text-sm text-zinc-400 mb-6">
Best available price across every Australian bookmaker. Updated every 60 seconds.
</p>
<OddsTable rows={rows} />
</main>
);
}This is the only client component, and it exists purely so sorting does not require a round trip.
// components/OddsTable.tsx
'use client';
import { useState, useMemo } from 'react';
type Row = {
eventId: string;
commenceTime: string;
home: string;
away: string;
best: { selection: string; price: number; bookmaker: string }[];
bookPct: number;
margin: number;
arb: number | null;
bookmakerCount: number;
};
type SortKey = 'time' | 'margin';
export default function OddsTable({ rows }: { rows: Row[] }) {
const [sort, setSort] = useState<SortKey>('time');
const sorted = useMemo(() => {
const copy = [...rows];
if (sort === 'margin') copy.sort((a, b) => a.margin - b.margin);
else copy.sort((a, b) => a.commenceTime.localeCompare(b.commenceTime));
return copy;
}, [rows, sort]);
return (
<>
<div className="flex gap-2 mb-4">
<button
onClick={() => setSort('time')}
className={sort === 'time' ? 'font-bold' : 'text-zinc-400'}
>
By start time
</button>
<button
onClick={() => setSort('margin')}
className={sort === 'margin' ? 'font-bold' : 'text-zinc-400'}
>
By lowest margin
</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-zinc-400">
<th className="py-2">Match</th>
<th>Best home</th>
<th>Best away</th>
<th>Books</th>
<th>Margin</th>
</tr>
</thead>
<tbody>
{sorted.map((r) => (
<tr key={r.eventId} className="border-t border-zinc-800">
<td className="py-3">
<div>{r.home} v {r.away}</div>
<div className="text-xs text-zinc-500">
{new Date(r.commenceTime).toLocaleString('en-AU', {
timeZone: 'Australia/Melbourne',
weekday: 'short', hour: 'numeric', minute: '2-digit',
})}
</div>
</td>
{r.best.slice(0, 2).map((b) => (
<td key={b.selection}>
<span className="font-semibold text-emerald-400">
${b.price.toFixed(2)}
</span>
<span className="block text-xs text-zinc-500">{b.bookmaker}</span>
</td>
))}
<td>{r.bookmakerCount}</td>
<td>
{r.arb !== null ? (
<span className="rounded bg-emerald-600 px-2 py-0.5 text-xs text-white">
ARB +{r.arb.toFixed(2)}%
</span>
) : (
<span>{r.margin.toFixed(2)}%</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
);
}The server cache revalidates every 60 seconds, but an open browser tab will keep showing the HTML it was served. Add a small refresher:
// components/AutoRefresh.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function AutoRefresh({ seconds = 60 }: { seconds?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), seconds * 1000);
return () => clearInterval(id);
}, [router, seconds]);
return null;
}Because router.refresh() re-runs the server component and the server component hits the Next.js data cache, this costs you nothing extra upstream.
| Decision | Why | Cost impact |
|---|---|---|
| Fetch in a server component | Key stays server-side; one fetch serves all users | Large |
revalidate: 60 | Caps upstream calls at 60/hour regardless of traffic | Large |
| Single wide request over per-match requests | One call returns the whole round | Large |
| Client-side sort/filter | Interaction without refetching | Medium |
| Staleness filter on prices | Prevents dead scrapers producing fake best prices | Accuracy, not cost |
| Throwing on non-200 | An outage looks like an outage, not an empty round | Accuracy |
/v1/odds-history and render a small SVG per row./v1/gameday/props extends the same table pattern to individual player markets./v1/stream/opportunities once you need sub-minute reaction.If you want to see the finished version of this pattern running at scale before you build it, the Krok Odds AFL board is the same computation across 140+ books with movement history and opportunity detection layered on top.
The Krok Odds API covers AFL, NRL, racing and 30+ other competitions across 140+ Australian bookmakers. Free tier, no card required.
Read the API docs →
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.