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

PuntersEdge Developers

Node.js and TypeScript quickstart

The Node counterpart to the getting-started guide: a key, a first authenticated call with the fetch built into Node 18+, and the two responses every production client has to tell apart. No package to install — the API is plain REST and JSON, and the endpoints on this page are the whole contract.

Want the typed client instead? npm install puntersedge gives you the official TypeScript and JavaScript client — the same endpoints with generated types, a typed error per status code and the credit headers parsed for you. This page is the version with nothing installed.

1. Get a free API key

Sign up at the API Platform page. A verification link is emailed; clicking it activates the key immediately. The free tier provides 3,000 credits per month at 30 requests/minute, with no credit card. Keep the key in the environment rather than in source:

# macOS / Linux
export PUNTERSEDGE_API_KEY="your-key-here"

# or from a .env file on Node 20.6+, no library needed
node --env-file=.env app.mjs

No key yet? GET https://api.puntersedge.online/v1/demo/racing/next-to-go returns live sample data with no key at all. It wraps its payload in an object ({ races: [...] }) where the keyed endpoint below returns a bare array, so expect to drop one level of destructuring when you switch.

2. Your first request

Send the key as an X-API-Key header. Save this as first-call.mjs and run node first-call.mjs:

const API = "https://api.puntersedge.online/v1";
const headers = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };

// Next races to jump, every book's win price per runner. Returns a bare JSON array.
const res = await fetch(`${API}/racing/next-to-go?num_races=5&categories=horse&country=AU`, {
  headers,
  signal: AbortSignal.timeout(10_000),   // fetch has no default timeout
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);

console.log("credits remaining:", res.headers.get("X-Credits-Remaining"));

for (const race of await res.json()) {
  console.log(`\n${race.venue} R${race.race_number} — ${race.start_time}`);
  for (const runner of race.runners) {
    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}`);
  }
}

Two other endpoints worth a first look: /v1/racing/best-odds merges every bookmaker's price into one best price per runner, and /v1/racing/results returns settled results so you can score those prices against an outcome. Sports work the same way through /v1/sports/{sport}/odds. Both take the same header and return a bare array. The next-to-go call above costs 2 credits (JSON); the per-endpoint table is on /api/pricing.

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. Handle 402 and 429 differently

These are the two responses that get conflated, and a retry loop that treats them alike spins forever. The rule from the errors page:

A small wrapper that gets both right, backs off on 5xx, and hands back the credit headers:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function call(path, { attempts = 4 } = {}) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(`${API}${path}`, { headers, signal: AbortSignal.timeout(10_000) });

    if (res.status === 429) {                                  // too fast — wait it out
      const wait = Number(res.headers.get("Retry-After") ?? 60);
      await sleep(wait * 1000);
      continue;
    }
    if (res.status === 402) {                                  // out of credits — do not retry
      const problem = await res.json();
      throw new Error(`credits exhausted: ${problem.detail}`);
    }
    if (res.status >= 500) {                                   // our fault — back off
      await sleep(2 ** i * 1000);
      continue;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);

    return {
      data: await res.json(),
      credits: {
        cost: Number(res.headers.get("X-Credits-Cost")),
        used: Number(res.headers.get("X-Credits-Used")),
        remaining: res.headers.get("X-Credits-Remaining"),    // a number, or "unlimited"
      },
    };
  }
  throw new Error("exhausted retries");
}

const { data, credits } = await call("/best-odds/nrl");
console.log(`${data.length} events, ${credits.cost} credits, ${credits.remaining} left`);

Errors arrive as RFC 7807 application/problem+json; detail is a string for every status except 422, where it is an array of field errors. Every authenticated response, including error responses, carries X-Credits-Cost, X-Credits-Used, X-Credits-Limit and X-Credits-Remaining, and the per-minute state in X-RateLimit-Limit and X-RateLimit-Remaining. Meter from the headers and you never spend a call on /v1/usage.

4. TypeScript

The same code type-checks as-is under "module": "NodeNext" with @types/node; fetch, Headers and Response are global in Node 18+. Types for the two payloads used above. The live OpenAPI schema types the race and event objects but leaves runners and selections as untyped objects, so those two are written from live responses and cover only the fields this page uses:

interface BookPrice { key: string; win_price: number; place_price?: number | null }
interface Runner   { name: string; number: number | null; bookmakers: BookPrice[] }
interface Race     { race_id: string; venue: string | null; race_number: number | null;
                     category: string | null; start_time: string; stale: boolean;
                     runners: Runner[] }

interface Selection { name: string; best_price: number; best_bookmaker: string }
interface BestOddsEvent { id: string; home_team: string; away_team: string;
                          commence_time: string; selections: Selection[];
                          arb_exists: boolean; arb_profit_pct: number }

const races = (await call("/racing/next-to-go?num_races=5&country=AU")).data as Race[];

For a generated client, openapi-typescript against the schema URL produces types for every endpoint in one step. The schema is generated from the running application, so it is never out of date with the deployment behind it.

5. Where next

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.