puntersedge 0.1.0 → 1.0.1

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 +289 -37
  3. package/dist/cjs/client.js +502 -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 +495 -0
  19. package/dist/esm/client.d.ts.map +1 -0
  20. package/dist/esm/client.js +498 -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 +814 -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,319 @@
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
+ ```
12
28
 
13
- ```typescript
14
- import PuntersEdgeClient from "puntersedge";
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.
15
34
 
16
- const client = new PuntersEdgeClient({ apiKey: "YOUR_API_KEY" });
35
+ ## Get a key
17
36
 
18
- // List sports
19
- const sports = await client.sports();
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=...`
20
40
 
21
- // NRL head-to-head odds
22
- const nrl = await client.odds("nrl", "h2h");
41
+ You can explore without a key at all — see [the sandbox](#the-sandbox-no-key-needed).
23
42
 
24
- // Best odds across bookmakers (with arb detection)
25
- const best = await client.bestOdds("afl");
43
+ ## Racing is not a sport key
26
44
 
27
- // Next 5 horse races
28
- const races = await client.nextToGo(5, "horse");
45
+ The single most common first mistake. There is no `sport_key` of `"horse-racing"`.
29
46
 
30
- // Check your credit usage
31
- const usage = await client.usage();
47
+ ```ts
48
+ await pe.sports.odds("horse-racing"); // ✗ 422
49
+ await pe.racing.nextToGo(); // ✓
32
50
  ```
33
51
 
34
- ## API key
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.
55
+
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 |
65
+
66
+ ## Credits, read for free
67
+
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.
70
+
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
+ ```
79
+
80
+ Prefer this to polling `/v1/usage`: the response you just made already answered the question.
81
+
82
+ For a callback on every billed call — to feed a gauge, or to log:
83
+
84
+ ```ts
85
+ const pe = new PuntersEdge({
86
+ apiKey: KEY,
87
+ onCredits: c => { if (c.warning) console.warn(`${c.remaining} credits left`); },
88
+ });
89
+ ```
90
+
91
+ ### What a call costs
92
+
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
+ | `racing.closingLinesCsv`, `racing.pricePathsCsv` | **20** |
35
102
 
36
- [Get a free API key](https://puntersedge.online/api-platform) no credit card required.
103
+ A malformed request an unknown sport key, an unrecognised bookmaker, a bad date — is refused
104
+ **before** billing, so a 422 is free.
37
105
 
38
106
  ## Errors
39
107
 
40
- ```typescript
41
- import { AuthError, RateLimitError, NotFoundError, ServerError } from "puntersedge";
108
+ ```ts
109
+ import {
110
+ PuntersEdgeError, AuthError, CreditsExhaustedError,
111
+ RateLimitError, ValidationError, NotFoundError, ServerError, NetworkError,
112
+ } from "puntersedge";
42
113
 
43
114
  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 */ }
115
+ await pe.racing.movers({ direction: "firming" });
116
+ } catch (err) {
117
+ if (err instanceof RateLimitError) {
118
+ // The server told you how long to wait. Wait, then repeat the identical call.
119
+ await new Promise(r => setTimeout(r, (err.retryAfter ?? 60) * 1000));
120
+ } else if (err instanceof CreditsExhaustedError) {
121
+ // Not a rate limit — backing off will not clear it.
122
+ console.error(`Out of credits. ${err.upgrade?.plan} gives ${err.upgrade?.credits}/mo: ${err.upgrade?.url}`);
123
+ if (err.upgrade?.covers === "partial") {
124
+ console.error("…and that is still below your measured need. Email hello@puntersedge.online.");
125
+ }
126
+ } else if (err instanceof ValidationError) {
127
+ // Deterministic: retrying will fail identically forever.
128
+ for (const f of err.fields) console.error(f.loc.join("."), f.msg);
129
+ } else if (err instanceof PuntersEdgeError) {
130
+ console.error(err.status, err.problem?.type, err.message);
131
+ }
50
132
  }
51
133
  ```
52
134
 
53
- ## Supported sports
135
+ Every error carries the RFC 7807 body the API returned, so `err.problem.type` is a stable URI you
136
+ can switch on, documented at [puntersedge.online/developers/errors](https://puntersedge.online/developers/errors).
137
+
138
+ ### Nothing is retried for you
139
+
140
+ Deliberately. Odds are time-sensitive: a transparent retry inside the client can hand you a price
141
+ recorded before a move you would have acted on, and it can double a billed call without telling
142
+ you. Retry at the layer that knows whether a stale answer is acceptable.
143
+
144
+ ```ts
145
+ async function withRetry<T>(call: () => Promise<T>, attempts = 3): Promise<T> {
146
+ for (let i = 0; ; i++) {
147
+ try {
148
+ return await call();
149
+ } catch (err) {
150
+ const retryable = err instanceof RateLimitError || err instanceof ServerError;
151
+ if (!retryable || i >= attempts - 1) throw err;
152
+ const wait = err instanceof RateLimitError
153
+ ? (err.retryAfter ?? 60) * 1000
154
+ : 2 ** i * 1000;
155
+ await new Promise(r => setTimeout(r, wait));
156
+ }
157
+ }
158
+ }
159
+ ```
160
+
161
+ Note what is missing from that list: `CreditsExhaustedError` and `ValidationError` are never
162
+ retried, because neither will ever succeed on a second attempt.
163
+
164
+ ## Polling a live board
165
+
166
+ Do not loop `nextToGo`. It re-downloads every race every time and bills 2 credits for the
167
+ privilege. `changes` returns only what moved since a timestamp, and hands back the cursor for the
168
+ next call.
169
+
170
+ ```ts
171
+ let since = new Date(Date.now() - 60_000).toISOString();
172
+
173
+ setInterval(async () => {
174
+ const batch = await pe.racing.changes({ since, categories: ["horse", "greyhound"] });
175
+ for (const race of batch.races ?? []) applyUpdate(race);
176
+ since = batch.server_time; // the cursor for the next poll
177
+ }, 15_000);
178
+ ```
179
+
180
+ `server_time` is deliberately set back 30 seconds from the server clock so an in-flight write is
181
+ never skipped. That makes the feed **at-least-once**: you will occasionally see a race twice, so
182
+ `applyUpdate` must be idempotent.
183
+
184
+ For anything slower than a few seconds, a [webhook](#webhooks) is cheaper still.
185
+
186
+ ## Freshness
187
+
188
+ A stalled scraper does not return an error — it keeps serving its last value, which is exactly what
189
+ a live price looks like. Every quote carries its own age:
190
+
191
+ ```ts
192
+ const quotes = race.runners[0].bookmakers.filter(b => !b.stale && (b.age_seconds ?? 0) < 120);
193
+ ```
194
+
195
+ And `pe.health.connectors()` reports `last_ok` per bookmaker, which is how you tell a quiet market
196
+ from a broken feed.
197
+
198
+ ## Webhooks
199
+
200
+ Creating a subscription needs the Standard plan or above.
201
+
202
+ ```ts
203
+ const hook = await pe.webhooks.create({
204
+ url: "https://example.com/pe-hook",
205
+ events: ["race.odds_open"],
206
+ });
207
+ console.log(hook.secret); // shown once — store it
208
+ ```
209
+
210
+ Every delivery is signed. Verify against the **raw body**, before anything parses it:
211
+
212
+ ```ts
213
+ import express from "express";
214
+ import { verifyWebhookSignature } from "puntersedge";
215
+
216
+ app.post("/pe-hook", express.raw({ type: "application/json" }), async (req, res) => {
217
+ const ok = await verifyWebhookSignature({
218
+ body: req.body, // a Buffer, unparsed
219
+ signature: req.header("X-Webhook-Signature"),
220
+ secret: process.env.PUNTERSEDGE_WEBHOOK_SECRET!,
221
+ });
222
+ if (!ok) return res.sendStatus(401);
223
+
224
+ res.sendStatus(200); // ack fast
225
+ queue.push(JSON.parse(req.body.toString("utf8"))); // work later
226
+ });
227
+ ```
228
+
229
+ > `JSON.stringify(req.body)` will never match. The API signs a canonical serialisation — keys
230
+ > sorted, no whitespace — and your framework's re-serialisation differs. Hash the bytes you
231
+ > received.
232
+
233
+ When nothing arrives, `pe.webhooks.deliveries(id)` shows the attempts and the response codes your
234
+ endpoint returned.
235
+
236
+ ## The sandbox, no key needed
237
+
238
+ Real prices, truncated to 3 races, 5 runners and 3 bookmakers. 0 credits, no signup — so the
239
+ example in *your* README stays runnable by someone who has not signed up yet.
240
+
241
+ ```ts
242
+ const pe = new PuntersEdge(); // no key
243
+ const { races } = await pe.demo.nextToGo();
244
+ ```
245
+
246
+ ## Bulk history
247
+
248
+ The archive endpoints stream CSV in a stable column order, which is the format a modeller actually
249
+ wants:
250
+
251
+ ```ts
252
+ const csv = await pe.racing.closingLinesCsv({ date: "2026-09-01", category: "horse" });
253
+ await writeFile("closing-lines.csv", csv);
254
+ ```
255
+
256
+ **CSV costs 20 credits, not 5.** These two endpoints are the only ones on the API whose price
257
+ depends on the format you ask for, and the reason is size: the server caps a CSV pull at 50,000
258
+ rows against 5,000 for a JSON page, so one call can return ten times the data for four times the
259
+ price. If you are paging a small window, ask for JSON.
260
+
261
+ ## Runtime notes
262
+
263
+ **Timeouts and cancellation.** 30 seconds by default; override per client or per call, and pass
264
+ your own `AbortSignal` whenever you have one.
265
+
266
+ ```ts
267
+ await pe.racing.nextToGo({ timeoutMs: 5_000, signal: req.signal });
268
+ ```
269
+
270
+ **Connection reuse matters more than anything you can tune server-side.** Measured against this
271
+ API: a cold TLS handshake costs 137ms against 40.5ms on a reused connection. Node's global fetch
272
+ agent pools by default — so reuse one `PuntersEdge` instance for the life of your process rather
273
+ than constructing one per request.
274
+
275
+ **Compression.** Responses are JSON and compress about 9x. Node, Deno and Bun request gzip
276
+ automatically. If you supply your own `fetch`, make sure it does too.
277
+
278
+ **Endpoints newer than this package.** `pe.raw()` reaches anything:
279
+
280
+ ```ts
281
+ const { data, credits, headers } = await pe.raw("GET", "/v1/racing/something-new", { limit: 5 });
282
+ ```
283
+
284
+ ## CommonJS
285
+
286
+ ```js
287
+ const { PuntersEdge } = require("puntersedge");
288
+ ```
289
+
290
+ ## Contributing
291
+
292
+ ```bash
293
+ npm install
294
+ npm run types:generate # regenerate src/types.generated.ts from the live schema
295
+ npm run typecheck
296
+ npm run build
297
+ npm test # unit + contract tests, offline
298
+ PUNTERSEDGE_API_KEY=... node --test test/live.test.mjs
299
+ ```
54
300
 
55
- AFL, NRL, NBA, Tennis ATP/WTA, EPL, A-League, Super Rugby, Big Bash, Test Cricket
301
+ `test/contract.test.mjs` checks every call this client can make against the API's own OpenAPI
302
+ document — every path template must exist and every query parameter must be one the server
303
+ declares. That is the test that catches the failure mode a stubbed unit test cannot: a mis-mapped
304
+ parameter name that the server silently ignores while returning a cheerful 200.
56
305
 
57
- ## Links
306
+ ## Also available
58
307
 
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/)
308
+ - **Python** — [`puntersedge`](https://pypi.org/project/puntersedge/) on PyPI, same design.
309
+ - **MCP server** — [`puntersedge-mcp`](https://pypi.org/project/puntersedge-mcp/), for Claude,
310
+ Cursor and other agent hosts. See [/developers/mcp-server](https://puntersedge.online/developers/mcp-server).
311
+ - **Examples** — [puntersedge-examples](https://github.com/puntersedge/puntersedge-examples):
312
+ runnable scripts, starters, n8n workflows and a Google Sheets connector.
313
+ - **Postman** — import <https://api.puntersedge.online/postman.json>.
64
314
 
65
- ## License
315
+ ## Licence
66
316
 
67
- MIT
317
+ MIT. Data licensing is separate: internal use is included on every plan, showing prices to end
318
+ users needs attribution on Plus and above, and redistribution needs Platform. See
319
+ [the terms](https://puntersedge.online/terms).