PuntersEdge Developers
Getting Started with the PuntersEdge API
PuntersEdge API delivers live and historical odds from Australian bookmakers — 13 on racing, 6 on sports. Start with a free key, make your first call in under two minutes.
1. Get a free API key
Sign up at the API Platform page to request your free API key. We email a verification link — click it and your key is active immediately and emailed to you. The free tier provides 3,000 credits per month, capped at 30 requests per minute — enough for exploration and lightweight integrations.
Pick a starting point
Choose what you are building to see the endpoint to start with, a runnable example, what it costs and which plan it needs. In your console the same steps are ticked off from your real requests.
Start with GET /v1/racing/results, then GET /v1/racing/price-history
A list of finished races with their placings, each carrying a race_id. Price history for one race_id returns every bookmaker's opening and closing price for each runner, so the two join on race_id and runner number.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
# 1. Final results from the last 12 hours (Australian racing runs roughly 00:00-14:00 UTC).
races = [r for r in get("/v1/racing/results", hours_back=12, country="AU",
status="final", limit=20).json() if r.get("race_id")]
if not races:
raise SystemExit("No final results in the last 12 hours yet. Try again during racing hours.")
race = races[0]
# 2. That race's prices, per runner per bookmaker (races under 24 hours old: every plan).
history = get("/v1/racing/price-history", race_id=race["race_id"], include_points="false").json()
by_number = {r["number"]: r for r in history["runners"]}
# 3. Join on runner number: finishing position against opening and closing prices.
print(race["venue"], "R%s" % race["race_number"])
for p in race["placings"]:
books = by_number.get(p["number"], {}).get("bookmakers", [])
closes = [b["close_price"] for b in books if b.get("close_price")]
opens = [b["open_price"] for b in books if b.get("open_price")]
print(p["position"], p["name"],
"open %.2f" % max(opens) if opens else "open n/a",
"close %.2f" % max(closes) if closes else "close n/a")
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
// 1. Final results from the last 12 hours (Australian racing runs roughly 00:00-14:00 UTC).
const results = await (await get("/v1/racing/results",
{ hours_back: 12, country: "AU", status: "final", limit: 20 })).json();
const race = results.find((r) => r.race_id);
if (!race) throw new Error("No final results in the last 12 hours yet. Try again during racing hours.");
// 2. That race's prices, per runner per bookmaker (races under 24 hours old: every plan).
const history = await (await get("/v1/racing/price-history",
{ race_id: race.race_id, include_points: "false" })).json();
const byNumber = new Map(history.runners.map((r) => [r.number, r]));
// 3. Join on runner number: finishing position against opening and closing prices.
console.log(race.venue, `R${race.race_number}`);
for (const p of race.placings) {
const books = byNumber.get(p.number)?.bookmakers ?? [];
const close = Math.max(...books.map((b) => b.close_price ?? 0));
const open = Math.max(...books.map((b) => b.open_price ?? 0));
console.log(p.position, p.name, "open", open || "n/a", "close", close || "n/a");
}
- Credits
/v1/racing/results2 credits;/v1/racing/price-history5 credits. Refused requests and empty[]responses cost 0.- Access
- Results and price history for races in the last 24 hours: every plan, Free included.
- Price history on older races: Standard and above.
- The closing-line archive (one call for a whole day): Standard and above.
- Licence
- Personal use. Terms section 8.1 says:
You may use the data for yourself: your own betting decisions, your own models, research, backtesting and dashboards you look at. This licence does not cover using the data in a business, doing client work with it, building it into a product you sell, or putting our prices in front of other people.
Read 8.1 in full - Done when
- Fetch a race's prices (price-history or closing-lines) and its result within 24 hours of each other, both returning data.
- Next step
- Open the research notebook starter: a sample day joined end to end, with unmatched races and scratchings reported. Racing research notebook (Python / Jupyter)
- Whole project
- The Research kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
Start with GET /v1/racing/next-to-go
Upcoming races with every runner's price from each bookmaker. Each race carries data_age_seconds and stale, and each price its own age_seconds, so a page can say how fresh it is instead of guessing.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
races = get("/v1/racing/next-to-go", country="AU", include_unresolved="true",
num_races=10).json()
if not races:
raise SystemExit("No Australian races in the next 24 hours: show an empty state, not an error.")
for race in races:
age = race.get("data_age_seconds")
flag = " (stale)" if race.get("stale") else ""
print(f"{race['venue']} R{race['race_number']} {race['start_time']} oldest price {age}s{flag}")
for runner in race["runners"]:
quotes = [(b["win_price"], b["key"]) for b in runner["bookmakers"] if b.get("win_price")]
if quotes:
price, book = max(quotes)
print(f" {runner['number']:>2} {runner['name']:<24} best {price:.2f} at {book}"
f" ({len(quotes)} books)")
# Showing these prices to other people needs the display licence and a
# "Prices by PuntersEdge" credit: see the permissions on this card.
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
const races = await (await get("/v1/racing/next-to-go",
{ country: "AU", include_unresolved: "true", num_races: 10 })).json();
if (races.length === 0) {
console.log("No Australian races in the next 24 hours: show an empty state, not an error.");
}
for (const race of races) {
const flag = race.stale ? " (stale)" : "";
console.log(`${race.venue} R${race.race_number} ${race.start_time} oldest price ${race.data_age_seconds}s${flag}`);
for (const runner of race.runners) {
const quotes = runner.bookmakers.filter((b) => b.win_price).map((b) => [b.win_price, b.key]);
if (quotes.length === 0) continue;
const [price, book] = quotes.sort((a, b) => b[0] - a[0])[0];
console.log(` ${runner.number} ${runner.name} best ${price.toFixed(2)} at ${book} (${quotes.length} books)`);
}
}
// Showing these prices to other people needs the display licence and a
// "Prices by PuntersEdge" credit: see the permissions on this card.
- Credits
/v1/racing/next-to-go2–4 credits, depending on how much the response holds. Refused requests and empty[]responses cost 0.- Access
- Calling the endpoint: every plan, Free included.
- Showing the prices to other people: Plus or above, with a visible Prices by PuntersEdge credit on the plans that require it.
- Licence
- Display with attribution, from Plus. Terms section 8.1 says:
You may show our odds to your own users, including your own paying subscribers, provided every surface that displays them carries a visible Prices by PuntersEdge credit linking to puntersedge.online. The commercial permission this carries is scoped to the product doing the displaying. It does not permit passing the data to a third party, or offering it as a feed, an export or an API of your own.
The credit is required on Plus, Business; not on Hobby, Standard, Platform, Scale. Read 8.1 in full - Done when
- Refresh a live board: 3 or more live-price requests that return data, spread over at least 10 minutes on one day.
- Next step
- Open the odds comparison website starter: server-side key, one shared cache for every visitor, freshness and attribution done. Odds comparison website (Node.js)
- Whole project
- The Product kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
Start with GET /v1/racing/results
Every race for a date, paged with limit and offset (X-Limit-Applied says the page size used). status separates final results from interim ones, so a nightly job knows what to fetch again.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
import datetime as dt
import json
# Run once a day from a scheduler: yesterday's results (Australian Eastern date), every page.
day = (dt.datetime.now(dt.timezone(dt.timedelta(hours=10))) - dt.timedelta(days=1)).date().isoformat()
rows, offset = [], 0
while True:
r = get("/v1/racing/results", date=day, country="AU", limit=200, offset=offset)
page = r.json()
rows += page
size = int(r.headers.get("X-Limit-Applied", 200))
if len(page) < size:
break
offset += size
with open(f"results-{day}.json", "w") as fh: # or INSERT into your own database
json.dump(rows, fh)
final = sum(1 for x in rows if x["status"] == "final")
print(f"{day}: {len(rows)} races, {final} final, {len(rows) - final} still interim or abandoned")
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
import { writeFile } from "node:fs/promises";
// Run once a day from a scheduler: yesterday's results (Australian Eastern date), every page.
const aest = new Date(Date.now() + 10 * 3600e3 - 24 * 3600e3);
const day = aest.toISOString().slice(0, 10);
const rows = [];
for (let offset = 0; ; ) {
const res = await get("/v1/racing/results", { date: day, country: "AU", limit: 200, offset });
const page = await res.json();
rows.push(...page);
const size = Number(res.headers.get("X-Limit-Applied") ?? 200);
if (page.length < size) break;
offset += size;
}
await writeFile(`results-${day}.json`, JSON.stringify(rows)); // or INSERT into your database
const final = rows.filter((x) => x.status === "final").length;
console.log(`${day}: ${rows.length} races, ${final} final, ${rows.length - final} still interim or abandoned`);
- Credits
/v1/racing/results2 credits per page. Refused requests and empty[]responses cost 0.- Access
- Dated results pulls: every plan.
- Closing lines for a date: Standard and above.
- Results pushed to you as they go final (race.result webhooks): Plus and above.
- Daily bulk files: Business and above.
- Using the data inside a business: Business or above (the commercial licence).
- Licence
- Commercial use, from Business. Terms section 8.1 says:
You may use the data commercially across your business: internal systems, trading and pricing decisions, client work, analysis you charge for, and products you sell. Where a product shows our prices to end users, the attribution requirement in section 8.3 applies to those surfaces unless your plan is exempt from it there. This licence does not permit handing the data on as a feed, an export or an API of your own.
Read 8.1 in full - Done when
- Run a scheduled pull: the same batch endpoint (results, closing-lines, events) returning data at least an hour apart, or a delivered webhook, or a bulk file.
- Next step
- Open the results tracker starter: polling with safe retries and de-duplication, and a webhook receiver for when your plan includes webhooks. Results tracker (Python)
- Whole project
- The Business kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
If a request fails
- No key yet, or the email has not arrived
- Your key is issued when you click the confirmation link. Submitting the signup form again with the same address sends a fresh link.
- 401 Unauthorized
- The key is missing, mistyped or has been replaced. Send it in the
X-API-Keyheader; the response says which it is. A console login is not an API key. - 403 Forbidden
- Your plan does not include that feature. The response names the plans that do, nothing is charged, and upgrading keeps the same key.
- 402 Payment Required
- This month's credits are used up. Requests are refused, and not charged, until the reset at 00:00 UTC on the 1st. The response names the plan that would cover your use.
- 422 Unprocessable
- A parameter is wrong or not recognised; the response names it. Nothing is charged.
- 429 Too Many Requests
- Over your plan's requests-per-minute limit. Wait the number of seconds in
Retry-Afterand space requests out. - 500, 502 or 503
- Our side, not your code. Retry with a growing delay, and see the status page, which reports the API and each data feed separately.
- An empty list,
[] - Nothing to return at that moment, not an error, and it costs 0 credits. Australian racing runs roughly 00:00 to 14:00 UTC; outside those hours next-to-go is short or empty.
2. Your first odds request
Pass your API key in the X-API-Key header. All responses are JSON.
# Next races — every covered bookmaker's price per runner
curl -H "X-API-Key: YOUR_KEY" \
"https://api.puntersedge.online/v1/racing/next-to-go?num_races=5&country=AU"
# Racing — best price per runner only
curl -H "X-API-Key: YOUR_KEY" \
"https://api.puntersedge.online/v1/racing/best-odds?categories=horse&country=AU"
# Sports use the same header and shape
curl -H "X-API-Key: YOUR_KEY" \
"https://api.puntersedge.online/v1/sports/nrl/odds?markets=h2h"
# Horses only, and more of them
curl -H "X-API-Key: YOUR_KEY" \
"https://api.puntersedge.online/v1/racing/next-to-go?num_races=10&categories=horse&country=AU"
Keep country=AU on racing calls while you are getting started: without it the next races to jump are often overseas meetings priced by a single bookmaker, which is not what the API is for and not what the plan cards describe.
Read the headers as well as the body: every billed call carries X-Credits-Cost, X-Credits-Used, X-Credits-Limit and X-Credits-Remaining, so your own request log shows your balance without an extra call. GET /v1/usage is free and returns the same numbers with a projection to month end. The full list is on the rate limits & credits page.
The same first request from Python and JavaScript, nothing to install beyond requests. Both run on your own machine or server and read the key from the PUNTERSEDGE_API_KEY environment variable (export PUNTERSEDGE_API_KEY="your-key-here"), so it never sits in source:
# Python 3 — pip install requests
import os
import requests
r = requests.get(
"https://api.puntersedge.online/v1/racing/next-to-go",
params={"num_races": 5, "country": "AU"},
headers={"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]},
timeout=15,
)
r.raise_for_status()
for race in r.json():
print(race["venue"], "R%s" % race["race_number"], race["category"])
for runner in race["runners"][:3]:
quoted = [b for b in runner["bookmakers"] if b.get("win_price")]
if quoted:
best = max(quoted, key=lambda b: b["win_price"])
print(" ", runner["name"], best["win_price"], "@", best["key"])
// JavaScript — server-side: Node 18+, Deno or Bun (fetch is built in).
// On Deno, read the key with Deno.env.get("PUNTERSEDGE_API_KEY").
const res = await fetch(
"https://api.puntersedge.online/v1/racing/next-to-go?num_races=5&country=AU",
{
headers: { "X-API-Key": process.env.PUNTERSEDGE_API_KEY },
signal: AbortSignal.timeout(10_000),
},
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
for (const race of await res.json()) {
console.log(race.venue, `R${race.race_number}`, race.category);
for (const runner of race.runners.slice(0, 3)) {
const quoted = runner.bookmakers.filter((b) => b.win_price);
if (quoted.length) {
const best = quoted.reduce((a, b) => (b.win_price > a.win_price ? b : a));
console.log(" ", runner.name, best.win_price, "@", best.key);
}
}
}
Prefer a typed client? pip install puntersedge (Python guide) or npm install puntersedge (TypeScript client), and the MCP server puts the same endpoints inside Claude and Cursor with no code at all.
Showing prices on a public website
Your API key stays on a server. Anything sent to a browser can be read by whoever loads the page, so a key in front-end JavaScript or a mobile app is a published key, and anyone holding it can spend your credits (terms, section 4.2). There are two supported ways to put prices in front of visitors:
- A small proxy route on your own server. It holds the key, caches the response for a few seconds and returns only the fields the page needs. The page calls your route; your route calls PuntersEdge.
- The free embeddable widget.
/widget/racingis an iframe with no key and no signup: the next Australian races with the best price per runner and a Powered by PuntersEdge credit. The greyhound, harness, thoroughbred and sports versions, and the theme, layout and limit options, are on the widgets page. On Platform, the white-label widget shows the same prices in your colours, with your bookmaker links and no PuntersEdge credit.
// server.mjs: Node 18+, no dependencies. The key never leaves this process.
import { createServer } from "node:http";
const UPSTREAM = "https://api.puntersedge.online/v1/racing/next-to-go?num_races=5&categories=horse&country=AU";
let cache = { at: 0, job: null };
async function load() {
const res = await fetch(UPSTREAM, {
headers: { "X-API-Key": process.env.PUNTERSEDGE_API_KEY },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`PuntersEdge ${res.status}: ${await res.text()}`);
// Only what the page shows: venue, race, jump time, best win price per runner.
return JSON.stringify((await res.json()).map((r) => ({
venue: r.venue, race: r.race_number, starts: r.start_time,
runners: r.runners
.map((x) => ({ name: x.name, best: Math.max(0, ...x.bookmakers.map((b) => b.win_price ?? 0)) }))
.filter((x) => x.best > 0),
})));
}
createServer(async (req, res) => {
if (req.url !== "/prices") return res.writeHead(404).end();
if (!cache.job || Date.now() - cache.at > 10_000) {
cache = { at: Date.now(), job: load() }; // one upstream call per 10 seconds
}
try {
const body = await cache.job;
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
} catch (err) {
cache.job = null; // try upstream again on the next request
console.error(err); // the detail stays in your logs
res.writeHead(502, { "Content-Type": "application/json" }).end('{"error":"prices unavailable"}');
}
}).listen(3000);
The page then calls fetch("/prices") on your own site, with no key anywhere in it. Every visitor inside the same ten seconds shares one upstream call, so the cost is one call per cache window while the page has visitors, not one per visitor; lengthen the window to suit your plan. Which plan covers showing prices to your own users is set out in section 8.1 of the terms.
Runtime support and key safety are separate questions: fetch and the TypeScript client run in Node, Deno, Bun and browsers, but a browser is not somewhere a key can live.
3. Supported workflows
- Live odds polling — Racing polling is per book and it is not uniform. Measured over the trailing 7 days to 2026-11-07 through the 10:00–21:00 AEST card, the median age of a connector's last successful read is 6–24s for eighteen of the eighteen served books (every racing market, not only Australian books). There is no single racing poll interval to quote: the slowest served book is 4x the fastest, so any blanket figure is wrong for a named book. Every quote carries its own age_seconds — that is the number to gate on.
- Sports polling — sports markets move far more slowly than racing, and every market carries a
qualityobject with its ownage_seconds. Gate a refetch on that age rather than on a fixed interval, or you spend credits on an unchanged payload. - Best-price detection — use
/best-odds/{sport}to get the top price per outcome across all covered bookmakers, plus an arb flag. - Racing next-to-go — horses, greyhounds and harness across the 13 Australian fixed-odds books. Betfair Exchange and Pinnacle are ingested for internal reference and withheld from customer responses, so a customer key never receives those keys.
- Historical data — see the historical racing odds API for past prices and results. The two sports history endpoints are on Standard and above; Free and Hobby get a
403.
4. Coverage summary
Measured 2026-11-07 over a trailing 7-day window, 13 Australian bookmakers quoted racing prices a customer key can read. 14 connectors quoted at all: the difference is Betfair Exchange and Pinnacle, ingested for internal reference and withheld from customer responses. Quote the served figure unless you mean the other one.
Full matrix at Australian Bookmakers Coverage.
- Racing — 13 books: TABtouch, Sportsbet, TAB, PointsBet, Palmerbet, BetRight, NextBet (formerly PlayUp), Unibet, Ladbrokes, Neds, bet365, BetDeluxe and Betr. Horses, greyhounds, harness.
- Sports — 6 books across 25 queryable sport keys. Measured 2026-11-07 over the trailing 7 days, NBA, Rugby Union, NFL, NBL, College Football, AFLW, EPL, A-League, WNBL, NRL, WNBA, NHL, Big Bash, AFL and NRLW carry more than one customer-served bookmaker on the majority of fixtures; on Tennis WTA, Tennis ATP, Soccer (Other Competitions), Cricket (Other Competitions), Basketball (Other Competitions), MMA/UFC, A-League Women, Test Cricket and MLB you are usually looking at a single price. The rule is one line — a sport is multi_book when at least 50% of its quoted fixtures in the window carried two or more customer-served bookmakers, single_book when it is below that, seasonal when no served book quoted any fixture, and not_queryable when the API 404s the key — so a sport is in exactly one of those lists. Check the live coverage map before you build on a single sport.
5. Pricing plans
- Free: 3,000 credits/month — no card required
- Hobby: 7,500 credits/month — $9 AUD/month
- Standard: 75,000 credits/month — $29 AUD/month
- Plus: 140,000 credits/month — $49 AUD/month
- Business: 300,000 credits/month — $99 AUD/month
- Platform: 1.5M credits/month — $199 AUD/month
- Scale: 5M credits/month — $249 AUD/month
Calls cost 0–100 credits depending on the endpoint — /v1/usage and /v1/health are free, /v1/sports/{key}/odds costs 1 per market type, the history and price-history endpoints cost 5 credits, and the ceiling is a bulk CSV export of /v1/racing/closing-lines. See the full credit table. Upgrade at any time on the platform page.
API reference for the endpoints in this guide
Hit something the API can't do yet? An endpoint, a bookmaker, a market — ask for it. Requests from people mid-integration are the ones that get built.