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 1,500 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`, { headers });
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/best-odds/nrl merges every bookmaker's price into one best price per selection, with an arbitrage flag, and /v1/sports/afl/odds?markets=h2h returns per-bookmaker head-to-head prices. Both take the same header and return a bare array. The next-to-go call above costs 2 credits; the per-endpoint table is on /api/pricing.

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 });

    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")).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.