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 1,500 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`, { 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"); 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. What about the npm package?
There is a puntersedge package on npm and you will find it if you search. Don't build against it yet. npm serves 0.1.0, published 2026-06-07, and it predates the response shapes this guide documents. Checked 2026-08-18 against the published tarball's own dist/index.d.ts:
bestOdds()is typed as{ sport_key, events }, with events carryingbest_odds,arb_opportunityandarb_margin. The live endpoint returns a bare array whose events carryselections,arb_existsandarb_profit_pct.nextToGo()is typed as{ races: [...] }withrunner.pricesas an object map. The live endpoint returns a bare array, and each runner carries abookmakersarray of{ key, win_price }.- Of the five methods, only
sports()still matches what the API returns.
The mismatch is not subtle — under tsc 5.9 with strict, the obvious code against 0.1.0 does not compile at all:
error TS2339: Property 'slice' does not exist on type 'BestOddsResponse'. error TS2339: Property 'selections' does not exist on type 'BestOddsEvent'.
Check it yourself before you trust any of this — it is one command:
npm view puntersedge dist-tags # latest: 0.1.0
fetch. Everything above this section is the supported path, the TypeScript interfaces in the next section are the types to use, and there is nothing to install. When a release that tracks the current shapes lands on npm, this section will say so and show it. PyPI also serves puntersedge 0.1.0 and nothing newer (checked 2026-08-18). Unlike this one, its method names and bare-list return shapes still match the live API, so the Python guide's snippet works as printed — but it is the same unmaintained release, so pin it.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.
PuntersEdge provides odds data for developers. 18+. Odds for information only — gamble responsibly.