puntersedge 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -37
- package/dist/cjs/client.js +487 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/credits.js +82 -0
- package/dist/cjs/credits.js.map +1 -0
- package/dist/cjs/errors.js +130 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/http.js +218 -0
- package/dist/cjs/http.js.map +1 -0
- package/dist/cjs/index.js +58 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/types.generated.js +13 -0
- package/dist/cjs/types.generated.js.map +1 -0
- package/dist/cjs/webhook.js +95 -0
- package/dist/cjs/webhook.js.map +1 -0
- package/dist/esm/client.d.ts +480 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/client.js +483 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/credits.d.ts +78 -0
- package/dist/esm/credits.d.ts.map +1 -0
- package/dist/esm/credits.js +78 -0
- package/dist/esm/credits.js.map +1 -0
- package/dist/esm/errors.d.ts +132 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +117 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/http.d.ts +52 -0
- package/dist/esm/http.d.ts.map +1 -0
- package/dist/esm/http.js +214 -0
- package/dist/esm/http.js.map +1 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +23 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.generated.d.ts +1453 -0
- package/dist/esm/types.generated.d.ts.map +1 -0
- package/dist/esm/types.generated.js +12 -0
- package/dist/esm/types.generated.js.map +1 -0
- package/dist/esm/webhook.d.ts +56 -0
- package/dist/esm/webhook.d.ts.map +1 -0
- package/dist/esm/webhook.js +92 -0
- package/dist/esm/webhook.js.map +1 -0
- package/package.json +52 -19
- package/src/client.ts +799 -0
- package/src/credits.ts +131 -0
- package/src/errors.ts +166 -0
- package/src/http.ts +268 -0
- package/src/index.ts +68 -206
- package/src/types.generated.ts +1514 -0
- package/src/webhook.ts +113 -0
- package/dist/index.d.ts +0 -117
- package/dist/index.js +0 -99
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PuntersEdge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,67 +1,313 @@
|
|
|
1
1
|
# puntersedge
|
|
2
2
|
|
|
3
|
-
Official
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Official TypeScript/JavaScript client for the [PuntersEdge Odds API](https://puntersedge.online/api)
|
|
4
|
+
— Australian and New Zealand racing (thoroughbred, greyhound, harness) and Australian sports odds
|
|
5
|
+
from 14 Australian bookmakers, with settled results, form, a permanent market-movement archive and
|
|
6
|
+
signed webhooks.
|
|
6
7
|
|
|
7
8
|
```bash
|
|
8
9
|
npm install puntersedge
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
```ts
|
|
13
|
+
import { PuntersEdge } from "puntersedge";
|
|
14
|
+
|
|
15
|
+
const pe = new PuntersEdge({ apiKey: process.env.PUNTERSEDGE_API_KEY });
|
|
16
|
+
|
|
17
|
+
for (const race of await pe.racing.nextToGo({ numRaces: 5, categories: "horse" })) {
|
|
18
|
+
const best = race.runners
|
|
19
|
+
.flatMap(r => r.bookmakers.map(b => ({ runner: r.name, book: b.key, price: b.win_price })))
|
|
20
|
+
.filter(q => q.price)
|
|
21
|
+
.sort((a, b) => b.price! - a.price!)[0];
|
|
22
|
+
|
|
23
|
+
console.log(`${race.venue} R${race.race_number} best: ${best?.runner} @ ${best?.price} (${best?.book})`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log(`${pe.credits?.remaining} credits left`);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **No dependencies.** Standard `fetch` only.
|
|
30
|
+
- **Runs anywhere.** Node 18+, Deno, Bun, Cloudflare Workers, Vercel Edge, browsers. ESM and CommonJS.
|
|
31
|
+
- **Fully typed.** Every response type is generated from the live OpenAPI document, so the types
|
|
32
|
+
cannot quietly drift from the server.
|
|
33
|
+
- **Errors are typed.** A 402 is not a 429 is not a 422, and each carries what you need to act on it.
|
|
34
|
+
|
|
35
|
+
## Get a key
|
|
36
|
+
|
|
37
|
+
1. Sign up at <https://puntersedge.online/api>. Free tier, no card: 1,500 credits/month, 30 requests/minute.
|
|
38
|
+
2. Click the verification link. The key is shown on screen and emailed to you.
|
|
39
|
+
3. `export PUNTERSEDGE_API_KEY=...`
|
|
40
|
+
|
|
41
|
+
You can explore without a key at all — see [the sandbox](#the-sandbox-no-key-needed).
|
|
42
|
+
|
|
43
|
+
## Racing is not a sport key
|
|
44
|
+
|
|
45
|
+
The single most common first mistake. There is no `sport_key` of `"horse-racing"`.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
await pe.sports.odds("horse-racing"); // ✗ 422
|
|
49
|
+
await pe.racing.nextToGo(); // ✓
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Racing and sports are separate endpoint families because they are separate shapes: a race has
|
|
53
|
+
runners, barriers, a venue and a jump time; a fixture has two teams and a market. They are grouped
|
|
54
|
+
the same way here.
|
|
12
55
|
|
|
13
|
-
|
|
14
|
-
|
|
56
|
+
| | |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `pe.racing.*` | thoroughbred, greyhound, harness — AU and NZ |
|
|
59
|
+
| `pe.sports.*` | AFL, NRL, NBA, and the rest, by sport key |
|
|
60
|
+
| `pe.arb.*` | cross-book comparison and arbitrage scanning |
|
|
61
|
+
| `pe.account.*` | usage, key metadata, rotation, billing |
|
|
62
|
+
| `pe.webhooks.*` | push delivery instead of polling |
|
|
63
|
+
| `pe.health.*` | per-connector freshness, public uptime |
|
|
64
|
+
| `pe.demo.*` | the sandbox: real prices, truncated, no key |
|
|
15
65
|
|
|
16
|
-
|
|
66
|
+
## Credits, read for free
|
|
17
67
|
|
|
18
|
-
|
|
19
|
-
|
|
68
|
+
The API bills per successful request and reports the running balance on **every** response. The
|
|
69
|
+
client parses those headers, so checking your balance never costs a call.
|
|
20
70
|
|
|
21
|
-
|
|
22
|
-
|
|
71
|
+
```ts
|
|
72
|
+
await pe.racing.nextToGo();
|
|
73
|
+
|
|
74
|
+
pe.credits?.cost; // 2
|
|
75
|
+
pe.credits?.remaining; // 1088 — or the string "unlimited"
|
|
76
|
+
pe.credits?.pctUsed; // 27.5
|
|
77
|
+
pe.credits?.warning; // "approaching-limit", once you cross the threshold
|
|
78
|
+
```
|
|
23
79
|
|
|
24
|
-
|
|
25
|
-
const best = await client.bestOdds("afl");
|
|
80
|
+
Prefer this to polling `/v1/usage`: the response you just made already answered the question.
|
|
26
81
|
|
|
27
|
-
|
|
28
|
-
const races = await client.nextToGo(5, "horse");
|
|
82
|
+
For a callback on every billed call — to feed a gauge, or to log:
|
|
29
83
|
|
|
30
|
-
|
|
31
|
-
const
|
|
84
|
+
```ts
|
|
85
|
+
const pe = new PuntersEdge({
|
|
86
|
+
apiKey: KEY,
|
|
87
|
+
onCredits: c => { if (c.warning) console.warn(`${c.remaining} credits left`); },
|
|
88
|
+
});
|
|
32
89
|
```
|
|
33
90
|
|
|
34
|
-
|
|
91
|
+
### What a call costs
|
|
35
92
|
|
|
36
|
-
|
|
93
|
+
| Endpoints | Credits |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `/v1/usage`, `/v1/billing/portal`, the sandbox, `/v1/uptime` | 0 |
|
|
96
|
+
| `sports.list`, `racing.events`, `racing.venues`, `racing.trackConditions`, coverage endpoints | 1 |
|
|
97
|
+
| `sports.odds` | **1 per market requested** |
|
|
98
|
+
| `racing.nextToGo`, `racing.results`, `racing.changes`, `racing.acceptances`, jockey/trainer stats, `arb.bestPrices` | 2 |
|
|
99
|
+
| `racing.bestOdds`, `racing.movers`, form endpoints, `sports.bestOdds`, `arb.sports`, `arb.lines` | 3 |
|
|
100
|
+
| `racing.priceHistory`, `racing.closingLines`, `racing.pricePaths`, sports history and movements | 5 |
|
|
101
|
+
|
|
102
|
+
A malformed request — an unknown sport key, an unrecognised bookmaker, a bad date — is refused
|
|
103
|
+
**before** billing, so a 422 is free.
|
|
37
104
|
|
|
38
105
|
## Errors
|
|
39
106
|
|
|
40
|
-
```
|
|
41
|
-
import {
|
|
107
|
+
```ts
|
|
108
|
+
import {
|
|
109
|
+
PuntersEdgeError, AuthError, CreditsExhaustedError,
|
|
110
|
+
RateLimitError, ValidationError, NotFoundError, ServerError, NetworkError,
|
|
111
|
+
} from "puntersedge";
|
|
42
112
|
|
|
43
113
|
try {
|
|
44
|
-
|
|
45
|
-
} catch (
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
114
|
+
await pe.racing.movers({ direction: "firming" });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err instanceof RateLimitError) {
|
|
117
|
+
// The server told you how long to wait. Wait, then repeat the identical call.
|
|
118
|
+
await new Promise(r => setTimeout(r, (err.retryAfter ?? 60) * 1000));
|
|
119
|
+
} else if (err instanceof CreditsExhaustedError) {
|
|
120
|
+
// Not a rate limit — backing off will not clear it.
|
|
121
|
+
console.error(`Out of credits. ${err.upgrade?.plan} gives ${err.upgrade?.credits}/mo: ${err.upgrade?.url}`);
|
|
122
|
+
if (err.upgrade?.covers === "partial") {
|
|
123
|
+
console.error("…and that is still below your measured need. Email hello@puntersedge.online.");
|
|
124
|
+
}
|
|
125
|
+
} else if (err instanceof ValidationError) {
|
|
126
|
+
// Deterministic: retrying will fail identically forever.
|
|
127
|
+
for (const f of err.fields) console.error(f.loc.join("."), f.msg);
|
|
128
|
+
} else if (err instanceof PuntersEdgeError) {
|
|
129
|
+
console.error(err.status, err.problem?.type, err.message);
|
|
130
|
+
}
|
|
50
131
|
}
|
|
51
132
|
```
|
|
52
133
|
|
|
53
|
-
|
|
134
|
+
Every error carries the RFC 7807 body the API returned, so `err.problem.type` is a stable URI you
|
|
135
|
+
can switch on, documented at [puntersedge.online/developers/errors](https://puntersedge.online/developers/errors).
|
|
136
|
+
|
|
137
|
+
### Nothing is retried for you
|
|
138
|
+
|
|
139
|
+
Deliberately. Odds are time-sensitive: a transparent retry inside the client can hand you a price
|
|
140
|
+
recorded before a move you would have acted on, and it can double a billed call without telling
|
|
141
|
+
you. Retry at the layer that knows whether a stale answer is acceptable.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
async function withRetry<T>(call: () => Promise<T>, attempts = 3): Promise<T> {
|
|
145
|
+
for (let i = 0; ; i++) {
|
|
146
|
+
try {
|
|
147
|
+
return await call();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const retryable = err instanceof RateLimitError || err instanceof ServerError;
|
|
150
|
+
if (!retryable || i >= attempts - 1) throw err;
|
|
151
|
+
const wait = err instanceof RateLimitError
|
|
152
|
+
? (err.retryAfter ?? 60) * 1000
|
|
153
|
+
: 2 ** i * 1000;
|
|
154
|
+
await new Promise(r => setTimeout(r, wait));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Note what is missing from that list: `CreditsExhaustedError` and `ValidationError` are never
|
|
161
|
+
retried, because neither will ever succeed on a second attempt.
|
|
162
|
+
|
|
163
|
+
## Polling a live board
|
|
164
|
+
|
|
165
|
+
Do not loop `nextToGo`. It re-downloads every race every time and bills 2 credits for the
|
|
166
|
+
privilege. `changes` returns only what moved since a timestamp, and hands back the cursor for the
|
|
167
|
+
next call.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
let since = new Date(Date.now() - 60_000).toISOString();
|
|
171
|
+
|
|
172
|
+
setInterval(async () => {
|
|
173
|
+
const batch = await pe.racing.changes({ since, categories: ["horse", "greyhound"] });
|
|
174
|
+
for (const race of batch.races ?? []) applyUpdate(race);
|
|
175
|
+
since = batch.server_time; // the cursor for the next poll
|
|
176
|
+
}, 15_000);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`server_time` is deliberately set back 30 seconds from the server clock so an in-flight write is
|
|
180
|
+
never skipped. That makes the feed **at-least-once**: you will occasionally see a race twice, so
|
|
181
|
+
`applyUpdate` must be idempotent.
|
|
182
|
+
|
|
183
|
+
For anything slower than a few seconds, a [webhook](#webhooks) is cheaper still.
|
|
184
|
+
|
|
185
|
+
## Freshness
|
|
186
|
+
|
|
187
|
+
A stalled scraper does not return an error — it keeps serving its last value, which is exactly what
|
|
188
|
+
a live price looks like. Every quote carries its own age:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const quotes = race.runners[0].bookmakers.filter(b => !b.stale && (b.age_seconds ?? 0) < 120);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
And `pe.health.connectors()` reports `last_ok` per bookmaker, which is how you tell a quiet market
|
|
195
|
+
from a broken feed.
|
|
196
|
+
|
|
197
|
+
## Webhooks
|
|
198
|
+
|
|
199
|
+
Creating a subscription needs the Standard plan or above.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const hook = await pe.webhooks.create({
|
|
203
|
+
url: "https://example.com/pe-hook",
|
|
204
|
+
events: ["race.odds_open"],
|
|
205
|
+
});
|
|
206
|
+
console.log(hook.secret); // shown once — store it
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Every delivery is signed. Verify against the **raw body**, before anything parses it:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import express from "express";
|
|
213
|
+
import { verifyWebhookSignature } from "puntersedge";
|
|
214
|
+
|
|
215
|
+
app.post("/pe-hook", express.raw({ type: "application/json" }), async (req, res) => {
|
|
216
|
+
const ok = await verifyWebhookSignature({
|
|
217
|
+
body: req.body, // a Buffer, unparsed
|
|
218
|
+
signature: req.header("X-Webhook-Signature"),
|
|
219
|
+
secret: process.env.PUNTERSEDGE_WEBHOOK_SECRET!,
|
|
220
|
+
});
|
|
221
|
+
if (!ok) return res.sendStatus(401);
|
|
222
|
+
|
|
223
|
+
res.sendStatus(200); // ack fast
|
|
224
|
+
queue.push(JSON.parse(req.body.toString("utf8"))); // work later
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
> `JSON.stringify(req.body)` will never match. The API signs a canonical serialisation — keys
|
|
229
|
+
> sorted, no whitespace — and your framework's re-serialisation differs. Hash the bytes you
|
|
230
|
+
> received.
|
|
231
|
+
|
|
232
|
+
When nothing arrives, `pe.webhooks.deliveries(id)` shows the attempts and the response codes your
|
|
233
|
+
endpoint returned.
|
|
234
|
+
|
|
235
|
+
## The sandbox, no key needed
|
|
236
|
+
|
|
237
|
+
Real prices, truncated to 3 races, 5 runners and 3 bookmakers. 0 credits, no signup — so the
|
|
238
|
+
example in *your* README stays runnable by someone who has not signed up yet.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const pe = new PuntersEdge(); // no key
|
|
242
|
+
const { races } = await pe.demo.nextToGo();
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Bulk history
|
|
246
|
+
|
|
247
|
+
The archive endpoints stream CSV in a stable column order, which is the format a modeller actually
|
|
248
|
+
wants:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const csv = await pe.racing.closingLinesCsv({ date: "2026-09-01", category: "horse" });
|
|
252
|
+
await writeFile("closing-lines.csv", csv);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Runtime notes
|
|
256
|
+
|
|
257
|
+
**Timeouts and cancellation.** 30 seconds by default; override per client or per call, and pass
|
|
258
|
+
your own `AbortSignal` whenever you have one.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
await pe.racing.nextToGo({ timeoutMs: 5_000, signal: req.signal });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Connection reuse matters more than anything you can tune server-side.** Measured against this
|
|
265
|
+
API: a cold TLS handshake costs 137ms against 40.5ms on a reused connection. Node's global fetch
|
|
266
|
+
agent pools by default — so reuse one `PuntersEdge` instance for the life of your process rather
|
|
267
|
+
than constructing one per request.
|
|
268
|
+
|
|
269
|
+
**Compression.** Responses are JSON and compress about 9x. Node, Deno and Bun request gzip
|
|
270
|
+
automatically. If you supply your own `fetch`, make sure it does too.
|
|
271
|
+
|
|
272
|
+
**Endpoints newer than this package.** `pe.raw()` reaches anything:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const { data, credits, headers } = await pe.raw("GET", "/v1/racing/something-new", { limit: 5 });
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## CommonJS
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
const { PuntersEdge } = require("puntersedge");
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Contributing
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
npm install
|
|
288
|
+
npm run types:generate # regenerate src/types.generated.ts from the live schema
|
|
289
|
+
npm run typecheck
|
|
290
|
+
npm run build
|
|
291
|
+
npm test # unit + contract tests, offline
|
|
292
|
+
PUNTERSEDGE_API_KEY=... node --test test/live.test.mjs
|
|
293
|
+
```
|
|
54
294
|
|
|
55
|
-
|
|
295
|
+
`test/contract.test.mjs` checks every call this client can make against the API's own OpenAPI
|
|
296
|
+
document — every path template must exist and every query parameter must be one the server
|
|
297
|
+
declares. That is the test that catches the failure mode a stubbed unit test cannot: a mis-mapped
|
|
298
|
+
parameter name that the server silently ignores while returning a cheerful 200.
|
|
56
299
|
|
|
57
|
-
##
|
|
300
|
+
## Also available
|
|
58
301
|
|
|
59
|
-
- [
|
|
60
|
-
- [
|
|
61
|
-
|
|
62
|
-
- [
|
|
63
|
-
|
|
302
|
+
- **Python** — [`puntersedge`](https://pypi.org/project/puntersedge/) on PyPI, same design.
|
|
303
|
+
- **MCP server** — [`puntersedge-mcp`](https://pypi.org/project/puntersedge-mcp/), for Claude,
|
|
304
|
+
Cursor and other agent hosts. See [/developers/mcp-server](https://puntersedge.online/developers/mcp-server).
|
|
305
|
+
- **Examples** — [puntersedge-examples](https://github.com/puntersedge/puntersedge-examples):
|
|
306
|
+
runnable scripts, starters, n8n workflows and a Google Sheets connector.
|
|
307
|
+
- **Postman** — import <https://api.puntersedge.online/postman.json>.
|
|
64
308
|
|
|
65
|
-
##
|
|
309
|
+
## Licence
|
|
66
310
|
|
|
67
|
-
MIT
|
|
311
|
+
MIT. Data licensing is separate: internal use is included on every plan, showing prices to end
|
|
312
|
+
users needs attribution on Plus and above, and redistribution needs Platform. See
|
|
313
|
+
[the terms](https://puntersedge.online/terms).
|