The PuntersEdge API serves live Australian bookmaker odds — horse, greyhound and harness racing plus AFL, NRL, NBA, tennis and cricket — as plain REST JSON. This guide takes you from a first call that needs no signup, through the racing and best-odds endpoints, to production concerns like retries, rate limits and credit accounting.
Everything here is plain JavaScript. There is no SDK requirement, no build step and no dependency to install — Node 18 shipped a global fetch, and that is the whole toolchain.
1. Your first call — no API key, no signup
Before you sign up for anything, prove the data is what you want. The API exposes a keyless sandbox at /v1/demo/racing/next-to-go. Save this as first-call.mjs and run node first-call.mjs:
// Node 18+. No dependencies, no API key, no signup. const res = await fetch("https://api.puntersedge.online/v1/demo/racing/next-to-go"); const { races } = await res.json(); for (const race of races) { console.log(`\n${race.venue} R${race.race_number} (${race.category})`); for (const runner of race.runners.slice(0, 3)) { const best = runner.bookmakers.reduce((a, b) => (b.win_price > a.win_price ? b : a)); console.log(` ${runner.name.padEnd(22)} ${String(best.win_price).padStart(6)} ${best.key}`); } }
That prints live races and real prices immediately:
Angle Park R1 (greyhound) Redeemer Lee 13 pointsbetau Regal Mack 2.7 tab Bet On Ace 41 pointsbetau Hobart R1 (greyhound) Leica Phoenix 2.3 tab Indy Gia 6.25 betright
There are three keyless sandbox endpoints in total: /v1/demo/racing/next-to-go, /v1/demo/best-odds (optional ?sport=) and /v1/demo/book-sport (which needs both ?book= and ?sport= — note it is book, not bookmaker). /v1/uptime is also public.
{ demo, note, races } — but the keyed /v1/racing/next-to-go returns a bare JSON array. Same for /v1/demo/best-odds (object with events) versus /v1/best-odds/{sport} (bare array). Prototype on the sandbox by all means, but expect to delete one layer of destructuring when you go live.2. Get a free API key
Create a key at the API platform — the free tier is 3,000 credits a month and takes no credit card. Every authenticated request carries an X-API-Key header. Read it from an environment variable rather than hard-coding it:
# macOS / Linux export PE_API_KEY="your-key-here" # or with a .env file and Node 20.6+, no library needed: node --env-file=.env app.mjs
3. Next-to-go racing
Racing is the highest-volume market in Australia, and /v1/racing/next-to-go is the endpoint most developers reach for first. It returns the next races to jump with their runners and current win prices per bookmaker — everything a next-to-go board needs in one call.
const API = "https://api.puntersedge.online/v1"; const HEADERS = { "X-API-Key": process.env.PE_API_KEY }; // /racing/next-to-go returns a JSON ARRAY of races — not { races: [...] }. const res = await fetch(`${API}/racing/next-to-go?num_races=5&categories=horse&country=AU`, { headers: HEADERS }); if (!res.ok) throw new Error(`HTTP ${res.status} — ${await res.text()}`); const races = await res.json(); for (const race of races) { console.log(`\n${race.venue} R${race.race_number} (${race.category}) — ${race.start_time}`); for (const runner of race.runners.slice(0, 4)) { const best = runner.bookmakers.reduce((a, b) => (b.win_price > a.win_price ? b : a)); const num = runner.number ?? "-"; // `number` can be null console.log(` ${String(num).padStart(2)} ${runner.name.padEnd(22)} ${String(best.win_price).padStart(6)} (${best.key})`); } }
Useful query parameters: num_races, categories (horse, greyhound, harness — comma-separate for several), bookmakers and country.
Each runner carries a bookmakers array of { key, win_price } objects, with place_price where the book publishes one. There is no pre-merged "best price" on this endpoint — it hands you every book's price so you can do your own comparison, which is exactly what the reduce above does.
runner.number: it is the runner number as the source bookmaker reports it, and it can be null. Treat it as the source's own numbering rather than a guaranteed saddlecloth, and match runners on name when you need certainty.4. Comparing prices across bookmakers
For sports, /v1/best-odds/{sport} does the cross-book merge server-side, so you never have to normalise competing bookmaker feeds yourself. It also returns a bare array; each event carries a selections list of { name, best_price, best_bookmaker }.
// Also a bare array. Each event carries `selections`, already merged across bookmakers. const res = await fetch(`${API}/best-odds/nrl`, { headers: HEADERS }); const events = await res.json(); for (const ev of events) { console.log(`\n${ev.home_team} v ${ev.away_team}`); for (const sel of ev.selections) { console.log(` ${sel.name.padEnd(22)} ${String(sel.best_price).padStart(6)} (${sel.best_bookmaker})`); } if (ev.arb_exists) console.log(` ↳ arb available: ${ev.arb_profit_pct}%`); }
Which prints:
Melbourne Storm v Penrith Panthers Melbourne Storm 3.3 (pointsbetau) Penrith Panthers 1.34 (betright) Canberra Raiders v Brisbane Broncos Canberra Raiders 1.37 (betright) Brisbane Broncos 3.21 (sportsbet)
Sport keys come from GET /v1/sports, which also returns a bare array of { key, title, group, active }. Call it rather than hard-coding a list — sports are added and deactivated without notice, and an unknown sport_key returns a 404 whose body lists the valid keys.
5. Errors, retries and credits
Errors come back as RFC 7807 application/problem+json with four keys: type, title, status and detail.
| Status | Meaning | Retry? |
|---|---|---|
401 | Missing or invalid X-API-Key. | No — fix the key. |
402 | Monthly credit allowance exhausted. | No — waiting will not help. |
403 | Endpoint exists, your plan does not include it. | No. |
404 | Unknown path, or a known path with an unknown key. | No. |
422 | Bad parameters. detail is an array here. | No. |
429 | Per-minute rate limit. Sends Retry-After. | Yes. |
5xx | Upstream problem. | Yes, with backoff. |
detail. It is a string on 401, an array of validation objects on 422, and an object on 429. String(body.detail) renders [object Object] for two of those three, so branch on status and never on the prose.This wrapper covers all of it — status-aware retries, Retry-After, and reading the credit meter the API sends back on every response:
async function getJson(path, { retries = 3 } = {}) { for (let attempt = 0; ; attempt++) { const res = await fetch(`${API}${path}`, { headers: HEADERS }); // The API reports your metering on EVERY response, not only when it refuses you. const credits = res.headers.get("x-credits-remaining"); // "unlimited" on some plans const perMinute = res.headers.get("x-ratelimit-remaining"); if (res.ok) return { data: await res.json(), credits, perMinute }; // Read the body as TEXT first. An nginx 502 is HTML, and res.json() would throw // SyntaxError and hide the real status from you. const text = await res.text(); let problem = null; try { problem = JSON.parse(text); } catch {} // Retry only 429 and 5xx. A 401 or 402 will never fix itself. if ((res.status !== 429 && res.status < 500) || attempt >= retries) { throw Object.assign(new Error(problem?.title ?? `HTTP ${res.status}`), { status: res.status }); } const retryAfter = Number(res.headers.get("retry-after")) || 0; const wait = retryAfter ? retryAfter * 1000 : Math.random() * Math.min(500 * 2 ** attempt, 8000); await new Promise((r) => setTimeout(r, wait)); } } const { data, credits, perMinute } = await getJson("/racing/next-to-go?num_races=3&country=AU"); console.log(`${data.length} races · ${credits} credits left · ${perMinute} requests left this minute`);
The multiplicative jitter on the backoff matters more than it looks: without it, every client that started together retries together forever. The headers worth reading are X-Credits-Cost, X-Credits-Used, X-Credits-Limit, X-Credits-Remaining and X-RateLimit-Limit / X-RateLimit-Remaining. X-Credits-Limit and X-Credits-Remaining are the literal string "unlimited" on unmetered plans, so do not blindly Number() them — you will get NaN and read it as "no credits left".
GET /v1/usage returns the same figures as JSON and costs 0 credits.
6. The official npm package
Everything above is plain fetch with nothing to install, and it stays the supported path — it is the fastest way to see the API work and it has no dependency to keep current. Once the project is real, there is an official client:
npm install puntersedge
puntersedge on npm is the TypeScript and JavaScript client for this API, currently 1.2.1. It has no dependencies, ships both ESM and CommonJS, and its types are generated from the live OpenAPI document, so they cannot drift from the responses this guide describes. It runs on Node 18+, Deno, Bun, Cloudflare Workers and in the browser.
- A typed error per status code — a
402out of credits is not a429rate limit is not a422bad parameter, so you can branch on them instead of parsing prose. - The credit balance read off each response rather than costing you a second call.
- Webhook signature verification that works on every runtime above.
Full method list, install and usage: the TypeScript client page. Python instead? pip install puntersedge (currently 0.3.3) — typed methods over every endpoint plus a puntersedge-arb scanner; see the Python guide.
fetch when you want one file and no dependency; reach for the client when you want types, typed errors and the credit headers parsed for you. Neither is a fallback for the other. Nothing on this page pins a version — the versions above are read from npm and PyPI, so a release lands here the day it ships.7. TypeScript
If you are calling the API directly with fetch, these are the shapes you will want. They mirror the API's OpenAPI schema, which you can always read in full at api.puntersedge.online/openapi.json:
interface RunnerPrice { key: string; win_price: number; place_price?: number } interface Runner { name: string; number: number | null; bookmakers: RunnerPrice[]; } interface NextRace { race_id: string; venue: string | null; race_number: number | null; category: string | null; // "horse" | "greyhound" | "harness" start_time: string; // ISO 8601, UTC country: string | null; runners: Runner[]; stale?: boolean; data_age_seconds?: number | null; } // The endpoint returns NextRace[] — a bare array, not a wrapper object. const races: NextRace[] = await (await fetch(`${API}/racing/next-to-go`, { headers: HEADERS })).json();
These interfaces type-check under tsc 5.9 with strict against a live /v1/racing/next-to-go response, and every field is present in NextRaceOut in the OpenAPI schema. There is no @types/ package to install — there is no package.
8. Production tips
- Secrets: read the key from
process.env.PE_API_KEY, never commit it. Node 20.6+ reads a.envfile natively with--env-file. - Cache: odds move fast but not every second. Caching responses for 10–30 seconds keeps you comfortably inside both the credit allowance and the per-minute limit.
- Freshness: most responses carry
data_age_seconds,freshest_age_seconds,staleandstale_bookmakers. Checkstalebefore you show a price to a user rather than assuming every book is live. - Timeouts:
fetchhas no default timeout. Pass anAbortSignal—AbortSignal.timeout(10_000)on Node 18+ — or a slow upstream will hang your worker. - Concurrency: the rate limit is per key per minute. Batch with
Promise.allby all means, but keep the batch smaller thanX-RateLimit-Remaining. - Don't hard-code paths: the OpenAPI document is the source of truth and endpoints get added. Reading it beats guessing.
Next steps
You now have live, multi-bookmaker Australian odds in Node with no dependencies — the data layer behind comparison sites, next-to-go boards, alerting bots and models. From here, browse the full endpoint list or the coverage breakdown by bookmaker and sport.
API reference for the endpoints in this guide
PuntersEdge provides odds data for developers. 18+. Odds for information only — gamble responsibly.