@0xinsider/sdk 0.14.0-bootstrap.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 +674 -0
- package/dist/client.d.ts +1652 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2127 -0
- package/dist/client.js.map +1 -0
- package/dist/data-quality.d.ts +74 -0
- package/dist/data-quality.d.ts.map +1 -0
- package/dist/data-quality.js +68 -0
- package/dist/data-quality.js.map +1 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +700 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +196 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +208 -0
- package/dist/pagination.js.map +1 -0
- package/dist/provenance.d.ts +8 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +15 -0
- package/dist/provenance.js.map +1 -0
- package/dist/retry.d.ts +68 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +125 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts +4910 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/stream.d.ts +509 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +932 -0
- package/dist/stream.js.map +1 -0
- package/dist/webhooks.d.ts +314 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +153 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
# @0xinsider/sdk
|
|
2
|
+
|
|
3
|
+
Official TypeScript SDK for the [0xinsider](https://0xinsider.com) API: analytics for Polymarket sports and esports markets. Wallet grades, large trades, profitable-wallet flows, market intel and OHLC candles, a live SSE feed, and signed webhooks.
|
|
4
|
+
|
|
5
|
+
The types are generated from the [published OpenAPI document](https://0xinsider.com/api/v1/openapi.json), and a drift check pins the operation table to it, so the SDK cannot silently diverge from the live API surface.
|
|
6
|
+
|
|
7
|
+
- Website and API keys: https://0xinsider.com/developers
|
|
8
|
+
- Authentication (API keys and OAuth 2.1): https://0xinsider.com/auth.md
|
|
9
|
+
- OpenAPI 3.1: https://0xinsider.com/api/v1/openapi.json
|
|
10
|
+
- Documentation: https://docs.0xinsider.com
|
|
11
|
+
- MCP server: https://api.0xinsider.com/api/v1/mcp
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js 18+ (uses native `fetch`, `ReadableStream`, and `node:crypto`). Zero runtime dependencies.
|
|
16
|
+
- Every API call has a 15 s SDK deadline (`DEFAULT_TIMEOUT_MS`). Set `timeoutMs` on the client or per call; `null` disables it. Only the SDK's deadline rejects with `RequestTimeoutError` (no status, body, or request id). An API call cancelled by the caller's `signal` rejects with its exact abort reason, including a caller-owned `TimeoutError`. `streamFeed` has no deadline; signed object downloads use their separate `downloadTimeoutMs` option.
|
|
17
|
+
- API call cancellation stays active while the response body is read and during retry backoff. The first signal to abort determines the rejection; no further attempt starts after cancellation. On runtimes without `AbortSignal.any`, request-owned listeners are removed when the call finishes, including failures; one cancellation signal can be reused across pages without accumulating listeners.
|
|
18
|
+
- `baseUrl` is the server URL, `https://api.0xinsider.com` by default or `https://0xinsider.com/sandbox`; a path on it is kept and the `/api/v1/...` operation paths are appended after it. It must be `https:`; `http:` is accepted only for a loopback host (`localhost`, `127.0.0.1`, `[::1]`). The constructor throws otherwise, so the API key is never sent to an untrusted origin.
|
|
19
|
+
- An API key. Your key looks like `oxi_sk_live_...`. Create one at [0xinsider.com/developers](https://0xinsider.com/developers). The [sandbox](#sandbox) needs none.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @0xinsider/sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The first npm release is pending. Until it lands, build the package from this repository and install it from the folder (npm 12 refuses git dependencies by default, so `npm install github:...` does not work):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/0xinsider/0xinsider-node
|
|
31
|
+
(cd 0xinsider-node && npm ci && npm run build)
|
|
32
|
+
npm install ./0xinsider-node # from your project, with the path to the clone
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package is ESM-only (`import`, not `require`) and ships its own type declarations.
|
|
36
|
+
|
|
37
|
+
Try it with no key against the [sandbox](#sandbox):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/0xinsider/0xinsider-node && cd 0xinsider-node
|
|
41
|
+
npm ci && npm run build && node examples/sandbox.mjs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Types
|
|
45
|
+
|
|
46
|
+
Every type comes from the published OpenAPI document
|
|
47
|
+
(https://0xinsider.com/api/v1/openapi.json), generated by
|
|
48
|
+
`scripts/generate.mjs` into `src/schema.ts`. All component schemas
|
|
49
|
+
are exported by name, and five per-operation maps (`OperationPath`,
|
|
50
|
+
`OperationQuery`, `OperationBody`, `OperationData`, `OperationResponse`) type
|
|
51
|
+
every convenience method, `call`, `list` and `paginate` by the operation they
|
|
52
|
+
name: the path parameters it takes, the query keys and values it documents,
|
|
53
|
+
the body it requires, and the envelope it answers with its own `data` and
|
|
54
|
+
`meta` (`BatchResponseMeta` on a batch, `EventReplayMeta` on the replay).
|
|
55
|
+
Nothing takes a type argument any more; the method knows its shape.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const trader = await client.getTrader("swisstony", { query: { expand: ["strategy"] } });
|
|
59
|
+
if (trader.object === "trader") {
|
|
60
|
+
trader.data?.grade; // Grade | null
|
|
61
|
+
trader.meta.request_id; // ResponseMeta, always present
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const explore = await client.exploreMarkets({ sort: "trending", limit: 10 });
|
|
65
|
+
explore.data[0]; // ExploreEntry, and explore.facets is typed too
|
|
66
|
+
explore.data[0].title; // compile error: an ExploreEntry has no title
|
|
67
|
+
|
|
68
|
+
const batch = await client.batchGetTraders(["swisstony", "0xabc..."], { expand: ["trust"] });
|
|
69
|
+
if (batch.object === "trader_batch") batch.meta.request_cost; // BatchResponseMeta
|
|
70
|
+
|
|
71
|
+
client.listInsiderRadar({ min_grade: "S" }); // compile error: the route takes min_suspicion and severity
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every method forwards the same transport options, so a conditional read,
|
|
75
|
+
a deadline or a cancellation looks the same everywhere:
|
|
76
|
+
`client.listLargeTradesConditional({ min_grade: "S", since }, { signal, timeoutMs: 5_000, maxRetries: 0, headers: { "If-None-Match": etag } })`.
|
|
77
|
+
A list method such as `listLargeTrades()` throws on a `304`; send
|
|
78
|
+
`If-None-Match` through a `...Conditional` method or `call()`, which return the
|
|
79
|
+
typed `not_modified` result instead.
|
|
80
|
+
|
|
81
|
+
`call`, `list`, `paginate`, `paginatePages` and `collect` are typed the same
|
|
82
|
+
way when the operation id is a literal. Given an id chosen at runtime, or an
|
|
83
|
+
explicit type argument, they fall back to the loose form (`path`, `query` and
|
|
84
|
+
`body` as plain records, the generic `{ object, data, meta? }` envelope back)
|
|
85
|
+
for the caller that asserts the shape itself.
|
|
86
|
+
|
|
87
|
+
`npm run check` regenerates from the committed snapshot and compares on every
|
|
88
|
+
push, so a contract change that nobody regenerated fails CI rather than
|
|
89
|
+
shipping as a stale type.
|
|
90
|
+
|
|
91
|
+
## Quickstart
|
|
92
|
+
|
|
93
|
+
### REST
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { OxinsiderApiClient } from "@0xinsider/sdk";
|
|
97
|
+
|
|
98
|
+
const client = new OxinsiderApiClient({
|
|
99
|
+
apiKey: process.env.OXINSIDER_API_KEY, // oxi_sk_live_...
|
|
100
|
+
// baseUrl defaults to https://api.0xinsider.com
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Single resource: { object, data, meta }
|
|
104
|
+
const trader = await client.getTrader("swisstony");
|
|
105
|
+
console.log(trader.data);
|
|
106
|
+
|
|
107
|
+
// 1 to 25 traders in one request (POST /api/v1/traders/batch). The body
|
|
108
|
+
// carries `traders` and the optional shared `expand`; `data` keeps request
|
|
109
|
+
// order, one row per input, each `status: "ok"` with `data` or
|
|
110
|
+
// `status: "error"` with that item's own `error`.
|
|
111
|
+
const batch = await client.batchGetTraders(["swisstony", "trd_123"], {
|
|
112
|
+
expand: ["strategy", "trust"],
|
|
113
|
+
});
|
|
114
|
+
for (const item of batch.data) {
|
|
115
|
+
if (item.status === "ok") console.log(item.input, item.data?.grade);
|
|
116
|
+
else console.log(item.input, item.error?.code);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A list endpoint returns the Stripe-style envelope:
|
|
120
|
+
// { object: "list", data, has_more, next_cursor, meta }
|
|
121
|
+
const board = await client.listLeaderboard({ strategy: "swing_trader", limit: 100 });
|
|
122
|
+
console.log(board.data, board.has_more, board.next_cursor);
|
|
123
|
+
|
|
124
|
+
// Keep compatibility by default, or opt into rejection of unsupported query
|
|
125
|
+
// names. The SDK exposes the server's diagnostics on response metadata.
|
|
126
|
+
const checked = await client.listLeaderboard(
|
|
127
|
+
{ strategy: "swing_trader" },
|
|
128
|
+
{ strictQuery: true },
|
|
129
|
+
);
|
|
130
|
+
console.log(checked.meta?.effectiveQuery, checked.meta?.queryIgnored);
|
|
131
|
+
|
|
132
|
+
// OHLC candles for a market (open or resolved):
|
|
133
|
+
const candles = await client.getMarketCandles("0xabc...", {
|
|
134
|
+
query: { resolution: "1d" },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Webhook event catalog (self-describing) and one endpoint's delivery log:
|
|
138
|
+
const events = await client.listWebhookEvents();
|
|
139
|
+
const deliveries = await client.listWebhookDeliveries(123, { limit: 50 });
|
|
140
|
+
|
|
141
|
+
// Any operation by id, typed by the id: path, query, body and the envelope.
|
|
142
|
+
const usage = await client.call("getUsage");
|
|
143
|
+
const holders = await client.call("getMarketHolders", {
|
|
144
|
+
path: { condition_id: "0x..." },
|
|
145
|
+
query: { outcome: "yes", limit: 25 },
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`getAccountIdentity()` remains available to a valid credential after paid data
|
|
150
|
+
access lapses and returns `credential_status` plus
|
|
151
|
+
`entitlement.paid_data_access` and `entitlement.recovery_action`. Call
|
|
152
|
+
`getUsage()` for the caller's rate-limit, daily-usage and monthly-quota state;
|
|
153
|
+
paid data routes still return `402 subscription_required` until access is
|
|
154
|
+
renewed.
|
|
155
|
+
|
|
156
|
+
For `listSportsEdgeSignals`, require every row's `category_skill` object and
|
|
157
|
+
compare `meta.category_skill_base_payload_hash` with
|
|
158
|
+
`meta.category_skill_enriched_base_payload_hash` before consuming category
|
|
159
|
+
evidence. The evidence is Polymarket-only, forward-observed, and explicitly
|
|
160
|
+
partial; `degraded` or mismatched hashes must not be treated as a measured edge.
|
|
161
|
+
|
|
162
|
+
### Observation-only sports cohorts
|
|
163
|
+
|
|
164
|
+
`listSportsEdgeObservations` exposes the measured `wider_holder`, the
|
|
165
|
+
overlapping `emerging_pile` projection, and provider-confirmed `in_play`
|
|
166
|
+
cohorts without changing the funded `sports-edge-signals` route. The response
|
|
167
|
+
includes `snapshot_as_of` and a
|
|
168
|
+
required snapshot-wide operational/unknown-completeness `degraded` verdict plus
|
|
169
|
+
a per-sport terminal-reason `funnel`; every row is tagged
|
|
170
|
+
`observation_only: true`. Do not route these rows to an order executor.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const tennis = await client.listSportsEdgeObservations({
|
|
174
|
+
cohort: "wider_holder",
|
|
175
|
+
category: "Tennis",
|
|
176
|
+
limit: 20,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
console.log(tennis.degraded, tennis.data, tennis.funnel.sports);
|
|
180
|
+
const previousEtag = tennis.meta.etag;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Treat `degraded: true` as partial evidence even when a row or the funnel's
|
|
184
|
+
single terminal reason does not independently expose every contributing
|
|
185
|
+
operational or unknown-completeness failure. `capacity_limited` is different:
|
|
186
|
+
it records intentional bounded provider-work admission, remains accountable in
|
|
187
|
+
the funnel, and does not by itself set `degraded: true`.
|
|
188
|
+
|
|
189
|
+
All category and all-sports cache scopes share one global observation
|
|
190
|
+
provider-work admission. Healthy `wider_holder` requests may reuse a snapshot
|
|
191
|
+
for about 180 seconds. `emerging_pile` projects `wider_holder` rows with finite
|
|
192
|
+
`sharp_pct` in the `[0.75, 0.85)` band, `holder_scan_complete: true`, and a
|
|
193
|
+
kickoff after the first-page projection cutoff. It overlaps the `wider_holder`
|
|
194
|
+
source and is not arrival history or an independent denominator. `in_play`
|
|
195
|
+
never serves a cached observation snapshot older than about 30 seconds and
|
|
196
|
+
fails closed when the provider live-board snapshot is stale or unavailable.
|
|
197
|
+
|
|
198
|
+
The SDK copies a successful response's weak semantic `ETag` header into
|
|
199
|
+
`meta.etag`; it is client metadata, not an additional field in the server's
|
|
200
|
+
JSON body. The validator covers the stable page payload, including the stable
|
|
201
|
+
page position in `next_cursor`, but excludes request-specific `meta` and, for
|
|
202
|
+
`emerging_pile`, the opaque projection cutoff inside that cursor.
|
|
203
|
+
|
|
204
|
+
For a conditional request, use `listSportsEdgeObservationsConditional()` so a
|
|
205
|
+
`304` returns the typed `{ object: "not_modified", data: null, meta }` result
|
|
206
|
+
while a `200` retains the full observation response type:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { isApiNotModifiedResponse } from "@0xinsider/sdk";
|
|
210
|
+
|
|
211
|
+
if (previousEtag) {
|
|
212
|
+
const conditional = await client.listSportsEdgeObservationsConditional(
|
|
213
|
+
{ cohort: "wider_holder", category: "Tennis", limit: 20 },
|
|
214
|
+
{
|
|
215
|
+
headers: { "If-None-Match": previousEtag },
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (isApiNotModifiedResponse(conditional)) {
|
|
220
|
+
console.log("snapshot unchanged");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Beyond the JSON envelope
|
|
226
|
+
|
|
227
|
+
Most operations answer `{ object, data, meta }` and `call()` reads them. Four do not, and each has its own method, because parsing a Markdown document as JSON only ever produced a confusing error about an invalid `200`:
|
|
228
|
+
|
|
229
|
+
| The route answers | Method | Returns |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| `text/markdown` (`context.md`) | `text()`, or `getTraderContextMarkdown` / `getMarketContextMarkdown` | the document as a `string` |
|
|
232
|
+
| JSON-RPC 2.0 (`POST /api/v1/mcp`) | `mcp()` | `{ status, response, sessionId? }` |
|
|
233
|
+
| `201` (`POST /api/v1/agents/register`) | `registerAgent()` | the envelope, `meta.status` `201` |
|
|
234
|
+
| `302` (export download) | `getTraderExportDownloadUrl()` / `downloadTraderExport()` | the presigned URL, or the object response |
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// Markdown, ready to paste into a prompt.
|
|
238
|
+
const context = await client.getTraderContextMarkdown("0x9d84...1344");
|
|
239
|
+
|
|
240
|
+
// One MCP JSON-RPC message. A JSON-RPC `error` is RETURNED, not thrown:
|
|
241
|
+
// an unknown tool is an answer, a revoked credential is not.
|
|
242
|
+
const listed = await client.mcp({ method: "tools/list", id: 1 });
|
|
243
|
+
listed.response?.result; // 200
|
|
244
|
+
const ack = await client.mcp({ method: "notifications/initialized" });
|
|
245
|
+
ack.status; // 202, ack.response is null
|
|
246
|
+
|
|
247
|
+
// A sandbox key, with no account and no credential.
|
|
248
|
+
const registered = await new OxinsiderApiClient().registerAgent();
|
|
249
|
+
registered.meta.status; // 201
|
|
250
|
+
const sandbox = OxinsiderApiClient.sandbox({ apiKey: registered.data.api_key });
|
|
251
|
+
|
|
252
|
+
// A finished export. The second request carries no headers at all, so your
|
|
253
|
+
// live key never reaches the object store; the presigned URL authorizes
|
|
254
|
+
// itself. Read expiresAt: links last at most one hour and cannot outlive
|
|
255
|
+
// artifact retention. Request a fresh link while retained, or a new export
|
|
256
|
+
// after the job expires.
|
|
257
|
+
const { response, filename, expiresAt } = await client.downloadTraderExport(address, jobId);
|
|
258
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(filename ?? "export.json"));
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`meta.status` is on every envelope: `201` on `registerAgent`, `202` on a `submitTraderExport` that queued a new job and `200` on one that returned a job already running, `200` everywhere else. The body is identical either way, so this is the only way to tell them apart.
|
|
262
|
+
|
|
263
|
+
The object fetch in `downloadTraderExport` has no deadline by default, the way the SSE stream has none: pass `signal` to cancel it or `downloadTimeoutMs` for one of your own, and read `response.body` as a stream rather than buffering a multi-gigabyte file. `getTraderExportDownloadUrl` reads the redirect with `redirect: "manual"`, which browsers answer with an opaque redirect no script can read; it says so rather than guessing, so run downloads from a server runtime.
|
|
264
|
+
|
|
265
|
+
Signed download URLs last at most 1 hour and never past the export job's `expires_at`. Use the returned `expiresAt`, which comes from the URL's signing fields, rather than assuming every link lasts 1 hour. Request a fresh URL while the job is retained; after `410 export_expired`, submit a new export.
|
|
266
|
+
|
|
267
|
+
Both `downloadTraderExport` and `downloadWhaleDataset` cancel an object response they cannot return, including a failed HTTP response or a failure while preparing the stream or metadata. They wait up to 2 seconds for that cleanup without buffering the error body. The original failure remains primary; a cleanup rejection or timeout is attached as its `cause`, retaining an existing cause in an `AggregateError`. If a thrown value cannot carry a cause, an `AggregateError` retains the original failure as its first entry and cause. A cleanup timeout means completion is unknown. Once the helper returns, consume or cancel `response.body` yourself.
|
|
268
|
+
|
|
269
|
+
`REDIRECT_OPERATIONS` and `UNSUPPORTED_OPERATIONS` are exported so you can see what is not wrapped and why. Today that is the public `GET /api/v1/openapi.json` redirect (fetch it directly) and `GET /api/v1/mcp`, which answers `405` by design because the endpoint offers no server-to-client stream.
|
|
270
|
+
|
|
271
|
+
### Sandbox
|
|
272
|
+
|
|
273
|
+
`OxinsiderApiClient.sandbox()` talks to `https://0xinsider.com/sandbox`, the second server in the OpenAPI document: no credential, no production data, every documented operation answered with its example or a deterministic sample, and `X-Oxi-Sandbox: true` on every response, which the client lifts to `meta.sandbox`. Every method works without a key, so you can write the integration before you have one. Add `sandbox_status` to a query to get one of the errors the operation documents, as the typed class it would be in production.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
import { OxinsiderApiClient, RateLimitedError, type Trader } from "@0xinsider/sdk";
|
|
277
|
+
|
|
278
|
+
const sandbox = OxinsiderApiClient.sandbox(); // no apiKey
|
|
279
|
+
|
|
280
|
+
const board = await sandbox.listLeaderboard({ limit: 5 });
|
|
281
|
+
console.log(board.data, board.meta.sandbox); // [...], true
|
|
282
|
+
|
|
283
|
+
// sandbox_status is not in the route's documented query, so this call takes
|
|
284
|
+
// the loose form: an explicit type argument instead of the typed method.
|
|
285
|
+
try {
|
|
286
|
+
await sandbox.call<Trader>("getTrader", {
|
|
287
|
+
path: { address: "swisstony" },
|
|
288
|
+
query: { sandbox_status: 429 },
|
|
289
|
+
});
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (err instanceof RateLimitedError) console.log(err.retryAfterSeconds); // 60
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A sandbox key from `POST https://api.0xinsider.com/api/v1/agents/register` (`oxi_sk_test_...`, no account needed) is optional: pass it as `apiKey` and the sandbox checks it. A live key (`oxi_sk_live_...`) is refused by the constructor in sandbox mode, so it is never sent there. Streams and file downloads are not simulated; `streamFeed` on a sandbox client throws the sandbox's own `BadRequestError` saying so. The same client with `sandbox: true` and an explicit `baseUrl` keeps that base's path, so a proxy under a path works the same way.
|
|
296
|
+
|
|
297
|
+
### Pagination
|
|
298
|
+
|
|
299
|
+
`paginate()` follows `next_cursor` over the list envelope and yields each item; `paginatePages()` yields whole envelopes when you need `meta` or `total`.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { paginate } from "@0xinsider/sdk";
|
|
303
|
+
|
|
304
|
+
for await (const trade of paginate(client, "listWhaleTrades", {
|
|
305
|
+
query: { min_grade: "S", limit: 100 },
|
|
306
|
+
})) {
|
|
307
|
+
console.log(trade);
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The loop ends when a page says `has_more: false`, whatever its `next_cursor` says. A page that says `has_more: true` with no usable `next_cursor`, or one whose `next_cursor` repeats a cursor the walk already requested, is a broken response rather than the end of the collection: the walk throws `PaginationError` (`reason` `missing_cursor` or `repeated_cursor`) before yielding that page and before any duplicate request, with the received `page` on the error so its data is not lost. The last 1,024 cursors are remembered for the repeat check (`CURSOR_HISTORY_LIMIT`), which keeps a long walk's memory flat. A list response whose `has_more` is not a boolean or whose `next_cursor` is not a string throws `InvalidResponseError`.
|
|
312
|
+
|
|
313
|
+
`maxPages` caps fetches and is checked before any request: a positive integer or `Infinity`; `0`, a negative, fractional or `NaN` value throws `RangeError`. Reaching it is your choice, not exhaustion. Pass a `progress` object to tell the two apart and keep the continuation:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { paginatePages, type PaginationProgress } from "@0xinsider/sdk";
|
|
317
|
+
|
|
318
|
+
const progress: PaginationProgress = {};
|
|
319
|
+
for await (const page of paginatePages(client, "listWhaleTrades", {
|
|
320
|
+
query: { min_grade: "S", limit: 100 },
|
|
321
|
+
maxPages: 3,
|
|
322
|
+
progress,
|
|
323
|
+
})) {
|
|
324
|
+
handle(page.data);
|
|
325
|
+
}
|
|
326
|
+
if (progress.stoppedBy === "max_pages") {
|
|
327
|
+
// the collection has more; continue later from progress.nextCursor
|
|
328
|
+
saveContinuation(progress.nextCursor);
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`signal` cancels between pages.
|
|
333
|
+
|
|
334
|
+
Progress counts each validated page before it is delivered, including a page
|
|
335
|
+
after which you `break`. It counts pages delivered, not items processed or
|
|
336
|
+
persisted. An early return retains `nextCursor` and leaves `stoppedBy` unset
|
|
337
|
+
unless that delivered page already exhausted the list or reached `maxPages`.
|
|
338
|
+
Reusing a progress object resets its owned fields when the new walk starts.
|
|
339
|
+
During a request, `cursor` names that attempt and `nextCursor` is undefined.
|
|
340
|
+
After a failure or between-page cancellation, `cursor` and `nextCursor` name
|
|
341
|
+
the retry cursor, and `pagesFetched` matches `paginationResumePoint(error)`;
|
|
342
|
+
the original error is rethrown.
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
Pass `strictQuery: true` to `paginate()`, `paginatePages()`, or `collect()` to
|
|
346
|
+
reject an unsupported query name before the first page request. With the
|
|
347
|
+
default compatibility mode, successful responses expose `meta.queryIgnored`
|
|
348
|
+
and `meta.effectiveQuery` when the server received query parameters that were
|
|
349
|
+
ignored or applied. The same option is available on the typed list methods and
|
|
350
|
+
on `client.call()`.
|
|
351
|
+
|
|
352
|
+
Each page request retries on its own (see [Retries](#retries)). If a page still fails, the walk throws that page's original error, and `paginationResumePoint(err)` returns the cursor to continue from; a `PaginationError` carries the same resume point:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
import { paginate, paginationResumePoint } from "@0xinsider/sdk";
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
for await (const trade of paginate(client, "listWhaleTrades", { query })) handle(trade);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const resume = paginationResumePoint(err); // { cursor, pagesFetched } or undefined
|
|
361
|
+
if (resume) scheduleLater({ ...query, cursor: resume.cursor });
|
|
362
|
+
else throw err;
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Polling for new large trades
|
|
367
|
+
|
|
368
|
+
`since` asks `listLargeTrades` for only the trades recorded after one you
|
|
369
|
+
already hold (#18507). Pair it with `If-None-Match` and a poll that finds
|
|
370
|
+
nothing new is a `304` with no body:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { isApiNotModifiedResponse, paginate } from "@0xinsider/sdk";
|
|
374
|
+
|
|
375
|
+
let since: string | undefined; // first trade of the last answer that had trades
|
|
376
|
+
let etag: string | undefined;
|
|
377
|
+
for (;;) {
|
|
378
|
+
const page = await client.listLargeTradesConditional(
|
|
379
|
+
{ since, limit: 100 },
|
|
380
|
+
{ headers: etag ? { "If-None-Match": etag } : {} },
|
|
381
|
+
);
|
|
382
|
+
if (!isApiNotModifiedResponse(page)) {
|
|
383
|
+
etag = page.meta.etag;
|
|
384
|
+
const trades = [...page.data];
|
|
385
|
+
if (since && page.has_more && page.next_cursor) {
|
|
386
|
+
// More than one page is new: read the rest under the same since.
|
|
387
|
+
for await (const trade of paginate(client, "listLargeTrades", {
|
|
388
|
+
query: { since, limit: 100, cursor: page.next_cursor },
|
|
389
|
+
})) trades.push(trade);
|
|
390
|
+
}
|
|
391
|
+
for (const trade of trades) handle(trade); // a late trade can repeat: dedupe on id
|
|
392
|
+
since = page.data[0]?.id ?? since;
|
|
393
|
+
}
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
"After" is commit order, so a trade recorded late with an earlier `traded_at`
|
|
399
|
+
still arrives, and one whose write is still open arrives on a later poll. Move
|
|
400
|
+
`since` only to the first trade of the first page. A `since` that names no
|
|
401
|
+
trade, or one more than 10,000 trades behind, is a `400` with `error.param`
|
|
402
|
+
`since`: poll once without it and continue from its first trade. `since` works
|
|
403
|
+
with `sort: "recent"` only.
|
|
404
|
+
|
|
405
|
+
### Live stream (SSE)
|
|
406
|
+
|
|
407
|
+
`streamFeed()` consumes `GET /api/v1/stream`, parses each `{ seq, published_at, type, ... }` feed envelope, and supports `Last-Event-ID` resume plus per-connection filters (`event`, `condition_id`, `min_grade`). The numeric cursor is cluster-shared across replicas and process restarts while retained; expired, future, or uncovered cursors emit `resync`.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { streamFeed } from "@0xinsider/sdk";
|
|
411
|
+
|
|
412
|
+
const cursor: { seq?: number } = {};
|
|
413
|
+
for await (const frame of streamFeed(client, {
|
|
414
|
+
event: ["WhaleTradesInserted"],
|
|
415
|
+
min_grade: "S",
|
|
416
|
+
cursor, // cursor.seq tracks the last DELIVERED seq: transport progress, not completed work
|
|
417
|
+
onResync: (m) => console.log("resync:", m.completeness?.reason),
|
|
418
|
+
})) {
|
|
419
|
+
if (frame.kind === "event") {
|
|
420
|
+
console.log(frame.seq, frame.envelope.type);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Both `streamFeedResilient` and `consumeStreamCheckpointed` honor a valid terminal error's absolute `retry_at` when no usable HTTP `Retry-After` duration exists. The HTTP duration is preferred because it does not depend on clock alignment. Absolute guidance uses your machine's clock once to calculate the remaining wait, so keep it synchronized with the server. Past times mean zero remaining wait; missing or invalid guidance uses local backoff. Positive jitter never shortens the requested wait. The same `maxRetryAfterMs` ceiling defers long waits with `StreamRetryDeferredError` before spending a reconnect, and your signal cancels an in-process wait promptly. Terminal errors never advance a cursor or acknowledgement.
|
|
426
|
+
|
|
427
|
+
The decoder is bounded and fails visibly rather than skipping bad data. A successful response that is not `text/event-stream`, a data frame whose payload is not JSON or not an object, a frame with no usable sequence (no finite `seq` in the envelope and no finite SSE `id`), or a `resync` frame whose payload is not a resync object throws `StreamProtocolError` (`reason` `unexpected_media_type`, `invalid_json`, `invalid_envelope`, `unusable_sequence`, `invalid_resync`). A frame that has not reached its blank-line delimiter after `maxFrameBytes` (default 1 MiB) throws `frame_too_large` and closes the connection. The error carries `lastSeq` (the last sequence delivered on that connection; a malformed frame never moves the cursor), `frameId`, `event` and `bytes`, and never the raw payload. Comment lines such as `: keep-alive`, LF and CRLF framing, unknown SSE fields and unknown-but-valid envelope `type`s are compatible as before.
|
|
428
|
+
|
|
429
|
+
`streamFeed()` reads one connection and stops when it ends. For a long-lived consumer, use `streamFeedResilient()`, which reconnects for you:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import {
|
|
433
|
+
streamFeedResilient,
|
|
434
|
+
StreamProtocolError,
|
|
435
|
+
StreamReconnectsExhaustedError,
|
|
436
|
+
StreamRetryDeferredError,
|
|
437
|
+
} from "@0xinsider/sdk";
|
|
438
|
+
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
try {
|
|
441
|
+
for await (const frame of streamFeedResilient(client, {
|
|
442
|
+
event: ["WhaleTradesInserted"],
|
|
443
|
+
signal: controller.signal, // abort to stop; the iterator ends without throwing
|
|
444
|
+
onReconnect: (attempt, lastSeq, cause) => console.warn("reconnecting", attempt, lastSeq, cause),
|
|
445
|
+
})) {
|
|
446
|
+
if (frame.kind === "resync") await refetchCurrentState();
|
|
447
|
+
else handle(frame.envelope);
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err instanceof StreamRetryDeferredError) scheduleAt(err.retryAt, () => resume(err.lastSeq));
|
|
451
|
+
else if (err instanceof StreamReconnectsExhaustedError) resumeLater(err.lastSeq);
|
|
452
|
+
else if (err instanceof StreamProtocolError) decideRecovery(err.reason, err.lastSeq, err.frameId);
|
|
453
|
+
else throw err; // 400, 401, 402, 403, 423: fix the request or the key
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
- **Reconnects** when the server closes the connection, on a network error, a 429, or a 5xx. Each reconnect resumes after the last delivered `seq` (`Last-Event-ID`), so frames are neither repeated nor skipped while the server still retains them.
|
|
458
|
+
- **Waits** for `Retry-After` (delta-seconds or an HTTP-date) plus jitter when the refusal carries one, otherwise a jittered backoff from 1 s up to 30 s. A `Retry-After` past `maxRetryAfterMs` (default 60 s, the same ceiling as REST retries) is not waited out: the loop throws `StreamRetryDeferredError` with `retryAt` (the server's not-before instant) and `lastSeq`, spends no reconnect on it, and never shortens the wait. Pass `maxRetryAfterMs: Infinity` to hold the process for however long the server says; a `monthly_quota_exceeded` refusal names the first of next month, so that is a choice, not the default.
|
|
459
|
+
- **Stops** with the original error on any other 4xx, and with `StreamProtocolError` on a malformed frame or a non-SSE response: resuming from `lastSeq` would replay the same frame, so the decision (resume later, skip past `frameId`, or refetch state and attach live) is yours. After `maxReconnects` (default 10) consecutive connections that delivered nothing, it throws `StreamReconnectsExhaustedError` with `lastSeq`.
|
|
460
|
+
- **Resync:** `resync` markers are yielded unchanged, and the marker's `id` becomes the resume cursor.
|
|
461
|
+
- **No deadline:** `timeoutMs` never applies to the stream. Every connection's body reader is released when it ends.
|
|
462
|
+
|
|
463
|
+
A `consumeStream(client, { onEvent, onResync }, options)` callback variant is also exported.
|
|
464
|
+
|
|
465
|
+
#### Acknowledged checkpoints
|
|
466
|
+
|
|
467
|
+
`cursor.seq` is written before a frame reaches your code, so it says the frame arrived and nothing about whether you finished with it. A handler that throws, then a reconnect from that cursor, resumes *after* the event you failed on. When losing an event would lose work, drive the stream with `consumeStreamCheckpointed()`: it keeps the received cursor for transport progress and adds a separate `StreamCheckpoint` that advances only after your handler and your own durable write have both resolved.
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { consumeStreamCheckpointed, StreamHandlerFailedError } from "@0xinsider/sdk";
|
|
471
|
+
|
|
472
|
+
const checkpoint = { seq: await loadCheckpoint() }; // undefined on a cold start
|
|
473
|
+
try {
|
|
474
|
+
await consumeStreamCheckpointed(client, {
|
|
475
|
+
onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
|
|
476
|
+
onResync: async () => { await refetchCurrentState(); }, // awaited barrier
|
|
477
|
+
onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
|
|
478
|
+
}, {
|
|
479
|
+
event: ["WhaleTradesInserted"],
|
|
480
|
+
lastEventId: checkpoint.seq,
|
|
481
|
+
checkpoint,
|
|
482
|
+
signal: controller.signal,
|
|
483
|
+
onHandlerError: (err, f) => console.warn("handler failed", f.seq, f.attempt, f.willRetry, f.cancelled, err),
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err instanceof StreamHandlerFailedError) parkForRepair(err.seq, err.replayFrom, err.cause);
|
|
487
|
+
else throw err; // the same stream errors as streamFeedResilient
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- **Order, per event:** `onEvent` is awaited, then `onCheckpoint(seq, "event")` (your durable write), and only then does `checkpoint.seq` become `seq`. A rejection at either step leaves the checkpoint *before* the event.
|
|
492
|
+
- **Replay:** a failure closes the connection, waits a jittered backoff (1 s to 30 s) and reconnects from the checkpoint, so the unacknowledged event is delivered again while the server retains it. After `maxHandlerRetries` (default 3) consecutive failures at the same `seq` it throws `StreamHandlerFailedError` with `seq`, `stage`, `attempts`, the unadvanced `checkpoint` and the `replayFrom` to resume with later.
|
|
493
|
+
- **Resync is a barrier:** `onResync` is awaited and the checkpoint moves to the marker only once it resolves, so an interrupted refresh is retried instead of being recorded as done. The fire-and-forget `StreamOptions.onResync` notification is not awaited and is not a barrier.
|
|
494
|
+
- **Backpressure, not buffering:** nothing is read from the connection while your handler runs. At most one frame is in flight and no queue is kept on your behalf; a slow handler is backpressure on the socket, and falling far enough behind the retained window surfaces as a `resync` marker.
|
|
495
|
+
- **At-least-once:** a replay re-delivers every unacknowledged frame, and a handler that succeeded but whose checkpoint write failed sees its event again. Deduplicate on `seq` or make the side effect idempotent; no client can promise exactly-once side effects.
|
|
496
|
+
- **Abort:** aborting `signal` ends the consumer without throwing. A handler already running is awaited rather than cancelled (pass the same signal into your own work if you want that), and if it and its durable checkpoint write succeed, the checkpoint is committed first. If an awaited event, resync, or checkpoint callback rejects while the signal is aborted, the acknowledgement stays unchanged and the consumer returns, including with zero or exhausted handler retries. `onHandlerError` still receives the original rejection, with `willRetry: false` and optional `cancelled: true`; cancellation spends no handler retry. The flag means cancellation stopped the consumer when the failure was observed, without claiming it caused the rejection, so unrelated failures racing shutdown remain visible. `attempt` remains the 1-based consecutive failure occurrence at that sequence. Without cancellation, bounded retries and `StreamHandlerFailedError` are unchanged. The diagnostic callback is synchronous and must not throw; it may abort the signal to stop the consumer.
|
|
497
|
+
|
|
498
|
+
### Webhook verification
|
|
499
|
+
|
|
500
|
+
The backend signs every delivery as `v1=hex(HMAC_SHA256(signing_secret, "<timestamp>.<raw_body>"))`, sent on `x-0xinsider-signature` (the timestamp is on `x-0xinsider-timestamp`). Verify the raw body BEFORE parsing it; re-serializing changes bytes and breaks the HMAC.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { verifySignature, parseWebhookEvent } from "@0xinsider/sdk";
|
|
504
|
+
|
|
505
|
+
// In your webhook handler (raw body string, not parsed JSON):
|
|
506
|
+
const ok = verifySignature({
|
|
507
|
+
secret: process.env.WEBHOOK_SIGNING_SECRET!,
|
|
508
|
+
timestamp: req.headers["x-0xinsider-timestamp"],
|
|
509
|
+
signature: req.headers["x-0xinsider-signature"],
|
|
510
|
+
body: rawBody,
|
|
511
|
+
});
|
|
512
|
+
if (!ok) return res.status(400).end();
|
|
513
|
+
|
|
514
|
+
const event = parseWebhookEvent(rawBody);
|
|
515
|
+
switch (event.type) {
|
|
516
|
+
case "wallet_grade_changed": // Pro-only
|
|
517
|
+
console.log(event.data.new_grade);
|
|
518
|
+
break;
|
|
519
|
+
case "insider_radar_flag_raised": // Pro-only
|
|
520
|
+
console.log(event.data.suspicion_score);
|
|
521
|
+
break;
|
|
522
|
+
case "whale_trades_inserted": // Pro-only
|
|
523
|
+
console.log(event.data.count);
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`verifySignature` enforces a 300-second replay tolerance (`toleranceSeconds` overrides it: a payload exactly that many seconds from `nowSeconds` still passes, one second further fails, and `0` accepts only the current second) and uses a constant-time compare, mirroring the backend exactly. It returns `false` on a bad signature or a malformed timestamp (never throws for those); it throws only on receiver misconfiguration: an empty secret, or a `toleranceSeconds` that is `NaN`, infinite or negative, each of which would otherwise silently turn the replay window off. Treat a `false` as the sender's problem (answer 4xx) and a throw as your own bug (log it). Typed payloads are exported for every event type, including the four Pro-only events (`whale_trades_inserted`, `wallet_grade_changed`, `insider_radar_flag_raised`, `smart_money_flow_detected`), which deliver only to API keys on an active Pro subscription.
|
|
529
|
+
|
|
530
|
+
### Staged webhook secret rotation
|
|
531
|
+
|
|
532
|
+
Use the staged operations when the receiver needs time to deploy a new secret:
|
|
533
|
+
|
|
534
|
+
1. Call `prepareWebhookSecret` and store the one-time `signing_secret`; the current secret keeps working.
|
|
535
|
+
2. Deploy the prepared secret and configure `verifySignature` to accept the comma-separated `v1=` candidates the sender emits during overlap.
|
|
536
|
+
3. Call `activateWebhookSecret`; deliveries carry both the new and previous signatures for one hour.
|
|
537
|
+
4. After the receiver rollout, call `retireWebhookSecret` to end the overlap early.
|
|
538
|
+
|
|
539
|
+
The endpoint's `secret_rotation.status` is `idle`, `pending`, or `overlap`. `rotateWebhookSecret` remains the immediate replacement path for emergency response and clears staged state. The staged operations are idempotent writes and accept `idempotencyKey`.
|
|
540
|
+
|
|
541
|
+
## Retries
|
|
542
|
+
|
|
543
|
+
The client retries a failed request when retrying is safe and can help. `maxRetries` defaults to 2 and can be set on the client or on one call; `0` sends exactly one request.
|
|
544
|
+
|
|
545
|
+
- **What is retried:** a GET, a read-only POST (`batchGetTraders`, `batchGetMarketIntel`, which resolve their inputs and store nothing), or one of the eight writes the API replays under `Idempotency-Key` (`createWebhook`, `updateWebhook`, `deleteWebhook`, `rotateWebhookSecret`, `prepareWebhookSecret`, `activateWebhookSecret`, `retireWebhookSecret`, `redeliverWebhookDelivery`, exported as `IDEMPOTENT_WRITE_OPERATIONS` and pinned to the OpenAPI contract by the drift check) when it carries a key; each when it fails with 408, 429, 502, 503, or 504, or with a network error before any response. A 408 is the server's own 30-second timeout (`error.code` `request_timeout`, thrown as `ServerTimeoutError` when retries run out); it carries `Retry-After` on a GET, and a keyed write replays safely. `retryEligibility(operation)` returns `"read"`, `"keyed"` or `"never"`.
|
|
546
|
+
- **How long it waits:** the response's `Retry-After` plus up to 250 ms of jitter; delta-seconds and HTTP-date forms both parse (`parseRetryAfter` is exported), and a negative, non-finite or malformed value is treated as absent. Without the header, a jittered exponential backoff from 500 ms, capped at 8 s. A `Retry-After` longer than 60 s (`RETRY_AFTER_CEILING_MS`) is not waited out; the error is thrown for you to schedule from `retryAfterSeconds` or `retryAt`. No wait is ever handed to a timer past Node's 2147483647 ms range, which would fire at once.
|
|
547
|
+
- **What is never retried:** 400, 401, 402, 403, 404, 409, 500, a write without an idempotency key, any write outside the eight above (`verifyWebhook` sends a challenge to your URL each time; `submitTraderExport` starts a job; an MCP call runs a tool), the client's own timeout (`RequestTimeoutError`), or your own abort. A key on one of those writes is refused before the request is sent, because the server would ignore it and the retry it seemed to license could repeat the effect.
|
|
548
|
+
- **Deadline:** all attempts share one `timeoutMs` deadline (15 s by default). A retry that cannot finish before it is not started, so you get the real error, not a timeout. An `AbortSignal` ends a backoff immediately.
|
|
549
|
+
|
|
550
|
+
Webhook writes accept `idempotencyKey`. It makes the write safe to replay and eligible for retry:
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
await client.call("createWebhook", {
|
|
554
|
+
body: {
|
|
555
|
+
name: "Large trades",
|
|
556
|
+
url: "https://example.com/hooks/0xinsider",
|
|
557
|
+
event_types: ["whale_trades_inserted"],
|
|
558
|
+
},
|
|
559
|
+
idempotencyKey: crypto.randomUUID(), // reuse the SAME key if you retry by hand
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
The API honours `idempotencyKey` on `createWebhook`, `updateWebhook`, `deleteWebhook`, `rotateWebhookSecret`, `prepareWebhookSecret`, `activateWebhookSecret`, `retireWebhookSecret`, and `redeliverWebhookDelivery`, and nowhere else. On `IdempotencyInProgressError`, retry with the same key and body.
|
|
564
|
+
|
|
565
|
+
The SDK validates the effective `Idempotency-Key` before sending: it must be nonempty after trimming and at most 255 UTF-8 bytes, including when `maxRetries` is `0`. You can supply it through `headers` using any header-name casing; `idempotencyKey` overrides that header when defined. An invalid effective value throws a configuration error without fetching. Supply a stable nonempty key, or omit both options to send a supported mutation once. The SDK never truncates a key or generates a replacement for you.
|
|
566
|
+
|
|
567
|
+
Safe replay is not exactly-once. The key lets the server answer a repeat of the same request with the first result; it does not tell you what happened when you never saw a result. After a `RequestTimeoutError`, an exhausted retry budget, or your own abort on a keyed write, the outcome is unknown: reconcile it by sending the same key and the same body once more (the server replays the stored result, or `IdempotencyInProgressError` says it is still running), or by reading the resource (`listWebhooks`, `listWebhookDeliveries`). Never mint a new key for the same intent, and never reuse a key with a different body, which the API answers with 422. Every automatic retry the client makes keeps the key and the exact request bytes.
|
|
568
|
+
|
|
569
|
+
A delivery that exhausts its retries, or that was queued when its endpoint was disabled, ends as `dead_letter`. Re-enabling the endpoint resends nothing; requeue each one with `client.redeliverWebhookDelivery(webhookId, deliveryId, { idempotencyKey })`.
|
|
570
|
+
|
|
571
|
+
## Errors
|
|
572
|
+
|
|
573
|
+
Every readable non-2xx response throws an `OxinsiderApiError`. The SDK dispatches to a specific subclass by the documented error code:
|
|
574
|
+
|
|
575
|
+
An owned JSON/text body that fails during consumption throws `ResponseBodyReadError`, carrying `responseStatus`, `phase: "response_body"` and original transport `cause`. Its message includes no body or credential. Client deadlines keep `RequestTimeoutError`, caller cancellation keeps its exact reason, and invalid JSON remains a response-contract failure. Already consumed/locked custom-fetch responses and allocation failures keep their local rejection. Body failures gain no automatic retry; reconcile an uncertain write before replaying it.
|
|
576
|
+
|
|
577
|
+
| Code | Subclass |
|
|
578
|
+
| --- | --- |
|
|
579
|
+
| `bad_request` | `BadRequestError` |
|
|
580
|
+
| `invalid_api_key` | `InvalidApiKeyError` |
|
|
581
|
+
| `subscription_required` | `SubscriptionRequiredError` |
|
|
582
|
+
| `forbidden` | `ForbiddenError` |
|
|
583
|
+
| `not_found` | `NotFoundError` |
|
|
584
|
+
| `account_locked` | `AccountLockedError` |
|
|
585
|
+
| `rate_limited` | `RateLimitedError` (carries `retryAfterSeconds`) |
|
|
586
|
+
| `rate_limit_unavailable` | `RateLimitUnavailableError` (carries `retryAfterSeconds`) |
|
|
587
|
+
| `rate_limit_unavailable` + `reason: database_unavailable` | `DatabaseUnavailableError` (carries `retryAfterSeconds`; the database is temporarily unreachable, nothing is rate-limited) |
|
|
588
|
+
| `internal_error` | `InternalServerError` |
|
|
589
|
+
| `invalid_response` (client-side) | `InvalidResponseError` (a `2xx` whose body does not match the contract, with the body on `received`) |
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
import { RateLimitedError, OxinsiderApiError } from "@0xinsider/sdk";
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
await client.listWhaleTrades({ min_grade: "S" });
|
|
596
|
+
} catch (err) {
|
|
597
|
+
// Thrown after the client's own retries (see Retries) are exhausted.
|
|
598
|
+
if (err instanceof RateLimitedError) {
|
|
599
|
+
await sleep((err.retryAfterSeconds ?? 1) * 1000);
|
|
600
|
+
} else if (err instanceof OxinsiderApiError) {
|
|
601
|
+
console.error(err.status, err.code, err.requestId);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Each error exposes `status`, `code`, `error` (the `{ code, message, doc_url, param }` body), `meta`, `requestId`, `retryAfterSeconds`, and the raw `body`. `requestId` prefers JSON `meta.request_id`, falling back to a nonempty received `X-Request-ID` header. The header never creates a synthetic `meta` object. Every HTTP error retains parsed `Retry-After` guidance, including generic 502/504 errors and plain-text or empty bodies; an absent or invalid header gives `null`. This guidance does not authorize replaying a mutation or change which requests the SDK retries. `retryAt` remains the parsed body `error.retry_at`, or `null`.
|
|
607
|
+
|
|
608
|
+
Direct error constructors accept optional `ApiErrorTransportMetadata` after their existing arguments. `errorFromResponse(status, body, retryAfterSeconds?, { requestId }?)` preserves the same subclass selection and all existing calls.
|
|
609
|
+
|
|
610
|
+
## Authentication
|
|
611
|
+
|
|
612
|
+
Pass `apiKey` to the client constructor. Public operations (`getApiDiscovery`, `getPlatforms`, `getHealth`, `getPickOfTheDayLedger`, and `registerAgent`, which mints the sandbox key) do not require a key; everything else does, and the SDK throws before making a request if a key is missing for a bearer operation, unless the client is in [sandbox](#sandbox) mode, where no operation needs one. Pro-only data (graded webhook events, parts of the stream) requires a key on an active Pro subscription.
|
|
613
|
+
|
|
614
|
+
## Examples
|
|
615
|
+
|
|
616
|
+
- [`examples/sandbox.mjs`](examples/sandbox.mjs): no key. The sandbox leaderboard, then a documented `429` on demand.
|
|
617
|
+
- [`examples/sdk-list-s-grade-wallets.ts`](examples/sdk-list-s-grade-wallets.ts): a live key. S-grade wallets, S-grade whale trades with cursor pagination, and a filtered live stream.
|
|
618
|
+
|
|
619
|
+
## How it is built
|
|
620
|
+
|
|
621
|
+
`src/schema.ts` is generated from the published [OpenAPI document](https://0xinsider.com/api/v1/openapi.json) by `scripts/generate.mjs`, which keeps the exact bytes it read in `openapi.json` and renders the types with the same generator the 0xinsider app runs (vendored in `scripts/app/`), so they are byte-identical to the app's for the same document. The client itself (`src/client.ts`, `src/stream.ts`, `src/pagination.ts`, `src/retry.ts`, `src/webhooks.ts`, `src/errors.ts`) is hand-written against those types.
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
npm run generate # fetch the published document; rewrite openapi.json, src/schema.ts, src/provenance.ts
|
|
625
|
+
npm run check # the committed files match the committed snapshot, and the client table matches it
|
|
626
|
+
npm run check:live # the same checks against the published document: is this release behind the API?
|
|
627
|
+
npm test # build, then the unit tests (node:test, no network)
|
|
628
|
+
npm run test:sandbox # live calls against https://0xinsider.com/sandbox, no key
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
A weekly workflow regenerates from the published document and opens a pull request when it changed. The drift check fails when the document gains or loses an operation, a query parameter or an `Idempotency-Key` route that the hand-written client does not follow, so that pull request says what to add. Releases publish to npm from GitHub Actions through trusted publishing, with a provenance attestation; [RELEASING.md](RELEASING.md) has the steps.
|
|
632
|
+
|
|
633
|
+
## Provenance
|
|
634
|
+
|
|
635
|
+
`src/provenance.ts`, generated alongside the types, says which document a release was generated from, and the package exports it: `OPENAPI_SHA256` (the SHA-256 of the document bytes), `OPENAPI_VERSION`, `OPERATION_COUNT`, `OPENAPI_SOURCE`, and `APP_COMMIT`, the `0xinsider/0xinsider` commit that last changed `web/public/api/v1/openapi.json` (or `null` when it could not be resolved).
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
import { OPENAPI_SHA256 } from "@0xinsider/sdk";
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Compare `OPENAPI_SHA256` with `curl -s https://0xinsider.com/api/v1/openapi.json | shasum -a 256` to see whether a release is behind the API.
|
|
642
|
+
|
|
643
|
+
## Other official tools
|
|
644
|
+
|
|
645
|
+
- Python SDK: `pip install 0xinsider` ([0xinsider/0xinsider-python](https://github.com/0xinsider/0xinsider-python))
|
|
646
|
+
- Go SDK: `go get github.com/0xinsider/0xinsider-go` ([0xinsider/0xinsider-go](https://github.com/0xinsider/0xinsider-go))
|
|
647
|
+
- Rust SDK: crate `oxinsider` ([0xinsider/0xinsider-rust](https://github.com/0xinsider/0xinsider-rust); until its first crates.io release, `cargo add oxinsider --git https://github.com/0xinsider/0xinsider-rust`)
|
|
648
|
+
- CLI and MCP server: `npm install --global @0xinsider/mcp` or `brew install 0xinsider/tap/oxinsider`
|
|
649
|
+
- Remote MCP server: `https://api.0xinsider.com/api/v1/mcp`
|
|
650
|
+
- Agent Plugin and skills: [0xinsider/agent-plugin](https://github.com/0xinsider/agent-plugin)
|
|
651
|
+
- Machine-readable contract: https://0xinsider.com/llms-full.txt and https://0xinsider.com/agents.md
|
|
652
|
+
|
|
653
|
+
## License
|
|
654
|
+
|
|
655
|
+
MIT
|
|
656
|
+
|
|
657
|
+
## Immutable whale datasets
|
|
658
|
+
|
|
659
|
+
`submitWhaleDataset({ from, to, condition_id?, min_size? })` submits a past
|
|
660
|
+
window up to 31 days. Poll `getWhaleDatasetStatus(jobId)` according to
|
|
661
|
+
`next_action` and `poll_after_s`. `downloadWhaleDataset(jobId)` streams decoded
|
|
662
|
+
NDJSON and verifies the immutable content checksum when the body finishes.
|
|
663
|
+
The signed URL is never sent your API credential. `cancelWhaleDataset(jobId)`
|
|
664
|
+
uses the same owner-scoped lifecycle as trader exports.
|
|
665
|
+
|
|
666
|
+
Rows contain exact decimal strings and detected whale-alert facts, not all
|
|
667
|
+
provider fills or current wallet grades. For continuation, send the manifest's
|
|
668
|
+
cursor and normalized condition/size filters through
|
|
669
|
+
`client.call("getEventReplaySince", { query })`. Omit null filters and keep the
|
|
670
|
+
`min_size` decimal string unchanged. Deltas
|
|
671
|
+
include already committed post-window arrivals, late trades, and intentional
|
|
672
|
+
overlap: deduplicate on the `wt_` ID (or `payload.whale_alert_id`). This is an
|
|
673
|
+
insertion feed, not updates or deletions. See the OpenAPI manifest for retention,
|
|
674
|
+
row/byte bounds and source coverage.
|