18+ Only  |  Gambling can be addictive — please gamble responsibly  |  Gambling Help: 1800 858 858  |  GambleAware

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.

What are you building?

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/results 2 credits; /v1/racing/price-history 5 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-go 2–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/results 2 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-Key header; 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-After and 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:

  1. 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.
  2. The free embeddable widget. /widget/racing is 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

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.

5. Pricing plans

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.

This site contains wagering-related analysis and is intended for Australian users aged 18+. Gambling involves risk. Please gamble responsibly.