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

PuntersEdge Developers

TypeScript & JavaScript client

The official client for Australian and New Zealand racing and Australian sports odds. No dependencies, both ESM and CommonJS, and types generated from the live OpenAPI document rather than hand-written — so they cannot quietly drift from what the API actually returns.

$ npm install puntersedge

1. First call

Get a free key at the API Platform page. The free tier is 1,500 credits per month at 30 requests/minute, no credit card. Keep it in the environment, not in source.

import { PuntersEdge } from "puntersedge";

const pe = new PuntersEdge({ apiKey: process.env.PUNTERSEDGE_API_KEY });

for (const race of await pe.racing.nextToGo({ numRaces: 5, categories: "horse" })) {
  console.log(race.venue, race.race_number, race.start_time);

  for (const runner of race.runners) {
    const best = runner.bookmakers
      .filter(b => b.win_price && !b.stale)
      .sort((a, b) => b.win_price - a.win_price)[0];
    if (best) console.log(` ${runner.name} ${best.win_price} @ ${best.key}`);
  }
}

console.log(pe.credits?.remaining, "credits left");

No key yet? new PuntersEdge() with no argument still reaches pe.demo.nextToGo() and pe.demo.bestOdds() — the sandbox, which returns real prices truncated to three races, five runners and three bookmakers, at no cost and with no signup. It is there so the example in your README stays runnable by someone who has not signed up yet.

2. Racing is not a sport key

The one thing worth knowing before you write a line. There is no sport_key of horse-racing — racing and sports are separate endpoint families, because they are separate shapes. A race has runners, barriers, a venue and a jump time; a fixture has two teams and a market.

await pe.sports.odds("horse-racing");   // ✗ ValidationError (422)
await pe.racing.nextToGo();            // ✓
pe.racing.*Thoroughbred, greyhound and harness — AU and NZ. Live prices, the change feed, best odds, movers, results, form, premierships, track conditions, the closing-line archive.
pe.sports.*AFL, NRL, NBA and the rest, by sport key from pe.sports.list().
pe.arb.*Cross-book comparison and arbitrage scanning.
pe.webhooks.*Push delivery instead of polling.
pe.health.*Per-connector freshness and public uptime.
pe.demo.*The sandbox — no key, no credits.

3. The credit balance is on the response you already made

Every billed response carries X-Credits-Remaining, and the client parses it. Reading your balance never costs a call, so there is no reason to poll /v1/usage to find out where you stand.

await pe.racing.nextToGo();

pe.credits?.cost;       // 2
pe.credits?.remaining;  // 1088   — or the string "unlimited"
pe.credits?.warning;    // "approaching-limit", once you cross the threshold

// Or a callback on every billed call, for a gauge or a log line:
const pe2 = new PuntersEdge({
  apiKey: KEY,
  onCredits: c => { if (c.warning) console.warn(`${c.remaining} credits left`); },
});

When the balance runs low the API also sends an X-Upgrade-* block naming the plan that would cover your measured usage, what it costs, and the exact request to buy it. The client surfaces that as pe.credits.upgrade and on the 402 itself. Check upgrade.covers: "partial" means the recommended plan is the largest self-serve tier and is still below your need, so buying it will not stop the 402s. The call above costs 2 credits; the per-endpoint table is on /api/pricing.

4. One error class per status, because they need different answers

A 402 means buy credits. A 429 means wait and repeat the same call. A 422 means the request itself is wrong and will fail identically forever. Collapsing those into one Error pushes the branch into a regex over the message — which is how a "rate limited, back off" path quietly starts swallowing "that sport key does not exist".

AuthError401/403 — bad key, or the plan does not include this endpoint. Do not retry.
CreditsExhaustedError402 — the month's allowance is spent. Not a rate limit; backing off never clears it. Carries .upgrade.
RateLimitError429 — carries .retryAfter in seconds. Wait that long, repeat the identical call.
ValidationError422 — carries .fields. Refused before billing, so it costs nothing.
ServerError5xx — ours, not yours. Safe to retry with backoff.
NetworkErrorThe request never completed: DNS, TLS, a timeout or an abort.
import { RateLimitError, CreditsExhaustedError, ValidationError } from "puntersedge";

try {
  await pe.racing.movers({ direction: "firming" });
} catch (err) {
  if (err instanceof RateLimitError) {
    await new Promise(r => setTimeout(r, (err.retryAfter ?? 60) * 1000));
  } else if (err instanceof CreditsExhaustedError) {
    console.error(`Out of credits. ${err.upgrade?.plan}: ${err.upgrade?.url}`);
  } else if (err instanceof ValidationError) {
    for (const f of err.fields) console.error(f.loc.join("."), f.msg);
  }
}

Every error also carries the RFC 7807 body as err.problem, so err.problem.type is a stable URI you can switch on. The catalogue is on the errors page.

Nothing is retried for you

Deliberately. Odds are time-sensitive: a retry hidden inside the client can hand back a price recorded before a move you would have acted on, and can double a billed call without telling you. Retry at the layer that knows whether a stale answer is acceptable — RateLimitError.retryAfter gives you the wait the server asked for.

5. Do not poll next-to-go

The most expensive mistake available. nextToGo re-downloads every race on every call. At a 15-second cadence that is roughly 11,500 credits a day — several times the entire free monthly allowance, burned in twenty-four hours, mostly to learn that nothing changed.

pe.racing.changes() returns only what moved since a timestamp, and hands back the cursor for the next call.

let since = new Date(Date.now() - 60_000).toISOString();

setInterval(async () => {
  const batch = await pe.racing.changes({ since, categories: ["horse", "greyhound"] });

  for (const race of batch.races ?? []) {
    // `changed_runners` is FLAT — one row per runner PER BOOKMAKER, with bookmaker_key
    // on the row. Not the nested runners[].bookmakers[] that nextToGo returns.
    for (const row of race.changed_runners ?? []) applyUpdate(race, row);
  }

  since = batch.server_time;   // the cursor for the next poll
}, 15_000);

server_time sits 30 seconds behind the server clock so an in-flight write is never skipped. That makes the feed at-least-once: you will see the same race twice, so applyUpdate must be idempotent. For anything slower than a few seconds, a webhook is cheaper still.

6. A stale price looks exactly like a live one

A stalled scraper does not return an error — it keeps serving its last value. Every quote carries its own age, so filter before you act on a price:

const usable = runner.bookmakers.filter(b => b.win_price && !b.stale && b.age_seconds < 120);

And pe.health.connectors() reports last_ok per bookmaker, which is how you tell a quiet market from a broken feed. A quiet Tuesday morning and a dead scraper produce the same silence.

7. Webhooks: verify the raw bytes

Every delivery carries X-Webhook-Signature: sha256=<hex>, an HMAC-SHA256 over the exact body bytes. The API signs a canonical serialisation — keys sorted, no whitespace — so JSON.stringify(req.body) produces different bytes and will never match. Hash what you received, before anything parses it.

import { verifyWebhookSignature } from "puntersedge";

app.post("/hook", express.raw({ type: "application/json" }), async (req, res) => {
  const ok = await verifyWebhookSignature({
    body: req.body,                                  // a Buffer, unparsed
    signature: req.header("X-Webhook-Signature"),
    secret: process.env.PUNTERSEDGE_WEBHOOK_SECRET,
  });
  if (!ok) return res.sendStatus(401);

  res.sendStatus(200);                               // ack fast
  queue.push(JSON.parse(req.body.toString("utf8"))); // work later
});

It uses WebCrypto, so the same call works on Node, Deno, Bun, Cloudflare Workers and in the browser. Acknowledge before doing the work: the sender waits ten seconds and retries once, so a slow handler turns one event into two deliveries.

8. Where it runs, and what is in the box

RuntimesNode 18+, Deno, Bun, Cloudflare Workers, Vercel Edge, browsers. Nothing is imported from node:*.
Module formatsESM and CommonJS. require("puntersedge") works.
DependenciesNone. Standard fetch, AbortController and WebCrypto only.
TypesGenerated from the live OpenAPI document. A field marked optional is one the API declares optional.
CoverageEvery customer-facing endpoint. Price ingestion, key creation and signup are deliberately not exposed.
Timeouts30 s by default; override per client or per call, and pass your own AbortSignal.
New endpointspe.raw("GET", "/v1/...", params) reaches anything, including endpoints newer than your installed version.
LicenceMIT.

Why the types are generated

The response shapes are wide — a single race carries twenty-odd fields plus nested runners, per-bookmaker quotes and scratchings — and they gain a field whenever the API ships one. Hand-written types guarantee drift, and a TypeScript type that lies is worse than no type at all: it type-checks the wrong thing, confidently. The generator reads the live schema, so the types are whatever the server actually says.

Connection reuse matters more than anything you can tune

Measured against this API: a cold TLS handshake costs 137 ms against 40.5 ms on a reused connection. Construct one PuntersEdge instance for the life of your process rather than one per request. Responses compress about nine times over, and Node, Deno and Bun request gzip automatically.

9. Before you show prices to other people

Using the data yourself and displaying it to end users are different licences, and it is worth settling before the fetch layer is written:

Full wording on the terms page.

Package: puntersedge (1.0.0 at the time of writing) · Issues: hello@puntersedge.online · Also in Python.

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