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.
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".
AuthError | 401/403 — bad key, or the plan does not include this endpoint. Do not retry. |
|---|---|
CreditsExhaustedError | 402 — the month's allowance is spent. Not a rate limit; backing off never clears it. Carries .upgrade. |
RateLimitError | 429 — carries .retryAfter in seconds. Wait that long, repeat the identical call. |
ValidationError | 422 — carries .fields. Refused before billing, so it costs nothing. |
ServerError | 5xx — ours, not yours. Safe to retry with backoff. |
NetworkError | The 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
| Runtimes | Node 18+, Deno, Bun, Cloudflare Workers, Vercel Edge, browsers. Nothing is imported from node:*. |
|---|---|
| Module formats | ESM and CommonJS. require("puntersedge") works. |
| Dependencies | None. Standard fetch, AbortController and WebCrypto only. |
| Types | Generated from the live OpenAPI document. A field marked optional is one the API declares optional. |
| Coverage | Every customer-facing endpoint. Price ingestion, key creation and signup are deliberately not exposed. |
| Timeouts | 30 s by default; override per client or per call, and pass your own AbortSignal. |
| New endpoints | pe.raw("GET", "/v1/...", params) reaches anything, including endpoints newer than your installed version. |
| Licence | MIT. |
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:
- Personal use — Free, Hobby and Standard: Your own betting, research, models and projects.
- Display with attribution — Plus: Show our odds to your own users, paying subscribers included, with a visible credit.
- Commercial use — Business and Unlimited: Use the data anywhere in your business — internal systems, client work, products you sell.
- Redistribution — Platform: Pass our data on inside your product, as a feed, an export or an API.
Full wording on the terms page.
Package: puntersedge (1.0.0 at the time of writing) · Issues: hello@puntersedge.online · Also in Python.