Skip to content

About

Official Node.js and TypeScript client for the 0xinsider Developer API: Polymarket sports and esports analytics, wallet grades, large trades, sharp-money flow

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@0xinsider/sdk

Official TypeScript SDK for the 0xinsider API: analytics for Polymarket sports and esports markets. Wallet grades, large trades, combos, profitable-wallet flows, market intel and OHLC candles, a live SSE feed, and signed webhooks.

The types are generated from the published OpenAPI document. The release check compares generated types and the operation table against the committed OpenAPI snapshot.

Requirements

  • Node.js 22+ (uses native fetch, ReadableStream, and node:crypto). Zero runtime dependencies.
  • 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.
  • 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.
  • 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.
  • An API key. Your key looks like oxi_sk_live_.... Create one at 0xinsider.com/developers. The sandbox needs none.

Install

npm install @0xinsider/sdk

For local SDK development, build this repository and install it from the folder:

git clone https://github.com/0xinsider/0xinsider-node
(cd 0xinsider-node && npm ci && npm run build)
npm install ./0xinsider-node   # from your project, with the path to the clone

The package is ESM-only (import, not require) and ships its own type declarations.

Development checks use npm run check-types and npm run lint. The SDK checks collection indexing and optional fields strictly: callers guard missing array entries, and request options omit absent fields. These checks preserve API payloads and caller-owned abort reasons. The lint contract exceptions record the named compatibility aliases, exact abort reasons and stream cleanup behavior preserved by the configuration.

Try it with no key against the sandbox:

git clone https://github.com/0xinsider/0xinsider-node && cd 0xinsider-node
npm ci && npm run build && node examples/sandbox.mjs

Types

SDK 0.28.0 requires Node.js 22 or newer: engines.node is >=22, and the package type-checks against Node 22's types. Node 18 reached end of life on 2025-04-30 and Node 20 on 2026-04-30. Request deadlines and caller signals now combine through AbortSignal.any, which every supported Node version has. Method names, request options and response types are unchanged; two scheduled Pick of the Day doc comments are refreshed.

SDK 0.27.1 refreshes Pick of the Day field guidance and synchronizes the current canonical response declarations. getMarketHolders.total is required in the returned type, matching the existing API response; consumers constructing mock values must include it. Existing webhook payload names remain available and use the generated contract declarations. Request behavior and the Node.js 18+ requirement are unchanged.

API response types come from the published OpenAPI document (https://0xinsider.com/api/v1/openapi.json), generated by scripts/generate.mjs into src/schema.ts. All component schemas are exported by name, and five per-operation maps (OperationPath, OperationQuery, OperationBody, OperationData, OperationResponse) type every convenience method, call, list and paginate by the operation they name: the path parameters it takes, the query keys and values it documents, the body it requires, and the envelope it answers with its own data and meta (BatchResponseMeta on a batch, EventReplayMeta on the replay). Nothing takes a type argument any more; the method knows its shape.

const trader = await client.getTrader("swisstony", { query: { expand: ["strategy"] } });
if (trader.object === "trader") {
  trader.data?.grade; // Grade | null
  trader.meta.request_id; // ResponseMeta, always present
}

const explore = await client.exploreMarkets({ sort: "trending", limit: 10 });
explore.data[0]; // ExploreEntry, and explore.facets is typed too
explore.data[0].title; // compile error: an ExploreEntry has no title

const batch = await client.batchGetTraders(["swisstony", "0xabc..."], { expand: ["trust"] });
if (batch.object === "trader_batch") batch.meta.request_cost; // BatchResponseMeta

client.listInsiderRadar({ min_grade: "S" }); // compile error: the route takes min_suspicion and severity

Every method forwards the same transport options, so a conditional read, a deadline or a cancellation looks the same everywhere: client.listLargeTradesConditional({ min_grade: "S", since }, { signal, timeoutMs: 5_000, maxRetries: 0, headers: { "If-None-Match": etag } }). A list method such as listLargeTrades() throws on a 304; send If-None-Match through a ...Conditional method or call(), which return the typed not_modified result instead.

call, list, paginate, paginatePages and collect are typed the same way when the operation id is a literal. Given an id chosen at runtime, or an explicit type argument, they fall back to the loose form (path, query and body as plain records, the generic { object, data, meta? } envelope back) for the caller that asserts the shape itself.

npm run check regenerates from the committed snapshot and compares on every push, so a contract change that nobody regenerated fails CI rather than shipping as a stale type.

SDK 0.25.0 adds PnlCorrection: pnl.correction on a Trader, and pnl_correction on a LeaderboardEntry and a TrendingWallet. It is present only when 0xinsider corrects Polymarket's P&L for neg-risk conversions Polymarket misbooks, in either direction: cost charged to inactive placeholder outcomes and never booked as a loss, or shares a conversion minted that Polymarket's records leave out. markets_measured says how many of the wallet's converted markets the amount sums; markets_excluded, when present, counts markets left at Polymarket's figure because shares moved in or out by direct transfer. A correction is published only once every converted market of the wallet is checked; until then pnl.correction_pending is true on a Trader. The body's own P&L fields keep Polymarket's figures; correction.net_total is the total the 0xinsider profile shows and correction.realized is what the grade uses.

const trader = await client.getTrader("swisstony");
const total = trader.data.pnl.correction?.net_total ?? trader.data.pnl.net_total;

SDK 0.27.0 types 23 response fields as always present, because the API sends them on every response and earlier releases typed them field?: T. Among them: freshness and score_components on an ExploreMarket, pick_rank on a PickOfTheDayArchiveEntry, stake_usd and series on a PickOfTheDayHitRate, and large_trade_count in market flows. Code that checks them for undefined keeps compiling, and the checks can go. The same release removes monthly_quota.unavailable_reason from the usage type, which the API has never sent: code that reads it no longer compiles.

SDK 0.29.0 adds monthly_quota.unavailable_reason back to the usage type, now that GET /api/v1/usage sends it on every response: "usage_price_reconciliation_required" while the account's usage subscription carries a price billing has not reconciled to its plan (pay as you go stays off), otherwise null.

Combos

client.combos.fills() returns observed Polymarket combo fills with their legs, wallet context, and coverage. client.combos.summary() reads the live combo count, and client.combos.get(conditionId) reads one combo's fills and lifecycle. These reads require an active Pro or Max credential.

const page = await client.combos.fills({ sort: "recent", limit: 20 });
for (const fill of page.data) console.log(fill);

const summary = await client.combos.summary();
if (summary.object === "combos_summary") console.log(summary.data.live);

const detail = await client.combos.get(conditionId);
if (detail.object === "combo") console.log(detail.data);

Keep the same filters while paging. For sort: "recent", send next_cursor as cursor on the next request. Ranked sorts return next_offset; send it as offset instead, and stop when has_more is false. Use combos.fills() directly for ranked pages because the shared paginate() helper follows cursors only.

Read coverage before treating the observed fills as a complete history. An unavailable live count is null, not zero; the response types preserve nullable and optional provider values.

Pick proof by stable identity

getPickOfTheDayLedgerEntry(pickId) reads the public proof for one pick without an API key. Keep pick_id as its decimal string; converting it to Number can lose precision.

const publicClient = new OxinsiderApiClient();
const proof = await publicClient.getPickOfTheDayLedgerEntry("731");
if (proof.object === "pick_of_the_day_ledger_entry") {
  console.log(proof.data.state, proof.data.pick_id);
}

Committed entries carry commitment_version: legacy version 1 payload bytes remain unchanged; new version 2 payloads bind pick_id and version: 2 instead of a rank. Only opened entries disclose the payload and nonce. Deprecated pick_rank fields and dated routes remain compatible.

Quickstart

REST

import { OxinsiderApiClient } from "@0xinsider/sdk";

const client = new OxinsiderApiClient({
  apiKey: process.env.OXINSIDER_API_KEY, // oxi_sk_live_...
  // baseUrl defaults to https://api.0xinsider.com
});

// Single resource: { object, data, meta }
const trader = await client.getTrader("swisstony");
console.log(trader.data);

// 1 to 25 traders in one request (POST /api/v1/traders/batch). The body
// carries `traders` and the optional shared `expand`; `data` keeps request
// order, one row per input, each `status: "ok"` with `data` or
// `status: "error"` with that item's own `error`.
const batch = await client.batchGetTraders(["swisstony", "trd_123"], {
  expand: ["strategy", "trust"],
});
for (const item of batch.data) {
  if (item.status === "ok") console.log(item.input, item.data?.grade);
  else console.log(item.input, item.error?.code);
}

// A list endpoint returns the Stripe-style envelope:
// { object: "list", data, has_more, next_cursor, meta }
const board = await client.listLeaderboard({ strategy: "swing_trader", limit: 100 });
console.log(board.data, board.has_more, board.next_cursor);

`listPositions()`, `listLargeTrades()` and `listLargeTradeHistory()` add
`data_quality` beside `data`. Read its `status`, `as_of` and `field_groups`
before treating a page as current. Positions report positions, trader and
market writer clocks; large-trade pages report alert, trade, trader, ranking,
market and volume clocks. `whale_alerts.inserted_xid` is a transaction
identifier, so the alert group is explicitly `unknown`. The stored clocks are
part of the ETag; `meta.cached` and `meta.cache_age_s` describe transport only.

`assessDataQuality()` turns that block into one decision against your own
tolerance. It is a pure function over the response and makes no request:

```ts
import { assessDataQuality } from "@0xinsider/sdk";

const trader = await client.getTrader(address);
// A trader carries data_quality inside data; a list page carries it beside data.
const verdict = assessDataQuality(trader.data, { maxAgeMs: 15 * 60_000 });
if (!verdict.ok) {
  // Each entry names the group, its status, its age and why it failed.
  console.warn(verdict.failing);
}

A group passes only when its status is fresh, it carries as_of, and that clock is within maxAgeMs. fresh means tracked and clocked, not current enough for you. unknown fails: the read cannot date that group, and missing is never recent. untracked groups are listed in verdict.untracked and left out of the verdict. Pass groups: ["ranking", "volume"] to judge only the groups you read; a named group the body does not carry fails as missing. The Python (assess_data_quality) and Go (AssessDataQuality) SDKs apply the same rule.

// Keep compatibility by default, or opt into rejection of unsupported query // names. The SDK exposes the server's diagnostics on response metadata. const checked = await client.listLeaderboard( { strategy: "swing_trader" }, { strictQuery: true }, ); console.log(checked.meta?.effectiveQuery, checked.meta?.queryIgnored);

// OHLC candles for a market (open or resolved): const candles = await client.getMarketCandles("0xabc...", { query: { resolution: "1d" }, });

// Webhook event catalog (self-describing) and one endpoint's delivery log: const events = await client.listWebhookEvents(); const deliveries = await client.listWebhookDeliveries(123, { limit: 50 });

// Any operation by id, typed by the id: path, query, body and the envelope. const usage = await client.call("getUsage"); const holders = await client.call("getMarketHolders", { path: { condition_id: "0x..." }, query: { outcome: "yes", limit: 25 }, });


An operation with path parameters or a request body needs its options argument: `client.call("createWebhook")` and `paginate(client, "listWebhookDeliveries")` do not compile. The same holds for `list()`, `text()`, `paginatePages()` and `collect()`. The untyped form takes an operation id chosen at runtime (a value typed `ApiOperationId`), and a missing path parameter there throws before any request.

`getAccountIdentity()` remains available to a valid credential after paid data
access lapses and returns `credential_status` plus
`entitlement.paid_data_access` and `entitlement.recovery_action`. Call
`getUsage()` for the caller's rate-limit, daily-usage and monthly-quota state;
paid data routes still return `402 subscription_required` until access is
renewed.

### Edge Hunter (Max only)

Read the same experimental board as [Edge Hunter](https://0xinsider.com/sports/edge):

```ts
const board = await client.edgeHunter.get({ horizon_hours: 48 });
if (board.object === "edge_hunter") {
  console.log(board.data.total, board.data.rows, board.data.as_of);
}

The board defaults cover all sports and esports with a B grade floor and at most 30 ranked rows. total counts selected admitted rows before the cap. horizon_hours defaults to 48 and clamps to 1..48; min_grade accepts S/A/B and limit defaults to 30 and clamps to 1..30. The board has no ETag or pagination.

Every row includes provider participants, both outcome quotes, and backed-side analytics. Current prices are decimal strings with their own basis, ts, and optional validated_at; compare those clocks independently from as_of. Null means unavailable. SDK 0.23.0 adds nullable outcomes[].token_id and signal.token_id, frozen signal.ranked_at, and the existing aggregate signal.category_skill evidence to the board. ranked_at copies the same snapshot computation time as the ranked feed's signal_created_at; category evidence retains its own status, coverage and clocks. These additions do not change admission, ordering or quotes, and no price is a wallet entry cost. The generator now models both current_price properties and row.signal as nullable references; guard row.signal and an unavailable quote before reading their fields. ranking_source: "db_only" and directional_source: "degraded" identify weaker evidence. This board is analytics, not an instruction to place an order.

A Pro credential throws MaxPlanRequiredError (403 forbidden, reason: max_plan_required); every other Pro route keeps working with the same key. All four previous sports-edge and pre-game-side paths are retired and return 410 endpoint_retired. Their list and conditional-read methods were removed in SDK 0.20.0. Migrate to client.edgeHunter.get(). SDK 0.21.0 preserves the default board and adds the complete advanced contracts:

const signals = await client.edgeHunter.get({ view: "signals", category: "Soccer", min_grade: "A", limit: 20 });
if (signals.object === "list") console.log(signals.view, signals.data, signals.next_cursor);

const observations = await client.edgeHunter.observations({ cohort: "in_play", limit: 20 });
if (observations.object === "list") console.log(observations.snapshot_as_of, observations.degradation_reasons, observations.coverage);

const unchanged = await client.edgeHunter.getConditional(
  { view: "observations", cohort: "in_play" },
  { headers: { "If-None-Match": observations.meta.etag ?? "" } },
);
if (unchanged.object === "not_modified") console.log("Unchanged snapshot");

view: "signals" returns full ranked records, nullable token_id, snapshot clocks, holder/directional analytics, category evidence and provenance. It accepts category, min_grade (S/A/B, default B), horizon_hours (default 12, clamp 1..48), limit (default 20, clamp 1..100) and cursor. Grade floor requires a holder at that grade or better; it does not remove other holders. signals() is a convenience for the same mode.

view: "observations" requires cohort (wider_holder, in_play, emerging_pile) and accepts category, limit and cursor. Records preserve verified token IDs, midpoint backed_price marks, observation/holder clocks, provider/directional status, and completeness. The envelope retains snapshot_as_of, degraded and per-sport funnel diagnostics. It adds required degradation_reasons and coverage. Each reason names its cause and canonical sport, or null for a cause affecting the whole generation. degraded describes the shared all-sports snapshot, even when you select a category or league. An empty reason list means the snapshot is not degraded; a clean selected funnel alone does not prove that.

coverage.input_markets, capacity_limited_markets, emitted_observations, and incomplete_holder_markets describe that whole snapshot before selection. selected_observations counts your matching cohort and selectors before pagination; returned_observations counts only the current page. These are measured counts, not a complete provider census. A legacy category value that cannot resolve to a sport returns an empty result without reading a sports snapshot; its counts are zero and its empty reasons do not establish the all-sports snapshot's health. capacity_limited is intentional admission and does not itself set degraded. Pagination cannot recover markets excluded by that admission. board_upcoming_status: "not_configured" means no upcoming source applies to the sport, not a provider failure. Price marks are not wallet entry costs; volume is shares. In-play reuse is capped at 30 seconds and fails closed without fresh source evidence.

Literal view queries have distinct result types, including on empty pages. Advanced responses add view: "signals" | "observations" beside object: "list". Both advanced views support ETag/304 and private revalidation. All public views read warmed generations only; missing/stale readiness returns 503 read_model_warming with Retry-After: 5 without running holder/provider computation in the request. getConditional() accepts advanced views only. Parameters for another view return 400, and a refreshed snapshot expires its cursors. Start a new cursor walk when migrating from retired URLs. The shared list(), paginate(), paginatePages() and collect() helpers accept Edge Hunter only with an explicit advanced view and preserve the corresponding record and envelope types:

for await (const page of paginatePages(client, "getEdgeHunter", {
  query: { view: "observations", cohort: "wider_holder", limit: 100 },
  maxPages: 2,
})) console.log(page.view, page.funnel, page.data);

SDK 0.22.0 adds common selections to every Edge Hunter view:

const nfl = await client.edgeHunter.get({ kind: "sports", leagues: ["nfl"], limit: 10 });
if (nfl.object === "edge_hunter") {
  console.log(nfl.selection, nfl.data.rows.map(row => row.market_context));
}
const selectedSignals = await client.edgeHunter.signals({
  sports: ["Soccer", "Football"], market_types: ["moneyline"], min_grade: "A", limit: 20,
});
const selectedObservations = await client.edgeHunter.observations({
  cohort: "in_play", kind: "esports", leagues: ["cs2", "lol"], limit: 20,
});
Selection Meaning
kind sports or esports; omit for both
sports Canonical sport buckets, including Football, Soccer, Esports and Table Tennis; case-insensitive
leagues Machine/provider league identities such as nfl, epl, cs2 and lol; known wire/tag aliases normalize
market_types Exact provider sports_market_type values from market_context.market_type
condition_ids Exact markets by raw provider ID or mkt_ alias
event_slugs / series_slugs Exact provider event/series identities from the row and market_context.series_slug
category Legacy canonical bucket alias: NFL selects all Football; use leagues: ["nfl"] for NFL only

Lists match OR within an axis and AND across axes. Each array must contain 1..100 supplied values before server normalization and deduplication; empty SDK arrays fail before HTTP. The SDK serializes repeated bare query names, and the server also accepts CSV inside a value. Market types and event/series slugs are trimmed exact case-sensitive strings. Unknown canonical sport names are invalid; unknown provider identities or market IDs can yield an empty selected collection.

A selected 200 response adds top-level selection with only requested normalized axes. Blank category retains omission behavior. Selected SDK reads always send X-Query-Validation: strict, even if strictQuery: false was supplied, and refuse responses missing their echo so an older serving instance cannot silently ignore new filters. Generic call(), list() and pagination use the same protection. Advanced cursors and ETags bind the normalized selection; restart at page one when changing it.

Every row adds required nullable market_context with required nullable league, series_slug, market_type and metadata_updated_at. Discover exact selectors from those frozen facts. The clock is local market_canonical.updated_at, not a provider observation time. Unselected legacy cache generations may carry market_context: null; selected reads require a ready frozen sidecar and return 503 warming if it is absent. Missing selected metadata excludes rows. Observation funnel remains source-generation/category accounting rather than a league/type/ID request-input census.

Beyond the JSON envelope

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:

The route answers Method Returns
text/markdown (context.md) text(), or getTraderContextMarkdown / getMarketContextMarkdown the document as a string
JSON-RPC 2.0 (POST /api/v1/mcp) mcp() { status, response, sessionId? }
201 (POST /api/v1/agents/register) registerAgent() the envelope, meta.status 201
302 (export download) getTraderExportDownloadUrl() / downloadTraderExport() the presigned URL, or the object response
// Markdown, ready to paste into a prompt.
const context = await client.getTraderContextMarkdown("0x9d84...1344");

// One MCP JSON-RPC message. A JSON-RPC `error` is RETURNED, not thrown:
// an unknown tool is an answer, a revoked credential is not.
const listed = await client.mcp({ method: "tools/list", id: 1 });
listed.response?.result;          // 200
const ack = await client.mcp({ method: "notifications/initialized" });
ack.status;                        // 202, ack.response is null

// A sandbox key, with no account and no credential.
const registered = await new OxinsiderApiClient().registerAgent();
registered.meta.status;            // 201
const sandbox = OxinsiderApiClient.sandbox({ apiKey: registered.data.api_key });

// A finished export. The second request carries no headers at all, so your
// live key never reaches the object store; the presigned URL authorizes
// itself. Read expiresAt: links last at most one hour and cannot outlive
// artifact retention. Request a fresh link while retained, or a new export
// after the job expires.
const { response, filename, expiresAt } = await client.downloadTraderExport(address, jobId);
await pipeline(Readable.fromWeb(response.body), createWriteStream(filename ?? "export.json"));

// Verify the immutable manifest while the decoded content streams through.
// The SDK throws ExportIntegrityError before a consumer can treat a partial
// or changed file as complete; discard any partial output and retry fresh.
const verified = await client.downloadTraderExport(address, jobId, {
  verifyChecksum: true,
});
await pipeline(Readable.fromWeb(verified.response.body), createWriteStream("export.csv"));

// Stop an export you no longer need. The answer is the job after the cancel.
const cancelled = await client.cancelTraderExport(address, jobId);
cancelled.data.status;             // "cancelled", "cancel_requested", or unchanged

cancelTraderExport answers the job resource every time. A queued job reads cancelled at once; a running job reads cancel_requested until the worker reaches its next safe point (it checks every 5 seconds and before the file is published), so poll getTraderExportStatus at poll_after_s until terminal. A job whose file is already being published, or that is already terminal, comes back unchanged: a cancel never deletes a ready file. Cancelling does not return quota; the submit's reservation keeps counting toward the daily and per-trader hourly caps, and a later submit reserves a new job.

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.

getTraderExportStatus returns the owner-authorized lifecycle and ready artifact manifest. downloadTraderExport leaves verification off by default for compatibility; pass verifyChecksum: true to fetch the status first and verify the decompressed stream against manifest.content_sha256 and manifest.content_size_bytes. A mismatch or truncated body throws ExportIntegrityError, and its message tells the caller to discard the partial file and request a fresh download. The object fetch 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.

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.

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.

Sandbox

OxinsiderApiClient.sandbox() talks to https://0xinsider.com/sandbox, the second server in the OpenAPI document: no credential, no production data, every documented operation except GET /api/v1/stream 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.

import { OxinsiderApiClient, RateLimitedError, type Trader } from "@0xinsider/sdk";

const sandbox = OxinsiderApiClient.sandbox(); // no apiKey

const board = await sandbox.listLeaderboard({ limit: 5 });
console.log(board.data, board.meta.sandbox); // [...], true

// sandbox_status is not in the route's documented query, so this call takes
// the loose form: an explicit type argument instead of the typed method.
try {
  await sandbox.call<Trader>("getTrader", {
    path: { address: "swisstony" },
    query: { sandbox_status: 429 },
  });
} catch (err) {
  if (err instanceof RateLimitedError) console.log(err.retryAfterSeconds); // 60
}

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 are not simulated; streamFeed on a sandbox client throws the sandbox's own BadRequestError saying so. The Markdown routes and the export download do work there: each Markdown document is built from the same sandbox trader and market the JSON routes serve, and the download's 302 points at a sample CSV the sandbox serves itself. 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.

Pagination

paginate() follows next_cursor over the list envelope and yields each item; paginatePages() yields whole envelopes when you need meta or total.

import { paginate } from "@0xinsider/sdk";

for await (const trade of paginate(client, "listWhaleTrades", {
  query: { min_grade: "S", limit: 100 },
})) {
  console.log(trade);
}

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 neither a string, null nor omitted throws InvalidResponseError. A null terminal cursor is preserved for Edge Hunter observations.

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:

import { paginatePages, type PaginationProgress } from "@0xinsider/sdk";

const progress: PaginationProgress = {};
for await (const page of paginatePages(client, "listWhaleTrades", {
  query: { min_grade: "S", limit: 100 },
  maxPages: 3,
  progress,
})) {
  handle(page.data);
}
if (progress.stoppedBy === "max_pages") {
  // the collection has more; continue later from progress.nextCursor
  saveContinuation(progress.nextCursor);
}

signal cancels between pages.

Progress counts each validated page before it is delivered, including a page after which you break. It counts pages delivered, not items processed or persisted. An early return retains nextCursor and leaves stoppedBy unset unless that delivered page already exhausted the list or reached maxPages. Reusing a progress object resets its owned fields when the new walk starts. During a request, cursor names that attempt and nextCursor is undefined. After a failure or between-page cancellation, cursor and nextCursor name the retry cursor, and pagesFetched matches paginationResumePoint(error); the original error is rethrown.

Pass strictQuery: true to paginate(), paginatePages(), or collect() to reject an unsupported query name before the first page request. With the default compatibility mode, successful responses expose meta.queryIgnored and meta.effectiveQuery when the server received query parameters that were ignored or applied. The same option is available on the typed list methods and on client.call().

Each page request retries on its own (see 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:

import { paginate, paginationResumePoint } from "@0xinsider/sdk";

try {
  for await (const trade of paginate(client, "listWhaleTrades", { query })) handle(trade);
} catch (err) {
  const resume = paginationResumePoint(err); // { cursor, pagesFetched } or undefined
  if (resume) scheduleLater({ ...query, cursor: resume.cursor });
  else throw err;
}

Polling for new large trades

since asks listLargeTrades for only the trades recorded after one you already hold (#18507). Pair it with If-None-Match and a poll that finds nothing new is a 304 with no body:

import { isApiNotModifiedResponse, paginate } from "@0xinsider/sdk";

let since: string | undefined; // first trade of the last answer that had trades
let etag: string | undefined;
for (;;) {
  const page = await client.listLargeTradesConditional(
    { since, limit: 100 },
    { headers: etag ? { "If-None-Match": etag } : {} },
  );
  if (!isApiNotModifiedResponse(page)) {
    etag = page.meta.etag;
    const trades = [...page.data];
    if (since && page.has_more && page.next_cursor) {
      // More than one page is new: read the rest under the same since.
      for await (const trade of paginate(client, "listLargeTrades", {
        query: { since, limit: 100, cursor: page.next_cursor },
      })) trades.push(trade);
    }
    for (const trade of trades) handle(trade); // a late trade can repeat: dedupe on id
    since = page.data[0]?.id ?? since;
  }
  await new Promise((resolve) => setTimeout(resolve, 5_000));
}

"After" is commit order, so a trade recorded late with an earlier traded_at still arrives, and one whose write is still open arrives on a later poll. Move since only to the first trade of the first page. A since that names no trade, or one more than 10,000 trades behind, is a 400 with error.param since: poll once without it and continue from its first trade. since works with sort: "recent" only.

Live stream (SSE)

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.

import { streamFeed } from "@0xinsider/sdk";

const cursor: { seq?: number } = {};
for await (const frame of streamFeed(client, {
  event: ["WhaleTradesInserted"],
  min_grade: "S",
  cursor, // cursor.seq tracks the last DELIVERED seq: transport progress, not completed work
  onResync: (m) => console.log("resync:", m.completeness?.reason),
})) {
  if (frame.kind === "event") {
    console.log(frame.seq, frame.envelope.type);
  }
}

When the key is revoked, the account lapses, or the credential store cannot confirm the key, the server ends the stream with an event: error frame. The SDK throws the typed error it carries (SubscriptionRequiredError, InvalidApiKeyError, AccountLockedError, DatabaseUnavailableError, ...) instead of delivering the frame: it never reaches onEvent and never moves the cursor or checkpoint. A frame with retry: false is permanent and streamFeedResilient does not reconnect on it; retry: true follows the normal reconnect path.

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.

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 larger than maxFrameBytes (default 1 MiB), whether or not its blank-line delimiter has arrived, throws frame_too_large before it is parsed or delivered 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 types are compatible as before.

Refusal bodies are bounded separately by maxErrorBodyBytes (default 65,536 bytes; a positive safe integer). A finite JSON refusal keeps the existing typed error and body metadata. A truncated body is never parsed as a complete refusal.

An absent, empty, invalid JSON, oversized, stalled, or unreadable body keeps the received HTTP status, Retry-After, and X-Request-ID. Its error's cause is a StreamRefusalBodyError with reason (absent, empty, invalid_json, too_large, deadline_exceeded, or read_failed), bytes, and maxBytes, without raw text. Permanent 4xx refusals still stop immediately, while 429 and 5xx keep the existing reconnect policy.

Caller cancellation preserves your abort reason in streamFeed and ends resilient consumers without throwing.

Every connection cancels its owned body, releases its reader lock, and disposes its timer and caller listener on exit. A rejected cancellation remains observable as the error's cause; a cancellation that does not settle within 2,000 ms reports that cleanup completion is unknown, then releases the lock. An injected fetch must respect the supplied signal to stop its own network work; the SDK bounds its wait and cancels a late response if that fetch eventually returns. Invalid transport or reconnect options fail before a request or onReconnect callback.

streamFeed() reads one connection and stops when it ends. For a long-lived consumer, use streamFeedResilient(), which reconnects for you:

import {
  streamFeedResilient,
  StreamProtocolError,
  StreamReconnectsExhaustedError,
  StreamRetryDeferredError,
} from "@0xinsider/sdk";

const controller = new AbortController();
try {
  for await (const frame of streamFeedResilient(client, {
    event: ["WhaleTradesInserted"],
    signal: controller.signal, // abort to stop; the iterator ends without throwing
    onReconnect: (attempt, lastSeq, cause) => console.warn("reconnecting", attempt, lastSeq, cause),
  })) {
    if (frame.kind === "resync") await refetchCurrentState();
    else handle(frame.envelope);
  }
} catch (err) {
  if (err instanceof StreamRetryDeferredError) scheduleAt(err.retryAt, () => resume(err.lastSeq));
  else if (err instanceof StreamReconnectsExhaustedError) resumeLater(err.lastSeq);
  else if (err instanceof StreamProtocolError) decideRecovery(err.reason, err.lastSeq, err.frameId);
  else throw err; // 400, 401, 402, 403, 423: fix the request or the key
}
  • 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.
  • 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.
  • 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.
  • Resync: resync markers are yielded unchanged, and the marker's id becomes the resume cursor.
  • Opening deadline: openTimeoutMs bounds response headers and finite refusal bodies on each connection. It defaults to 15,000 ms independently of the client's REST timeoutMs; pass a positive integer up to 2,147,483,647 ms, or null to disable it explicitly. A header stall throws RequestTimeoutError (operationId: "getStream"), which the resilient consumers treat as a transient connection failure.
  • Healthy lifetime: After a readable text/event-stream response is established, the opening deadline is cleared. Healthy and idle streams have no lifetime deadline; your signal remains connected until the reader is cancelled and its lock released.

A consumeStream(client, { onEvent, onResync }, options) callback variant is also exported.

Acknowledged checkpoints

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.

import { consumeStreamCheckpointed, StreamHandlerFailedError } from "@0xinsider/sdk";

const checkpoint = { seq: await loadCheckpoint() }; // undefined on a cold start
try {
  await consumeStreamCheckpointed(client, {
    onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
    onResync: async () => { await refetchCurrentState(); },   // awaited barrier
    onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
  }, {
    event: ["WhaleTradesInserted"],
    lastEventId: checkpoint.seq,
    checkpoint,
    signal: controller.signal,
    onHandlerError: (err, f) => console.warn("handler failed", f.seq, f.attempt, f.willRetry, f.cancelled, err),
  });
} catch (err) {
  if (err instanceof StreamHandlerFailedError) parkForRepair(err.seq, err.replayFrom, err.cause);
  else throw err; // the same stream errors as streamFeedResilient
}
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Webhook verification

For durable asynchronous consent, admit an attempt and read its status:

const admitted = await client.createWebhookVerificationAttempt(webhook.id, {
  verification_token: webhook.verification.token,
}, { idempotencyKey: "webhook-consent-1" });
const current = await client.getWebhookVerificationAttempt(webhook.id, admitted.data.id);

The 202 response acknowledges admission. Only state: "verified" activates the endpoint; queued and running await consent, while failed, cancelled, and expired are terminal. Read status_url or use the status method for current state, because a keyed replay returns the original admission response.

The receiver's signed webhook.verification challenge adds verification_attempt_id and x-0xinsider-verification-attempt, stable across at-least-once retries. Answer 2xx after verifying the HMAC. Transport/timeout/429/5xx failures make at most 4 claimed challenges, ending at the earlier token deadline or 15-minute horizon; configuration changes and account revocation prevent activation. The legacy synchronous verifyWebhook keeps its 200/422 behavior and cannot use automatic keyed retries.

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.

import { verifySignature, parseWebhookEvent } from "@0xinsider/sdk";

// In your webhook handler (raw body string, not parsed JSON):
const ok = verifySignature({
  secret: process.env.WEBHOOK_SIGNING_SECRET!,
  timestamp: req.headers["x-0xinsider-timestamp"],
  signature: req.headers["x-0xinsider-signature"],
  body: rawBody,
});
if (!ok) return res.status(400).end();

const event = parseWebhookEvent(rawBody);
switch (event.type) {
  case "large_trade_inserted_v2": // Pro and Max; one trade per delivery
    console.log(event.data.wallet, event.data.side, event.data.size_usd);
    break;
  case "wallet_grade_changed": // Pro and Max
    console.log(event.data.new_grade);
    break;
  case "suspicious_trade_flagged": // Pro and Max; `insider_radar_flag_raised` is
                                  // the same event under its old spelling
    console.log(event.data.suspicion_score);
    break;
  case "large_trades_inserted": // Pro and Max; a count, no trade.
                               // `whale_trades_inserted` is its old spelling
    console.log(event.data.count);
    break;
}

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 all 16 event types in WEBHOOK_EVENT_TYPES. The SDK compiles only while that list, the typed payloads and the OpenAPI WebhookEventType enum name the same events, so a case on any delivered type narrows event.data. Five events deliver only to API keys on an active Pro or Max subscription: large_trade_inserted_v2, large_trades_inserted, wallet_grade_changed, suspicious_trade_flagged, and sharp_money_flow_detected. Four events keep a deprecated second spelling: whale_trades_inserted, whale_trader_synced, insider_radar_flag_raised, and smart_money_flow_detected. Either spelling subscribes, and an endpoint receives its deliveries under the spelling it registered, so switch on the spelling you used.

SDK 0.25.0 types large_trade_inserted_v2 (LargeTradeInsertedV2Event), which the API has offered since September 23, 2026 and the list did not name. Its data is the trade: event_id, whale_alert_id, condition_id, trader_id, wallet, grade (null for an ungraded wallet), size_usd, shares, price, side, outcome_index, traded_at, and platform. size_usd, shares and price are decimal strings, and the envelope's created_at is the trade's fill time. large_trades_inserted carries a count and no trade. The same release adds LargeTradesInsertedEvent and TraderSyncedEvent for the two canonical spellings a case could not name before, and exports ClientResponseMeta, the type of meta on ApiEnvelope and ApiListEnvelope.

SDK 0.26.0 corrects the webhook payload types to match what the API sends. trade_id on suspicious_trade_flagged (and insider_radar_flag_raised) is a number: the API has always sent a JSON number, and earlier releases typed it as a string, so a comparison with a string id compiled and never matched. Fields the backend fills from a fixed set are now literal unions: track is "whale" | "fresh_conviction" | "sliced_position", side and dominant_side are "yes" | "no", direction is "upgrade" | "downgrade", and old_grade, new_grade and grade_floor are Grade. Code that assigned these fields to string keeps compiling; code that compared them with a value outside the set, or treated trade_id as a string, now fails to compile.

Staged webhook secret rotation

Use the staged operations when the receiver needs time to deploy a new secret:

  1. Call prepareWebhookSecret and store the one-time signing_secret; the current secret keeps working.
  2. Deploy the prepared secret and configure verifySignature to accept the comma-separated v1= candidates the sender emits during overlap.
  3. Call activateWebhookSecret; deliveries carry both the new and previous signatures for one hour.
  4. After the receiver rollout, call retireWebhookSecret to end the overlap early.

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.

Retries

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.

  • What is retried: a GET, a read-only POST (batchGetTraders, batchGetMarketFlow and its deprecated batchGetMarketIntel, which resolve their inputs and store nothing), a convergent write (cancelTraderExport, exported as CONVERGENT_WRITE_OPERATIONS: a repeat answers the state the first one reached and never repeats a transition), or one of the nine writes the API replays under Idempotency-Key (createWebhook, updateWebhook, deleteWebhook, rotateWebhookSecret, prepareWebhookSecret, activateWebhookSecret, retireWebhookSecret, redeliverWebhookDelivery, createWebhookVerificationAttempt, 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".
  • 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. To show or forward the wait as a validated whole number of seconds, use parseRetryAfterStrict: it accepts only the RFC 9110 forms (digits, or one of the three HTTP-date formats with a valid calendar date and weekday), rounds a date up to whole seconds, and returns null for anything else, a fractional delay included. 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.
  • What is never retried: 400, 401, 402, 403, 404, 409, 500, a write without an idempotency key, any other write outside the nine 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.
  • 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.

Webhook writes accept idempotencyKey. It makes the write safe to replay and eligible for retry:

await client.call("createWebhook", {
  body: {
    name: "Large trades",
    url: "https://example.com/hooks/0xinsider",
    event_types: ["whale_trades_inserted"],
  },
  idempotencyKey: crypto.randomUUID(), // reuse the SAME key if you retry by hand
});

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.

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.

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.

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 }).

Errors

Every readable non-2xx response throws an OxinsiderApiError. The SDK dispatches to a specific subclass by the documented error code:

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.

Code Subclass
bad_request BadRequestError
invalid_api_key InvalidApiKeyError
subscription_required SubscriptionRequiredError
forbidden ForbiddenError
not_found NotFoundError
account_locked AccountLockedError
rate_limited RateLimitedError (carries retryAfterSeconds)
rate_limit_unavailable RateLimitUnavailableError (carries retryAfterSeconds)
rate_limit_unavailable + reason: database_unavailable DatabaseUnavailableError (carries retryAfterSeconds; the database is temporarily unreachable, nothing is rate-limited)
internal_error InternalServerError
invalid_response (client-side) InvalidResponseError (a 2xx whose body does not match the contract, with the body on received)
import { RateLimitedError, OxinsiderApiError } from "@0xinsider/sdk";

try {
  await client.listWhaleTrades({ min_grade: "S" });
} catch (err) {
  // Thrown after the client's own retries (see Retries) are exhausted.
  if (err instanceof RateLimitedError) {
    await sleep((err.retryAfterSeconds ?? 1) * 1000);
  } else if (err instanceof OxinsiderApiError) {
    console.error(err.status, err.code, err.requestId);
  }
}

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.

Direct error constructors accept optional ApiErrorTransportMetadata after their existing arguments. errorFromResponse(status, body, retryAfterSeconds?, { requestId }?) preserves the same subclass selection and all existing calls.

Authentication

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 mode, where no operation needs one. Pro-only data (graded webhook events, parts of the stream) requires a key on an active Pro subscription.

Examples

How it is built

src/schema.ts is generated from the published OpenAPI document 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.

npm run generate     # fetch the published document; rewrite openapi.json, src/schema.ts, src/provenance.ts
npm run check        # the committed files match the committed snapshot, and the client table matches it
npm run check:live   # the same checks against the published document: is this release behind the API?
npm test             # build, then the unit tests (node:test, no network)
npm run test:sandbox # live calls against https://0xinsider.com/sandbox, no key

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 has the steps.

Provenance

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).

import { OPENAPI_SHA256 } from "@0xinsider/sdk";

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.

Other official tools

License

MIT

Immutable whale datasets

submitWhaleDataset({ from, to, condition_id?, min_size? }) submits a past window up to 31 days. Poll getWhaleDatasetStatus(jobId) according to next_action and poll_after_s. downloadWhaleDataset(jobId) streams decoded NDJSON and verifies the immutable content checksum when the body finishes. The signed URL is never sent your API credential. cancelWhaleDataset(jobId) uses the same owner-scoped lifecycle as trader exports.

Rows contain exact decimal strings and detected whale-alert facts, not all provider fills or current wallet grades. For continuation, send the manifest's cursor and normalized condition/size filters through client.call("getEventReplaySince", { query }). Omit null filters and keep the min_size decimal string unchanged. Deltas include already committed post-window arrivals, late trades, and intentional overlap: deduplicate on the wt_ ID (or payload.whale_alert_id). This is an insertion feed, not updates or deletions. See the OpenAPI manifest for retention, row/byte bounds and source coverage.

About

Official Node.js and TypeScript client for the 0xinsider Developer API: Polymarket sports and esports analytics, wallet grades, large trades, sharp-money flow

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages