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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -37
  3. package/dist/cjs/client.js +487 -0
  4. package/dist/cjs/client.js.map +1 -0
  5. package/dist/cjs/credits.js +82 -0
  6. package/dist/cjs/credits.js.map +1 -0
  7. package/dist/cjs/errors.js +130 -0
  8. package/dist/cjs/errors.js.map +1 -0
  9. package/dist/cjs/http.js +218 -0
  10. package/dist/cjs/http.js.map +1 -0
  11. package/dist/cjs/index.js +58 -0
  12. package/dist/cjs/index.js.map +1 -0
  13. package/dist/cjs/package.json +3 -0
  14. package/dist/cjs/types.generated.js +13 -0
  15. package/dist/cjs/types.generated.js.map +1 -0
  16. package/dist/cjs/webhook.js +95 -0
  17. package/dist/cjs/webhook.js.map +1 -0
  18. package/dist/esm/client.d.ts +480 -0
  19. package/dist/esm/client.d.ts.map +1 -0
  20. package/dist/esm/client.js +483 -0
  21. package/dist/esm/client.js.map +1 -0
  22. package/dist/esm/credits.d.ts +78 -0
  23. package/dist/esm/credits.d.ts.map +1 -0
  24. package/dist/esm/credits.js +78 -0
  25. package/dist/esm/credits.js.map +1 -0
  26. package/dist/esm/errors.d.ts +132 -0
  27. package/dist/esm/errors.d.ts.map +1 -0
  28. package/dist/esm/errors.js +117 -0
  29. package/dist/esm/errors.js.map +1 -0
  30. package/dist/esm/http.d.ts +52 -0
  31. package/dist/esm/http.d.ts.map +1 -0
  32. package/dist/esm/http.js +214 -0
  33. package/dist/esm/http.js.map +1 -0
  34. package/dist/esm/index.d.ts +22 -0
  35. package/dist/esm/index.d.ts.map +1 -0
  36. package/dist/esm/index.js +23 -0
  37. package/dist/esm/index.js.map +1 -0
  38. package/dist/esm/types.generated.d.ts +1453 -0
  39. package/dist/esm/types.generated.d.ts.map +1 -0
  40. package/dist/esm/types.generated.js +12 -0
  41. package/dist/esm/types.generated.js.map +1 -0
  42. package/dist/esm/webhook.d.ts +56 -0
  43. package/dist/esm/webhook.d.ts.map +1 -0
  44. package/dist/esm/webhook.js +92 -0
  45. package/dist/esm/webhook.js.map +1 -0
  46. package/package.json +52 -19
  47. package/src/client.ts +799 -0
  48. package/src/credits.ts +131 -0
  49. package/src/errors.ts +166 -0
  50. package/src/http.ts +268 -0
  51. package/src/index.ts +68 -206
  52. package/src/types.generated.ts +1514 -0
  53. package/src/webhook.ts +113 -0
  54. package/dist/index.d.ts +0 -117
  55. 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 Node.js / TypeScript client for the [PuntersEdge Australian Sports Odds API](https://puntersedge.online/api-platform).
4
-
5
- ## Install
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
- ## Quick start
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
- ```typescript
14
- import PuntersEdgeClient from "puntersedge";
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
- const client = new PuntersEdgeClient({ apiKey: "YOUR_API_KEY" });
66
+ ## Credits, read for free
17
67
 
18
- // List sports
19
- const sports = await client.sports();
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
- // NRL head-to-head odds
22
- const nrl = await client.odds("nrl", "h2h");
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
- // Best odds across bookmakers (with arb detection)
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
- // Next 5 horse races
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
- // Check your credit usage
31
- const usage = await client.usage();
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
- ## API key
91
+ ### What a call costs
35
92
 
36
- [Get a free API key](https://puntersedge.online/api-platform) — no credit card required.
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
- ```typescript
41
- import { AuthError, RateLimitError, NotFoundError, ServerError } from "puntersedge";
107
+ ```ts
108
+ import {
109
+ PuntersEdgeError, AuthError, CreditsExhaustedError,
110
+ RateLimitError, ValidationError, NotFoundError, ServerError, NetworkError,
111
+ } from "puntersedge";
42
112
 
43
113
  try {
44
- const data = await client.odds("afl");
45
- } catch (e) {
46
- if (e instanceof AuthError) { /* invalid key */ }
47
- if (e instanceof RateLimitError) { /* out of credits */ }
48
- if (e instanceof NotFoundError) { /* sport not found */ }
49
- if (e instanceof ServerError) { /* 5xx */ }
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
- ## Supported sports
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
- AFL, NRL, NBA, Tennis ATP/WTA, EPL, A-League, Super Rugby, Big Bash, Test Cricket
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
- ## Links
300
+ ## Also available
58
301
 
59
- - [API documentation](https://puntersedge.online/api/examples)
60
- - [Coverage](https://puntersedge.online/api/coverage)
61
- - [Pricing](https://puntersedge.online/api/pricing)
62
- - [Status](https://puntersedge.online/status)
63
- - [Python SDK](https://pypi.org/project/puntersedge/)
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
- ## License
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).