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

Australian Sports Odds API in Node.js: The Complete Guide

Pull live fixed odds from Australian bookmakers into Node.js — next-to-go racing, AFL and NRL prices, and best-price comparison across the board — with nothing but the fetch that ships with Node 18. Your first working call needs no API key at all. Every snippet here was run on Node 18+ before publishing, and every field name is checked against the live OpenAPI schema at api.puntersedge.online/openapi.json.

Level: BeginnerTime: ~15 minStack: Node.js 18+, zero dependenciesAlso: TypeScript

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.

Read this before you swap in a real key. The sandbox wraps its payload in an object — { 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.

On 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.

StatusMeaningRetry?
401Missing or invalid X-API-Key.No — fix the key.
402Monthly credit allowance exhausted.No — waiting will not help.
403Endpoint exists, your plan does not include it.No.
404Unknown path, or a known path with an unknown key.No.
422Bad parameters. detail is an array here.No.
429Per-minute rate limit. Sends Retry-After.Yes.
5xxUpstream problem.Yes, with backoff.
Watch the shape of 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 carrying best_odds, arb_opportunity and arb_margin. The live endpoint returns a bare array whose events carry selections, arb_exists and arb_profit_pct.
  • nextToGo() is typed as { races: [...] } with runner.prices as an object map. The live endpoint returns a bare array, and each runner carries a bookmakers array 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
So: use plain 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 .env file 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, stale and stale_bookmakers. Check stale before you show a price to a user rather than assuming every book is live.
  • Timeouts: fetch has no default timeout. Pass an AbortSignalAbortSignal.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.all by all means, but keep the batch smaller than X-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.

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