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.
- Website and API keys: https://0xinsider.com/developers
- Authentication (API keys and OAuth 2.1): https://0xinsider.com/auth.md
- OpenAPI 3.1: https://0xinsider.com/api/v1/openapi.json
- Documentation: https://docs.0xinsider.com
- MCP server: https://api.0xinsider.com/api/v1/mcp
- Node.js 22+ (uses native
fetch,ReadableStream, andnode:crypto). Zero runtime dependencies. - Every API call has a 15 s SDK deadline (
DEFAULT_TIMEOUT_MS). SettimeoutMson the client or per call;nulldisables it. Only the SDK's deadline rejects withRequestTimeoutError(no status, body, or request id). An API call cancelled by the caller'ssignalrejects with its exact abort reason, including a caller-ownedTimeoutError.streamFeedhas no deadline; signed object downloads use their separatedownloadTimeoutMsoption. - 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. baseUrlis the server URL,https://api.0xinsider.comby default orhttps://0xinsider.com/sandbox; a path on it is kept and the/api/v1/...operation paths are appended after it. It must behttps:;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.
npm install @0xinsider/sdkFor 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 cloneThe 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.mjsSDK 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 severityEvery 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.
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.
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.
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.
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 unchangedcancelTraderExport 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.
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.
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;
}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.
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. ARetry-AfterpastmaxRetryAfterMs(default 60 s, the same ceiling as REST retries) is not waited out: the loop throwsStreamRetryDeferredErrorwithretryAt(the server's not-before instant) andlastSeq, spends no reconnect on it, and never shortens the wait. PassmaxRetryAfterMs: Infinityto hold the process for however long the server says; amonthly_quota_exceededrefusal 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
StreamProtocolErroron a malformed frame or a non-SSE response: resuming fromlastSeqwould replay the same frame, so the decision (resume later, skip pastframeId, or refetch state and attach live) is yours. AftermaxReconnects(default 10) consecutive connections that delivered nothing, it throwsStreamReconnectsExhaustedErrorwithlastSeq. - Resync:
resyncmarkers are yielded unchanged, and the marker'sidbecomes the resume cursor. - Opening deadline:
openTimeoutMsbounds response headers and finite refusal bodies on each connection. It defaults to 15,000 ms independently of the client's RESTtimeoutMs; pass a positive integer up to 2,147,483,647 ms, ornullto disable it explicitly. A header stall throwsRequestTimeoutError(operationId: "getStream"), which the resilient consumers treat as a transient connection failure. - Healthy lifetime: After a readable
text/event-streamresponse is established, the opening deadline is cleared. Healthy and idle streams have no lifetime deadline; yoursignalremains connected until the reader is cancelled and its lock released.
A consumeStream(client, { onEvent, onResync }, options) callback variant is also exported.
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:
onEventis awaited, thenonCheckpoint(seq, "event")(your durable write), and only then doescheckpoint.seqbecomeseq. 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 sameseqit throwsStreamHandlerFailedErrorwithseq,stage,attempts, the unadvancedcheckpointand thereplayFromto resume with later. - Resync is a barrier:
onResyncis 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-forgetStreamOptions.onResyncnotification 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
resyncmarker. - 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
seqor make the side effect idempotent; no client can promise exactly-once side effects. - Abort: aborting
signalends 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.onHandlerErrorstill receives the original rejection, withwillRetry: falseand optionalcancelled: 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.attemptremains the 1-based consecutive failure occurrence at that sequence. Without cancellation, bounded retries andStreamHandlerFailedErrorare unchanged. The diagnostic callback is synchronous and must not throw; it may abort the signal to stop the consumer.
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.
Use the staged operations when the receiver needs time to deploy a new secret:
- Call
prepareWebhookSecretand store the one-timesigning_secret; the current secret keeps working. - Deploy the prepared secret and configure
verifySignatureto accept the comma-separatedv1=candidates the sender emits during overlap. - Call
activateWebhookSecret; deliveries carry both the new and previous signatures for one hour. - After the receiver rollout, call
retireWebhookSecretto 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.
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,batchGetMarketFlowand its deprecatedbatchGetMarketIntel, which resolve their inputs and store nothing), a convergent write (cancelTraderExport, exported asCONVERGENT_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 underIdempotency-Key(createWebhook,updateWebhook,deleteWebhook,rotateWebhookSecret,prepareWebhookSecret,activateWebhookSecret,retireWebhookSecret,redeliverWebhookDelivery,createWebhookVerificationAttempt, exported asIDEMPOTENT_WRITE_OPERATIONSand 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.coderequest_timeout, thrown asServerTimeoutErrorwhen retries run out); it carriesRetry-Afteron a GET, and a keyed write replays safely.retryEligibility(operation)returns"read","keyed"or"never". - How long it waits: the response's
Retry-Afterplus up to 250 ms of jitter; delta-seconds and HTTP-date forms both parse (parseRetryAfteris 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, useparseRetryAfterStrict: 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 returnsnullfor anything else, a fractional delay included. Without the header, a jittered exponential backoff from 500 ms, capped at 8 s. ARetry-Afterlonger than 60 s (RETRY_AFTER_CEILING_MS) is not waited out; the error is thrown for you to schedule fromretryAfterSecondsorretryAt. 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 (
verifyWebhooksends a challenge to your URL each time;submitTraderExportstarts 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
timeoutMsdeadline (15 s by default). A retry that cannot finish before it is not started, so you get the real error, not a timeout. AnAbortSignalends 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 }).
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.
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/sandbox.mjs: no key. The sandbox leaderboard, then a documented429on demand.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.
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 keyA 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.
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.
- Python SDK:
pip install 0xinsider(0xinsider/0xinsider-python) - Go SDK:
go get github.com/0xinsider/0xinsider-go(0xinsider/0xinsider-go) - Rust SDK: crate
oxinsider(0xinsider/0xinsider-rust; until its first crates.io release,cargo add oxinsider --git https://github.com/0xinsider/0xinsider-rust) - CLI and MCP server:
npm install --global @0xinsider/mcporbrew install 0xinsider/tap/oxinsider - Remote MCP server:
https://api.0xinsider.com/api/v1/mcp - Agent Plugin and skills: 0xinsider/agent-plugin
- Machine-readable contract: https://0xinsider.com/llms-full.txt and https://0xinsider.com/agents.md
MIT
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.