Your account, live.
Log in with your email and password — or paste your API key — to see usage, credits remaining, your burn rate and the month-end projection, issue keys, and manage billing and your plan, straight from the API. Nothing is sent to our web server: this page calls api.puntersedge.online directly and what it remembers stays in your browser.
Log in
Forgot your password? Never set one? Use the one-time link, then set a password from your account below. Signing in never changes your API key; a session lasts 30 days on this device.
Or paste your API key
· off by default: a remembered key stays in this browser's storage, so prefer logging in by email above · No key yet? Free tier, no card
Your API account
This key is on file for this account.
Upgrading keeps this exact key. Subscribe on the plans page with the same email shown above, and this key moves onto the paid plan in place — the key string does not change, so there is nothing to swap in your code and no second key or account is created. Paying with a different address is the one thing that makes a duplicate, so keep it to the email shown here.
Choose a plan →Account & billing
Password
Subscription
Promotional emails
Onboarding
What are you building? Your answer only changes what this page shows first; it is saved to your account so you can change it later, or skip it.
Start with GET /v1/racing/results, then GET /v1/racing/price-history
A list of finished races with their placings, each carrying a race_id. Price history for one race_id returns every bookmaker's opening and closing price for each runner, so the two join on race_id and runner number.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
# 1. Final results from the last 12 hours (Australian racing runs roughly 00:00-14:00 UTC).
races = [r for r in get("/v1/racing/results", hours_back=12, country="AU",
status="final", limit=20).json() if r.get("race_id")]
if not races:
raise SystemExit("No final results in the last 12 hours yet. Try again during racing hours.")
race = races[0]
# 2. That race's prices, per runner per bookmaker (races under 24 hours old: every plan).
history = get("/v1/racing/price-history", race_id=race["race_id"], include_points="false").json()
by_number = {r["number"]: r for r in history["runners"]}
# 3. Join on runner number: finishing position against opening and closing prices.
print(race["venue"], "R%s" % race["race_number"])
for p in race["placings"]:
books = by_number.get(p["number"], {}).get("bookmakers", [])
closes = [b["close_price"] for b in books if b.get("close_price")]
opens = [b["open_price"] for b in books if b.get("open_price")]
print(p["position"], p["name"],
"open %.2f" % max(opens) if opens else "open n/a",
"close %.2f" % max(closes) if closes else "close n/a")
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
// 1. Final results from the last 12 hours (Australian racing runs roughly 00:00-14:00 UTC).
const results = await (await get("/v1/racing/results",
{ hours_back: 12, country: "AU", status: "final", limit: 20 })).json();
const race = results.find((r) => r.race_id);
if (!race) throw new Error("No final results in the last 12 hours yet. Try again during racing hours.");
// 2. That race's prices, per runner per bookmaker (races under 24 hours old: every plan).
const history = await (await get("/v1/racing/price-history",
{ race_id: race.race_id, include_points: "false" })).json();
const byNumber = new Map(history.runners.map((r) => [r.number, r]));
// 3. Join on runner number: finishing position against opening and closing prices.
console.log(race.venue, `R${race.race_number}`);
for (const p of race.placings) {
const books = byNumber.get(p.number)?.bookmakers ?? [];
const close = Math.max(...books.map((b) => b.close_price ?? 0));
const open = Math.max(...books.map((b) => b.open_price ?? 0));
console.log(p.position, p.name, "open", open || "n/a", "close", close || "n/a");
}
- Credits
/v1/racing/results2 credits;/v1/racing/price-history5 credits. Refused requests and empty[]responses cost 0.- Access
- Results and price history for races in the last 24 hours: every plan, Free included.
- Price history on older races: Standard and above.
- The closing-line archive (one call for a whole day): Standard and above.
- Licence
- Personal use. Terms section 8.1 says:
You may use the data for yourself: your own betting decisions, your own models, research, backtesting and dashboards you look at. This licence does not cover using the data in a business, doing client work with it, building it into a product you sell, or putting our prices in front of other people.
Read 8.1 in full - Done when
- Fetch a race's prices (price-history or closing-lines) and its result within 24 hours of each other, both returning data. Counted from your requests, not from copying this example.
- Next step
- Open the research notebook starter: a sample day joined end to end, with unmatched races and scratchings reported. Racing research notebook (Python / Jupyter)
- Whole project
- The Research kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
Start with GET /v1/racing/next-to-go
Upcoming races with every runner's price from each bookmaker. Each race carries data_age_seconds and stale, and each price its own age_seconds, so a page can say how fresh it is instead of guessing.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
races = get("/v1/racing/next-to-go", country="AU", include_unresolved="true",
num_races=10).json()
if not races:
raise SystemExit("No Australian races in the next 24 hours: show an empty state, not an error.")
for race in races:
age = race.get("data_age_seconds")
flag = " (stale)" if race.get("stale") else ""
print(f"{race['venue']} R{race['race_number']} {race['start_time']} oldest price {age}s{flag}")
for runner in race["runners"]:
quotes = [(b["win_price"], b["key"]) for b in runner["bookmakers"] if b.get("win_price")]
if quotes:
price, book = max(quotes)
print(f" {runner['number']:>2} {runner['name']:<24} best {price:.2f} at {book}"
f" ({len(quotes)} books)")
# Showing these prices to other people needs the display licence and a
# "Prices by PuntersEdge" credit: see the permissions on this card.
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
const races = await (await get("/v1/racing/next-to-go",
{ country: "AU", include_unresolved: "true", num_races: 10 })).json();
if (races.length === 0) {
console.log("No Australian races in the next 24 hours: show an empty state, not an error.");
}
for (const race of races) {
const flag = race.stale ? " (stale)" : "";
console.log(`${race.venue} R${race.race_number} ${race.start_time} oldest price ${race.data_age_seconds}s${flag}`);
for (const runner of race.runners) {
const quotes = runner.bookmakers.filter((b) => b.win_price).map((b) => [b.win_price, b.key]);
if (quotes.length === 0) continue;
const [price, book] = quotes.sort((a, b) => b[0] - a[0])[0];
console.log(` ${runner.number} ${runner.name} best ${price.toFixed(2)} at ${book} (${quotes.length} books)`);
}
}
// Showing these prices to other people needs the display licence and a
// "Prices by PuntersEdge" credit: see the permissions on this card.
- Credits
/v1/racing/next-to-go2–4 credits, depending on how much the response holds. Refused requests and empty[]responses cost 0.- Access
- Calling the endpoint: every plan, Free included.
- Showing the prices to other people: Plus or above, with a visible Prices by PuntersEdge credit on the plans that require it.
- Licence
- Display with attribution, from Plus. Terms section 8.1 says:
You may show our odds to your own users, including your own paying subscribers, provided every surface that displays them carries a visible Prices by PuntersEdge credit linking to puntersedge.online. The commercial permission this carries is scoped to the product doing the displaying. It does not permit passing the data to a third party, or offering it as a feed, an export or an API of your own.
The credit is required on Plus, Business; not on Hobby, Standard, Platform, Scale. Read 8.1 in full - Done when
- Refresh a live board: 3 or more live-price requests that return data, spread over at least 10 minutes on one day. Counted from your requests, not from copying this example.
- Next step
- Open the odds comparison website starter: server-side key, one shared cache for every visitor, freshness and attribution done. Odds comparison website (Node.js)
- Whole project
- The Product kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
Start with GET /v1/racing/results
Every race for a date, paged with limit and offset (X-Limit-Applied says the page size used). status separates final results from interim ones, so a nightly job knows what to fetch again.
import os
import requests
API = "https://api.puntersedge.online"
HEADERS = {"X-API-Key": os.environ["PUNTERSEDGE_API_KEY"]} # export PUNTERSEDGE_API_KEY=... first
def get(path, **params):
r = requests.get(API + path, headers=HEADERS, params=params, timeout=30)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:500]}")
return r
import datetime as dt
import json
# Run once a day from a scheduler: yesterday's results (Australian Eastern date), every page.
day = (dt.datetime.now(dt.timezone(dt.timedelta(hours=10))) - dt.timedelta(days=1)).date().isoformat()
rows, offset = [], 0
while True:
r = get("/v1/racing/results", date=day, country="AU", limit=200, offset=offset)
page = r.json()
rows += page
size = int(r.headers.get("X-Limit-Applied", 200))
if len(page) < size:
break
offset += size
with open(f"results-{day}.json", "w") as fh: # or INSERT into your own database
json.dump(rows, fh)
final = sum(1 for x in rows if x["status"] == "final")
print(f"{day}: {len(rows)} races, {final} final, {len(rows) - final} still interim or abandoned")
// Node 18+ (fetch is built in). Run with: PUNTERSEDGE_API_KEY=... node example.mjs
const API = "https://api.puntersedge.online";
const HEADERS = { "X-API-Key": process.env.PUNTERSEDGE_API_KEY };
if (!HEADERS["X-API-Key"]) throw new Error("Set PUNTERSEDGE_API_KEY first");
async function get(path, params = {}) {
const res = await fetch(API + path + "?" + new URLSearchParams(params), { headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`);
return res;
}
import { writeFile } from "node:fs/promises";
// Run once a day from a scheduler: yesterday's results (Australian Eastern date), every page.
const aest = new Date(Date.now() + 10 * 3600e3 - 24 * 3600e3);
const day = aest.toISOString().slice(0, 10);
const rows = [];
for (let offset = 0; ; ) {
const res = await get("/v1/racing/results", { date: day, country: "AU", limit: 200, offset });
const page = await res.json();
rows.push(...page);
const size = Number(res.headers.get("X-Limit-Applied") ?? 200);
if (page.length < size) break;
offset += size;
}
await writeFile(`results-${day}.json`, JSON.stringify(rows)); // or INSERT into your database
const final = rows.filter((x) => x.status === "final").length;
console.log(`${day}: ${rows.length} races, ${final} final, ${rows.length - final} still interim or abandoned`);
- Credits
/v1/racing/results2 credits per page. Refused requests and empty[]responses cost 0.- Access
- Dated results pulls: every plan.
- Closing lines for a date: Standard and above.
- Results pushed to you as they go final (race.result webhooks): Plus and above.
- Daily bulk files: Business and above.
- Using the data inside a business: Business or above (the commercial licence).
- Licence
- Commercial use, from Business. Terms section 8.1 says:
You may use the data commercially across your business: internal systems, trading and pricing decisions, client work, analysis you charge for, and products you sell. Where a product shows our prices to end users, the attribution requirement in section 8.3 applies to those surfaces unless your plan is exempt from it there. This licence does not permit handing the data on as a feed, an export or an API of your own.
Read 8.1 in full - Done when
- Run a scheduled pull: the same batch endpoint (results, closing-lines, events) returning data at least an hour apart, or a delivered webhook, or a bulk file. Counted from your requests, not from copying this example.
- Next step
- Open the results tracker starter: polling with safe retries and de-duplication, and a webhook receiver for when your plan includes webhooks. Results tracker (Python)
- Whole project
- The Business kit: a ready-to-run starter project for this route, with tests and AI-assistant (MCP) configs.
Your progress
- …Loading your progress from your account…
If a request fails
- No key yet, or the email has not arrived
- Your key is issued when you click the confirmation link. Submitting the signup form again with the same address sends a fresh link.
- 401 Unauthorized
- The key is missing, mistyped or has been replaced. Send it in the
X-API-Keyheader; the response says which it is. A console login is not an API key. - 403 Forbidden
- Your plan does not include that feature. The response names the plans that do, nothing is charged, and upgrading keeps the same key.
- 402 Payment Required
- This month's credits are used up. Requests are refused, and not charged, until the reset at 00:00 UTC on the 1st. The response names the plan that would cover your use.
- 422 Unprocessable
- A parameter is wrong or not recognised; the response names it. Nothing is charged.
- 429 Too Many Requests
- Over your plan's requests-per-minute limit. Wait the number of seconds in
Retry-Afterand space requests out. - 500, 502 or 503
- Our side, not your code. Retry with a growing delay, and see the status page, which reports the API and each data feed separately.
- An empty list,
[] - Nothing to return at that moment, not an error, and it costs 0 credits. Australian racing runs roughly 00:00 to 14:00 UTC; outside those hours next-to-go is short or empty.
Make a call right now
Every example reads the key from the PUNTERSEDGE_API_KEY environment variable, so it never lands in source control or a screenshot. Next-to-go costs 2 credits a call — the free tier covers hourly polling all month. What each endpoint does · Playground · Docs
Build something
A server-side page that compares bookmakers on upcoming races, marks the best price, shows how fresh each price is, and shares one cached API call between every visitor.
Open the starter (Node.js) →Pulls a sample day of closing prices and results, joins them on race id, reports unmatched races, scratchings and incomplete fields, and charts price movement by finishing position.
Open the starter (Python / Jupyter) →Follows chosen races until their results are final, tells interim placings from final ones and a complete field, retries safely and never reports a result twice.
Open the starter (Python) →Back to onboarding · Getting started · Errors and retries · API reference
Referrals
Send another developer to PuntersEdge and you earn 10% of every invoice they pay us, for 12 months. Your code and link are tied to this account and never change.
Your referral code
They type it into the referral box on the signup form — say it out loud, put it in a post, it still counts.
Your referral link
Same code, already filled in for them.
Lost your key?
If you can’t find the key from your welcome email, we can send a fresh one to the address it’s registered to. This issues a new key and retires the old one after a short grace window, so only use it if you no longer have the original — to upgrade, use the panel above, which keeps your key.
Something look wrong? Reply to any email from us — a human reads it.