Skip to main content
These are the API changelog entries from September 22, 2026, newest first. The newest changes are on the API changelog.
API responses expose processing duration in Server-Timing
Every public /api/v1 response now includes Server-Timing: api;dur=<milliseconds>. Browser clients can read this header through CORS.Before this change, responses did not expose their server processing duration.
  • The clock includes authentication, quota admission, handler work, and response construction. It excludes network transit and later streamed-body or file transfer.
  • A stream measures response opening. Export admission, status, and download redirects measure their own responses, separately from background generation and file transfer.
  • The OpenAPI x-latency-budget declares an engineering requirement strictly below 250 ms for each operation. This is not an achieved latency guarantee and does not change the request timeout.
Backward compatible. This adds a readable response header without changing response bodies.What to change: Nothing. Record Server-Timing alongside your own round-trip duration when you need to distinguish server processing from network and transfer time.
Official Rust client, and the TypeScript client's public source repository
Two official clients are now public repositories: 0xinsider/0xinsider-rust, the Rust client (crate oxinsider), and 0xinsider/0xinsider-node, the source npm will publish @0xinsider/sdk from. Before this change, the only official clients you could install were the Python and Go ones, and the TypeScript source sat in a private repository.
  • The Rust client is generated from the OpenAPI document: one async method per operation, typed errors, retries that honor Retry-After, cursor pagination, and a bounded reader for the event stream. Install it with cargo add oxinsider --git https://github.com/0xinsider/0xinsider-rust until the first crates.io release.
  • The TypeScript client builds from 0xinsider-node with npm ci && npm run build until the first npm release of @0xinsider/sdk.
Backward compatible. No API behavior changed, and no existing client changed.What to change: Nothing. To call the API from Rust, add the crate; to use the TypeScript client, build it from the public repository.
The Python SDK types every operation from the API schemas instead of Any
0xinsider 0.4.0 on PyPI generates a type for every documented request body, response envelope and schema, and puts them in the method signatures. Before, every operation took Any for its body and query arguments and returned Any, so the package shipped py.typed and told a type checker nothing.
  • An operation returns its own envelope: client.get_trader("swisstony") is a GetTraderResponse, client.list_positions(...) is a ListPositionsResponse, and GET /api/v1/trader/{address}/context.md is a str, not an envelope.
  • A query value takes what the document allows: min_grade= is Literal["S", "A", "B", "C", "D", "F"], format= on an export is Literal["json", "ndjson", "csv"]. A request body is a TypedDict, so a missing or misspelled key fails before the call spends a request.
  • client.with_response.<method>() returns ApiResponse[<the same body>], and every shape is importable from oxinsider.types.
  • A field the API can omit is an optional key. trader["data"]["grade"] is a type error and trader["data"].get("grade") is not, because an ungraded wallet carries no grade; pnl.realized behaves the same way when no verified native realized-P&L source is available. A nullable field is | None. An omitted key and a null value stay different facts, and neither is a zero.
  • An enum the API returns is typed Literal[...] | str, so a value added to the API after a release is not a type error in a client that has not upgraded. An enum you send is a strict Literal. A const is exact, so GET /api/v1/pick-of-the-day/ledger entries narrow on entry["state"] to the sealed, opened or uncommitted shape.
  • A conditional read is overloaded: client.get_trader(address) returns the response, and client.get_trader(address, if_none_match=etag) returns GetTraderResponse | NotModifiedResponse, which is when a 304 can happen.
Backward compatible. No method, argument name, default or returned object changed. Nothing is validated, converted or copied at runtime: the methods return the decoded JSON exactly as before, and the annotations are deferred, so none is evaluated at import. Python 3.9 is still the floor.What to change: Nothing to run the same code. A type checker may now report reads your code was already doing: a field the API can omit read with [] instead of .get() is the common one, and it is the case where the old code would raise on a real response. To read a field this release does not know yet, call client.request(...), which is typed Any.
TypeScript SDK covers Markdown, JSON-RPC, registration, and export downloads
@0xinsider/sdk 0.5.0 calls the four operations it had no way to reach. Each answers something other than the JSON { object, data, meta } envelope, and the client assumed every operation answered that envelope, so a Markdown body was parsed as JSON and then rejected as an invalid 200, and the two operations without a documented 200 were absent from the client altogether.
  • GET /api/v1/trader/{address}/context.md and GET /api/v1/market/{condition_id}/context.md are client.getTraderContextMarkdown(address) and client.getMarketContextMarkdown(conditionId), returning the text/markdown document as a string. client.text(operationId, options) is the general form.
  • POST /api/v1/mcp is client.mcp(request, options). It returns { status, response, sessionId? }: status 200 with the JSON-RPC body, or status 202 with response: null for a notification you sent without an id. A JSON-RPC error member is returned, not thrown; only a non-2xx HTTP status throws. sessionId and protocolVersion options send Mcp-Session-Id and MCP-Protocol-Version.
  • POST /api/v1/agents/register is client.registerAgent(). It needs no credential and answers 201 with the oxi_sk_test_ sandbox key.
  • GET /api/v1/trader/{address}/export/download is client.getTraderExportDownloadUrl(address, jobId), which resolves the 302 and returns the presigned URL with its expiresAt, and client.downloadTraderExport(address, jobId), which then fetches the object. That second request carries no headers at all, so your API key never reaches the file host; the body is streamed, not buffered, and the object fetch has no deadline unless you pass downloadTimeoutMs.
Every envelope’s meta also gains a client-set status holding the HTTP status: 201 on registerAgent, and 202 on a POST /api/v1/trader/{address}/export that queued a new job against 200 for one that already existed. The two bodies are identical, so this is the only way to tell them apart.call() on an operation that does not answer the envelope now names the method that does, instead of failing on the body, and the package exports REDIRECT_OPERATIONS and UNSUPPORTED_OPERATIONS so every published operation is accounted for.Backward compatible. No existing method, argument or return type changed, and no API behavior changed. meta.status is a new optional field.What to change: Nothing. If you were calling the Markdown routes with your own fetch because the SDK could not, the methods above replace that. The Python (0xinsider on PyPI) and Go (github.com/0xinsider/0xinsider-go) clients already cover these operations.
The sandbox pages through a coherent world and refuses what the live API refuses
The sandbox at https://0xinsider.com/sandbox answers every operation with a body the live API could have produced, and checks your request against the same OpenAPI document it serves.
  • Every cursor-paginated list serves one fixed collection of 7 rows. limit and cursor cut the page, has_more and next_cursor agree with it, total is the collection size, and the last page reads has_more: false and next_cursor: null. Before, limit and cursor changed nothing and a list answered has_more: false beside next_cursor: "sandbox".
  • Field values are legal for their field. grade is a grade letter, platform is polymarket, an address is 40 hex characters, and a trader’s id is trd_ plus that address. Before, an unconstrained string read sandbox and an unconstrained number read 1.
  • POST /api/v1/traders/batch and POST /api/v1/markets/intel/batch answer one item per identity you post, in the order you posted them, with meta.total_items, meta.successful_items and meta.failed_items matching the items and each item carrying data or error, never both. Before, both echoed the two identities of the documented example whatever you posted.
  • Documented query parameters and JSON request bodies are validated. A value outside its schema is 400 with error.reason invalid_query, a body that is missing, unparseable or does not fit is 400 invalid_body with error.param, a body without Content-Type: application/json is 415 unsupported_media_type, and a cursor the sandbox did not issue is 400 with error.reason cursor_expired. Unknown query names are ignored and reported in X-Query-Ignored, and X-Query-Validation: strict returns 400 unknown_query_parameter, as on the live API.
  • Every sandbox response carries X-Request-Id: req_sandbox, matching meta.request_id in the body.
Not backward compatible for a malformed request. A request the sandbox used to answer 200 now returns the error the live API returns. Response shapes and field names are unchanged, and the live API is unchanged by this release.What to change: Nothing if your client already sends what the document describes. If a sandbox call starts returning 400 or 415, read error.param and error.reason: the live API would have refused the same request. If you hard-coded "sandbox" or 1 as an expected sandbox value, read the field instead.
The OpenAPI document now matches what the server does on 6 points
/api/v1/openapi.json now describes what the server already does in 6 places. Nothing on the wire changed.Backward compatible. No request or response changed.What to change: Nothing. If you generate a client from the document, regenerate it to pick up the limit default and the corrected error lists.
V1 reports ignored query parameters and supports opt-in strict validation
V1 routes now add two response headers: X-Query-Ignored names the query parameters the operation does not support, and X-Effective-Query lists the normalized names and values it applied. By default, an unknown query parameter is still ignored.Before this change, an unknown query parameter was ignored with no machine-readable signal.
  • Send the new X-Query-Validation: strict request header, or set strictQuery: true in the TypeScript SDK, to reject an unsupported name before the handler runs.
  • A rejected request answers 400 bad_request with error.reason unknown_query_parameter, and error.param names the query key.
Backward compatible. Existing callers see only the new headers.What to change: Nothing. If you opt into strict mode, handle unknown_query_parameter and use each operation’s documented query names.
Large trades: market_volume_share never exceeds 1
market_volume_share on GET /api/v1/whale-trades, GET /api/v1/whale-trades/history, GET /api/v1/whale-trades/{id}, and the replay’s expand=trade is a trade’s size_usd as a share of its market’s volume. It now always falls between 0 and 1, and the OpenAPI schema states maximum: 1.Before this change, the share could read far above 1. The denominator was the market’s volume when the trade was stored: a cached copy of the provider’s figure, often older than the trade, so it could not include the trade. Of the 3,086 trades stored since the field shipped on September 16, 412 read above 1, and the highest read 18,358.08.
  • The denominator is now a market volume figure recorded at or after the trade, used only when it is at least as large as the trade it must include.
  • When no such figure is available, the field is absent, as it already was for a market with no volume figure. It is never 0, never 1 as a stand-in, and never capped.
  • Measured over the same window on September 22: 3,064 of 3,086 trades carry a share (99.3%), none above 1. The highest is 0.876506 and the median 0.020408.
  • A market’s volume keeps growing, so the field now reports a trade’s size against the market’s volume today. The same trade reports a smaller share as the market trades on, and a trade only minutes old can be absent until a volume figure recorded after it arrives.
Breaking for a client that stored earlier values. The field keeps its name, its type (number, six decimal places), and its optionality, and no other field moved.What to change: Discard stored or cached values, because the same trade can now report a different share, or none. A client that expected values above 1 can rely on the 0 to 1 range.
The OpenAPI document separates fields a response leaves out from fields it sends as null
The OpenAPI document now says, for every response property, whether the server leaves the key out or sends it as null. @0xinsider/sdk ships regenerated types to match. Nothing on the wire changed.Before this change, the document had it wrong in three ways:
  • 222 properties were marked nullable: true, but no response ever sends them as null: the key is absent instead. They are now typed as their plain type and kept out of required. Among them: Trader.username, Trader.grade, Trader.score, Trader.rank, every leg of Trader.pnl and Trader.stats, Position.avg_price, Position.cash_pnl, Position.realized_pnl, Position.trader.grade, Position.market.slug, WhaleTrade.trader.username, WhaleTrade.market.category, every LeaderboardEntry field except id, address, and platform, TrendingWallet.username, CounterpartyParticipant.grade, error.param, error.doc_url, and the next_cursor and total pair on every list response.
  • 159 properties were missing from required, although every response sends them, as null when there is no value. They keep nullable: true and are now required. Among them: WhaleTrade.outcome, WhaleTrade.token_id, Position.token_id, most of ExploreMarket and its score_components, SportsEdgeSignal.title, SportsEdgeSignal.token_id, MarketHolder.name, ExportSourceRange.*, and TraderPnl.freshness_at.
  • 17 properties the server omits were listed in required. GET /api/v1/pick-of-the-day carries 14 of them, absent when the response is a teaser rather than a full pick: pick_date, thesis, matchup, position, outcome, platform, release_at, is_locked, side_summary, smart_wallet_count, disclaimer, category, clv_status, and pick_outcome_label.
In the SDK types, an omitted field now reads grade?: string instead of grade?: string | null, and an always-sent one reads token_id: string | null instead of token_id?: string | null.Backward compatible. No response body is different.What to change: Regenerate any client you generate from the document. Read an omitted field with ?. or a default, and read an always-sent one as a value that can be null. A client that already treats absent and null alike needs no change.
Every whale trade carries the grade its wallet held when the trade happened
GET /api/v1/whale-trades, GET /api/v1/whale-trades/history, GET /api/v1/whale-trades/{id}, and the trade object from expand=trade on GET /api/v1/events/feed/since add two fields to trader: grade_at_trade and grade_at_trade_status.Before this change, every row carried only the wallet’s grade today, however old the trade.
  • grade_at_trade is S to F, or null: the grade the wallet held at traded_at, read from grade history recorded since September 19, 2026 23:00 UTC.
  • grade_at_trade_status is graded, ungraded, or unknown. Every trade before that history starts reads unknown, with grade_at_trade null, never today’s grade projected backward. unknown never means ungraded.
  • Unchanged: trader.grade, signal_score, and the min_grade filter still read the wallet as it is today, on every row however old.
  • The history page now lists the archive’s size floor by period.
A backtest that selects on trader.grade, signal_score, or min_grade is selecting on what the wallet did after the trade. Select on trader.grade_at_trade and recorded_signal_score instead.Backward compatible.What to change: Nothing. @0xinsider/sdk types the new fields from the regenerated schema.
Python and Go SDKs 0.3.0: 67 operations, visible pagination failures, bounded Go requests
0xinsider 0.3.0 on PyPI (import oxinsider) and github.com/0xinsider/0xinsider-go v0.3.0 release everything both clients had merged since 0.2.0. Both implement 67 operations, up from 64, and both are generated from OpenAPI document SHA-256 ece72cd4d303353608f144489537c5760f359bcecc9aa9b0c742f3f629c84318.Before this change, the newest work in each client was only on its default branch. A user who installed 0.2.0 got 64 operations, a Python paginate that could not tell a malformed page from an exhausted one, no way to read a successful response’s headers, and a Go request that waited forever on a stalled connection.Python 0.3.0:
  • client.paginate(...) checks each page before it yields it and before it spends another request. A response that is not a cursor-paginated list envelope, or whose data is not a list, raises the new oxinsider.PaginationError with reason invalid_envelope or invalid_data.
  • has_more: true with no usable next_cursor raises missing_cursor. A next_cursor the walk already requested raises repeated_cursor, before the duplicate request goes out.
  • progress= and oxinsider.pagination_checkpoint(error) report where a walk stopped, so a long walk resumes instead of restarting. progress.stopped_by is set only when the walk ended on its own terms.
  • client.with_response.<method>(...) returns an ApiResponse: the same body under .data, plus .etag, .request_id, .rate_limit, .monthly_quota, .batch_rate_limit and .request_cost. A header the API did not send reads None, never 0.
Go v0.3.0:
  • Every ordinary request from oxinsider.New is bounded: DefaultRequestTimeout of 30 seconds end to end, DefaultDownloadTimeout of 5 minutes for the export download, over a transport that bounds the dial, the TLS handshake and the wait for response headers.
  • A deadline the client applied returns *RequestTimeoutError, which carries Deadline, Method and URL and unwraps to context.DeadlineExceeded. A context deadline you pass still wins, and WithRequestTimeout(d) changes the default.
  • GET /api/v1/stream is excluded and bounded by OpenStream’s start and idle timeouts, so a healthy stream is not cut by the per-request deadline.
  • oxinsider.Version is "0.3.0" and rides in User-Agent.
Backward compatible, with two deliberate behavior changes. Python’s paginate raises PaginationError where 0.2.0 returned silently on a malformed page, which is the point of the change. A Go request that used to wait forever now returns *RequestTimeoutError after 30 seconds.What to change: Upgrade. In Python, catch oxinsider.PaginationError if you page over an operation that is not a cursor-paginated list. In Go, pass oxinsider.WithRequestTimeout(0) or your own context if you deliberately relied on an unbounded request.
TypeScript SDK 0.4.0: a stream checkpoint that moves only after your code finishes
@0xinsider/sdk 0.4.0 adds consumeStreamCheckpointed(client, handlers, options) for GET /api/v1/stream. It keeps two positions instead of one: options.cursor is the last frame received, and the new options.checkpoint (a StreamCheckpoint) is the last frame your code finished with.Before this change, the received cursor was the only position a consumer had. streamFeed() writes it before a frame reaches your code, so when a handler threw, a reconnect from that cursor resumed after the event that failed.
  • options.checkpoint advances only after your onEvent handler and your own onCheckpoint write have both resolved. options.cursor keeps its meaning.
  • If onEvent or onCheckpoint rejects, the checkpoint stays before that event. The consumer closes the connection, waits a jittered 1 to 30 seconds, and reconnects from the checkpoint, so the server replays the event while it still holds it.
  • onHandlerError(error, failure) reports each failure with seq, stage, attempt, willRetry, replayFrom, and the unadvanced checkpoint.
  • After maxHandlerRetries consecutive failures at one seq (3 by default), it throws the new StreamHandlerFailedError, which carries the same fields.
  • onResync is awaited. The checkpoint moves past a resync marker only once your refresh resolves, so an interrupted refresh is retried rather than recorded as done.
  • Nothing is read from the connection while a handler runs. A slow handler applies backpressure to the socket, and no queue is kept for you.
Delivery is at least once. A replay re-delivers every frame you have not acknowledged, and a handler that succeeded but whose checkpoint write failed sees its event again. Deduplicate on seq, or make the side effect idempotent.Backward compatible. streamFeed(), streamFeedResilient(), and consumeStream() behave exactly as before.What to change: Nothing, unless you need the guarantee. If you store cursor.seq as “work completed”, move to consumeStreamCheckpointed and store the checkpoint from onCheckpoint.
Python SDK 0.2.0: with_response returns a call's status, headers, ETag, and budgets
0xinsider 0.2.0 on PyPI (import oxinsider) adds client.with_response.<method>(...). It calls the same operation and returns an ApiResponse instead of the body alone.Before this change, a successful call returned only the decoded body and dropped everything else the response carried. The README advertised if_none_match=, but the ETag was reachable only on a 304, never on the 200 that produced the data, so there was nothing to revalidate with.The request id, the rate-limit window, the monthly quota, the batch item budget, the request cost, and Retry-After were out of reach on a success too.
  • ApiResponse.data is exactly what the plain method returns, with status and headers beside it.
  • Its typed accessors: etag and not_modified for conditional reads; request_id (X-Request-Id, the same id as meta.request_id); retry_after; rate_limit (X-RateLimit-* with RateLimit-Reset); monthly_quota (X-Monthly-Quota-*); batch_rate_limit and request_cost (X-Batch-RateLimit-*, X-Request-Cost); server_timing_ms (Server-Timing: api;dur=); usage_accounting; deprecation; sandbox; content_type; and header(name).
  • Each of the three budgets is a Budget with limit, remaining, reset_at (a Unix epoch second), and reset_after (seconds from now, which only the per-minute window publishes). It is None when the response described no such window.
  • A header the API did not send reads None, never 0. A budget the response did not mention is not an exhausted budget.
  • OxinsiderApiError.response carries a failed response the same way, so you can handle a 429 from the window the API described rather than from a guess.
  • client.request(method, path, raw=True) does the same for a hand-built call. Rate limits documents the headers themselves.
Backward compatible. The server did not change. Every existing method returns what it returned before, including the text of a Markdown route, None for a 204 or an empty body, and {"object": "not_modified", ...} for a 304. The SDK sends the same requests, and an error status raises the same typed error with the same code, reason, retry_after, request_id, and body.What to change: Nothing. raw=True with stream=True now raises ValueError instead of being silently ignored, because a stream already hands back the open httpx.Response with its own headers. A redirect-only operation returns the same Download under with_response, because that object already carries the file’s headers.
Go SDK: every request has a timeout, and the stream has start and idle timeouts
github.com/0xinsider/0xinsider-go now puts a time limit on every request a client from oxinsider.New makes. Before this change, a stalled connection could wait forever, because Go treats a zero http.Client.Timeout as no timeout and the quickstart uses context.Background().
  • DefaultRequestTimeout (30 seconds) covers each request end to end, from the dial to the last byte of the body, across any redirect.
  • DefaultDownloadTimeout (5 minutes) applies instead to GET /api/v1/trader/{address}/export/download, whose body is a file the SDK cannot size in advance.
  • The transport adds the connection limits net/http leaves unset: DefaultConnectTimeout (10 seconds) to dial, DefaultTLSHandshakeTimeout (10 seconds), and DefaultResponseHeaderTimeout (15 seconds) from the end of the request to the first response header.
  • A context that already has a deadline keeps it. A shorter deadline of yours wins, and a longer one is honored.
  • oxinsider.WithRequestTimeout(d) replaces the default for every request except the stream. WithRequestTimeout(0) removes it and keeps the connection limits.
  • A timeout the client applied returns *oxinsider.RequestTimeoutError, which names the limit, the method, and the path, unwraps to context.DeadlineExceeded, and reports Timeout() true. Your own deadline or cancellation still returns context.DeadlineExceeded or context.Canceled, and a connect, handshake, or header stall returns the transport’s own timeout error.
  • http.Client.Timeout stays unset, because it would also cut off reading the event stream.
GET /api/v1/stream has no total time limit. Instead, WithStreamStartTimeout (10 seconds) limits the wait for the response headers, and WithStreamIdleTimeout (60 seconds) ends a connection that has sent nothing, not even a keep-alive. The stream sends a keep-alive every 5 seconds, so 60 seconds of silence means 12 missed keep-alives.
  • Either limit ends the call with *oxinsider.StreamTimeoutError. Its Phase is start or idle, and its LastSeq is the value to resume from with Last-Event-ID.
  • Pass 0 to remove either limit.
  • The idle clock runs only while the reader waits for bytes, so taking your time between frames does not trigger it.
Backward compatible on the wire: the server did not change, and no method was removed or renamed.What to change: Nothing, if you already pass a context with a deadline. Otherwise:
  • If you relied on an unbounded context.Background() call, for a slow export download or a long read, pass your own deadline or use WithRequestTimeout.
  • If you read the stream through GetStream with RawStreamContext, you own the raw body, and only the connection limits apply.
  • Code that branches on a timeout should match *RequestTimeoutError or *StreamTimeoutError, or keep using errors.Is(err, context.DeadlineExceeded).
Python SDK 0.2.0: paginate stops on a broken page and can resume from a checkpoint
0xinsider 0.2.0 on PyPI (import oxinsider) makes client.paginate(...) check every page before it yields the page or requests the next one. A page that breaks the cursor protocol now raises the new oxinsider.PaginationError instead of looping or ending silently.Before this change, paginate yielded data only when it happened to be a list, returned silently when the page fields were missing, and followed next_cursor without checking progress. A repeated cursor with has_more: true looped and kept spending requests, and a malformed page looked the same as the end of the list.PaginationError.reason says what was wrong:
  • invalid_envelope: the response is not a cursor-paginated list.
  • invalid_data: data is not a list.
  • missing_cursor: has_more is true, but there is no usable next_cursor.
  • repeated_cursor: next_cursor is a cursor paginate already requested. It is raised before the duplicate request goes out.
These still end pagination normally: has_more: false, whatever next_cursor says; a valid empty page; and a 304 from if_none_match, which ends it with stopped_by not_modified. The SDK remembers the last 1,024 cursors it requested (oxinsider.CURSOR_HISTORY_LIMIT), so memory use stays flat however many pages you read, and a cycle longer than that is not detected.New options on the call:
  • max_pages and max_items limit how far paginate goes. Each is a positive integer or None, checked before the first request, so 0, a negative number, or a float raises ValueError without spending a request.
  • progress=oxinsider.PaginationProgress() reports pages_fetched, items_yielded, cursor, next_cursor, and stopped_by (exhausted, max_pages, max_items, not_modified, or None when pagination was interrupted). Reaching a limit is reported as that limit, not as the end of the list.
  • oxinsider.pagination_checkpoint(error) returns the same snapshot from a failure, and the original error is re-raised unchanged. An except RateLimitedError keeps working, and after a rate limit, an expired cursor, or a dropped connection you can resume from checkpoint.cursor instead of page 1.
  • client.paginate_pages(...) yields whole envelopes (total, has_more, meta) instead of items.
The filters are sent unchanged on every page. Only the cursor changes.Backward compatible for a correct server, and the server did not change.What to change: Nothing for normal pagination, including a call that passes cursor= to resume. If you called paginate on an operation that is not a cursor-paginated list, you now get a PaginationError naming the envelope instead of an empty iterator. For example, GET /api/v1/whale-trades/{id}/counterparties/executions and its makers route take a cursor but answer object: "counterparty_analysis" with an object data, so call their methods directly.
The Pick of the Day ledger is public: no API key, no subscription
GET /api/v1/pick-of-the-day/ledger no longer requires authentication. It now answers any caller, like GET /api/v1 and GET /api/v1/platforms, and its operation in the OpenAPI document has an empty security.Before this change, it needed a Pro API key. The key protected nothing, because the ledger exists to be republished. It also made the public record depend on one key: on September 22 a routine key rotation revoked the mirror’s key, and github.com/0xinsider/picks stopped recording commitments for 3 hours.
  • The response is byte for byte the same. A live pick is still sealed, with no nonce, payload, side, or price. A settled pick is still opened, with the nonce and the canonical payload.
  • The key never hid a live pick’s side. The server reads the nonce and the payload only for a pick whose outcome is no longer pending, and checks that on every row before serving it.
  • A request that still sends Authorization is served, not refused, even with a revoked or expired key. A mirror with a dead key starts working again without any change.
  • Requests without a key count against the shared per-IP limit for public routes, not against a key’s quota.
Backward compatible. Nothing a client sends has to change.What to change: Nothing. @0xinsider/sdk 0.3.1 calls getPickOfTheDayLedger() with no key configured.
GET /api/v1/positions filters by wallet, so a portfolio no longer pages the whole board
GET /api/v1/positions takes a new query parameter, wallet, that returns the positions of one wallet or a book of up to 25. The response is the same list of positions, in the same order and with the same cursor, limited to those wallets, and a page costs the same however large the board is.Before this change, there was no wallet filter. The portfolio recipe paged the whole board and kept the rows it recognized, which also missed every wallet whose positions were under the board’s default min_size of $100.
  • Repeat the parameter up to 25 times (wallet=a&wallet=b), use the wallet[] form, or send comma-separated values in one parameter.
  • Each value is a wallet address, a known username, or a trd_ trader id, resolved the same way as the path of GET /api/v1/trader/{address}.
  • The order is unchanged: current_value_usd descending, then wallet, market, and outcome.
  • With wallet present, min_size defaults to 0 instead of 100, so you get all of the wallet’s open positions, not only those worth $100 or more. Send min_size to keep a floor. Without wallet, nothing changes.
  • An address 0xinsider does not track still returns any positions stored for it, or an empty list. A username or trader id that matches nothing answers 404 with error.param wallet. More than 25 values answers 400.
  • The route still does not return closed positions, positions 0xinsider has not valued yet, or positions in markets with more than two outcomes.
Backward compatible.What to change: Nothing, unless you want the filter. @0xinsider/sdk listPositions accepts wallet: string[] and "wallet[]": string[]. A portfolio tracker that scanned the board should call the route once per wallet, or once per book of 25, and drop its own filter; the recipe now does.
Event replay takes four filters, and expand=trade adds the full trade to each event
GET /api/v1/events/feed/since takes four new query parameters that narrow the stream, and a new expand=trade that puts the full trade on every event. The order events come back in, and how far back you can read, are unchanged.Before this change, the route had no filters, and reading the trade behind an event took one call to GET /api/v1/whale-trades/{id} per row.
  • trader: a wallet address, a trd_ id, or a username. A trader 0xinsider does not know matches nothing.
  • condition_id: the raw provider id, or its mkt_ id.
  • min_grade: S to F, read from the wallet’s newest ranking.
  • min_size: the trade size in USD.
The filters are bound to the cursor, so you cannot add or change one once you have started reading.
  • next_cursor from a filtered page carries the filters that produced it.
  • Sending that cursor with a different set of filters, including none, answers 400 bad_request with error.reason cursor_expired and error.param cursor.
  • Every cursor issued before today is bound to no filters, so adding a filter after you have started is refused the same way.
  • meta.replay.filters shows the filters in effect on a filtered page.
expand=trade adds a trade object to every event: the same object GET /api/v1/whale-trades/{id} returns for that row. 0xinsider reads them with one query per page, so a page of 100 events costs 1 request instead of 101. The parameter is repeatable and trade is its only value.
  • payload still holds the facts as they were when the event happened.
  • trade.trader.grade, trade.trader.username, trade.signal_score, trade.recorded_signal_score, trade.suspicion_*, and trade.market are read when you make the request, so they can differ from the event.
  • trade is null when the row’s trader or market is not in 0xinsider’s database yet. That is the same row the detail route answers 404 for.
  • meta.replay.expand lists the expansions applied. expand is not bound to the cursor.
  • Measured locally, a 100-event page with expand=trade was 131 KB in 148 ms. The plain page plus 100 detail calls was 159 KB in 59.5 s.
One behavior change on every read: after a page that is not full, next_cursor and meta.replay.to_cursor now move past the last event on the page, up to the newest position where every write has finished. An empty page therefore still moves forward, so a filter that matches little stops re-reading the same range on every poll.meta.replay.to_sequence is still the last event’s id, and it can sit behind the position the cursor encodes.Backward compatible. The four filters and expand are new parameters, and the response fields you already read are unchanged.What to change: Nothing, if you store next_cursor opaquely. To start using a filter, request the first page again without a cursor.
TypeScript SDK 0.3.0: every method is typed from its own API operation
@0xinsider/sdk 0.3.0 types every convenience method from that operation’s own entry in the OpenAPI document. That covers the path parameters it takes, the query keys and values it documents, the body it requires, and the envelope it answers with, including data and meta. call, list, paginate, paginatePages, and collect get the same types when you pass a literal operation id.Before this change, exploreMarkets rows were typed as market intel rows and listInsiderRadar rows as webhook rows, and listInsiderRadar accepted a min_grade that the route ignored.
  • exploreMarkets rows are now ExploreEntry, and listInsiderRadar rows are now RadarFlag.
  • listInsiderRadar takes the route’s own query, min_suspicion and severity.
  • A batch response’s meta is BatchResponseMeta (request_cost and rate_limit), and the event replay’s is EventReplayMeta. meta is always present, as the API contract says.
  • Every method takes signal, timeoutMs, maxRetries, and headers after its own parameters, so a deadline, a cancellation, or an If-None-Match looks the same on a list read as on a single read.
  • Webhook ids are number, which is what the document declares.
  • Five generated maps are exported for your own code: OperationPath, OperationQuery, OperationBody, OperationData, and OperationResponse.
A query key the route does not document, a missing path parameter, or a wrong body is now a compile error instead of a request the server ignores or refuses. Nothing on the wire changes.What to change: Drop the type argument from any convenience call, so client.getTrader<MyTrader>(...) becomes client.getTrader(...), and drop it from paginate<T> when the operation id is a literal. Read meta.field instead of meta?.field. call<T>, list<T>, paginate<T>, paginatePages<T>, and collect<T> keep the loose form, for an operation you pick at runtime or a shape you assert yourself.
Python and Go SDKs 0.2.0 cover all 64 API operations
0xinsider 0.2.0 on PyPI and github.com/0xinsider/0xinsider-go 0.2.0 are regenerated from the current OpenAPI document, which has 64 operations. Seven routes that had no method in the published clients now have one.Before this change, you reached those seven through client.request() in Python, or your own http.Request in Go.In Python, GET /api/v1/mcp no longer has a method. The document lists that route only to refuse it (405, because there is no server-to-client stream), so the method could only raise. The route stays in oxinsider.OPERATIONS.Each release now names the document it was generated from, so you can tell whether it is behind the API.
  • Python exports oxinsider.OPENAPI_SHA256 (the SHA-256 of the document bytes), oxinsider.OPENAPI_VERSION, oxinsider.OPERATION_COUNT, and oxinsider.APP_COMMIT (the 0xinsider/0xinsider commit that last changed the document, or None).
  • Go exports oxinsider.OpenAPISHA256, OpenAPIVersion, OperationCount, and AppCommit.
  • Compare the hash with shasum -a 256 of the live document.
  • Both 0.2.0 releases were generated from SHA-256 8d648c728b525eea69814f0bb0056aa05b447525a32ec868fef7711f903aa4b5, at app commit a138487e97eb1681d38a624d2bc4f12175584673.
Backward compatible. No method was removed or renamed, and every existing call sends the request it sent before. The one exception is Python’s two context.md methods, which now send Accept: text/markdown, and those routes serve Markdown either way.What to change: Nothing. The server did not change, and if you called one of the seven routes by hand, you can switch to its method.
The stream closes with an error frame when its key stops working
GET /api/v1/stream now re-checks its API key every 30 seconds while the connection is open. If the key is revoked, expires, or is rotated, or the account is deleted, locked, or no longer subscribed, the stream sends one final event: error frame within 40 seconds and closes.Before this change, the key was checked only when the stream opened, and an open stream kept delivering until the client disconnected.
  • The frame’s id is the last seq the stream sent.
  • Its body is { "type": "error", "error": { "code", "message", "doc_url", "reason", "retry_at" }, "retry": <boolean> }.
  • error is the error a reconnect with the same key would get: invalid_api_key (401), subscription_required (402), forbidden (403, the account was deleted), account_locked (423), or insufficient_scope (403, an OAuth token without the read scope). All of these carry retry: false.
  • If 0xinsider cannot confirm the key for 90 seconds, the stream ends with database_unavailable and retry: true. Reconnect after error.retry_at with Last-Event-ID set to the frame’s id, and the stream resumes where it stopped.
Backward compatible. Data frames, resync markers, keep-alives, and the responses you get when you connect are unchanged.What to change: If you read the stream without the SDK, handle event: error and stop reconnecting when retry is false, because every reconnect will be refused the same way. A browser EventSource also fires its own error event when the connection fails, so check event.data before you parse it.@0xinsider/sdk needs no change: streamFeed() yields the frame with type error, and streamFeedResilient() already stops on a 401, 402, 403, or 423.
Pick of the Day is priced at a flat $1,000 stake instead of $100
GET /api/v1/pick-of-the-day and GET /api/v1/pick-of-the-day/archive now value every pick at a flat stake of $1,000. That applies to every pick ever published.Before this change, the record was stated at a $100 stake.
  • stake_usd is new on every priced pick and on hit_rate. Its value is 1000.
  • return_usd is new on every priced pick. It is the gross return of that stake: a win pays stake_usd / backed_price, a loss pays 0, and a void refunds the stake.
  • payout_display and profit_display are now formatted from return_usd.
  • hit_rate.net_profit_usd, staked_usd, series[].net_profit_usd, and the per-day net_profit_usd on the stats route are ten times what the same record read at $100.
  • return_per_100 keeps its name and its meaning, 100 / backed_price. unit_score, roi_pct, pct, and every CLV field do not depend on the stake and are unchanged.
The public ledger at github.com/0xinsider/picks recomputes its index.json record on the same basis. It renames record.profit_per_100 to record.profit_usd and adds a new record.stake_usd.Backward compatible. Nothing was removed from the wire: the two fields are new, and the fields whose values moved keep their names and types.What to change: Nothing, if you read return_per_100 and scale it to your own stake, or read roi_pct and units. If you displayed payout_display, profit_display, net_profit_usd, or staked_usd as a figure per $100, read stake_usd rather than assuming the stake. @0xinsider/sdk 0.2.6 types the new fields.
GET /api/v1/me reports whether paid data access is active or lapsed
GET /api/v1/me adds two things: credential_status, which says the credential itself is valid, and an entitlement object holding paid_data_access and recovery_action. paid_data_access reads active or lapsed, and recovery_action is renew_subscription when renewing the subscription is what restores access.Before this change, the response stopped at account and credential identity, and a valid credential with no active Pro subscription got 402.GET /api/v1/usage is available to that credential too. It still reports the credential’s rate-limit, daily-usage, and monthly-quota state.Backward compatible. The existing fields stay, and you can ignore the new ones. Paid data routes still answer 402 subscription_required while access is lapsed, and revoked, expired, unknown, deleted, locked, and insufficient-scope credentials keep the refusals they had.What to change: Nothing. To tell a lapsed subscription from a broken credential, read /api/v1/me, then read /api/v1/usage when you need the budget.
Insider Radar takes mode=stable, a cursor tied to one published set of scores
GET /api/v1/insider-radar takes a new mode parameter. mode=stable ties the cursor to one published set of scores, together with the limit, min_suspicion, severity, and the exact score boundary of the page.Before this change, the route had one behavior, the one now called mode=live. It is still the default.A mode=stable cursor answers 400 with error.reason cursor_expired once 0xinsider republishes the scores, or once you change the filters. Request the first page again.Backward compatible. Existing live requests, response fields, and live cursors all keep working.What to change: Nothing for a live read. To review several pages that all describe the same set of scores, send mode=stable and keep the same mode and filters on every request that follows the cursor.
Webhooks get staged secret rotation with a 1-hour signature overlap
Webhook signing secrets get a three-step rotation: POST /api/v1/webhooks/{id}/rotate-secret/prepare, then activate, then retire. Webhook objects also carry two new fields, secret_rotation.status and the nullable secret_rotation.overlap_expires_at.Before this change, the only rotation replaced the current secret at once, and a read of the webhook said nothing about rotation.
  • prepare returns a new signing_secret once, while the current secret stays active.
  • activate promotes the new secret and returns it once. Deliveries then carry comma-separated v1 HMAC candidates for a 1-hour overlap.
  • retire ends the overlap and stops the previous secret from authorizing any later delivery.
  • Changing the webhook’s endpoint URL clears the staged state.
  • The immediate POST /api/v1/webhooks/{id}/rotate-secret is still there, for replacing a secret in an emergency.
Backward compatible. A client that keeps using immediate rotation, or ignores the new fields, sends the same requests it sent before.What to change: Nothing, unless you want a rotation that drops no deliveries. For that, deploy the secret prepare returned, call activate, accept either signature candidate while the overlap lasts, and call retire once the new secret is live everywhere.
Webhook delivery records add retry_schedule_reason beside next_attempt_at
GET /api/v1/webhooks/{id}/deliveries and the success response from POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/redeliver add a nullable retry_schedule_reason next to next_attempt_at. It says what set the time in next_attempt_at.Before this change, you could see when a pending retry was due, but not why it was due then. That time can come from the receiver asking for it, a transient failure, a permanent or auth response, a manual redelivery, or a change to the endpoint’s configuration.retry_schedule_reason is null while an attempt is in flight, and after the delivery reaches its final state.0xinsider now also reads Retry-After from a receiver’s 408, 429, or 5xx response, as delta-seconds or an HTTP-date, and clamps the wait it asks for to between 60 and 3,600 seconds. A missing, malformed, past, or non-retryable hint keeps the ordinary retry schedule. The 8-attempt delivery budget, the endpoint failure budget, and the fairness limits are unchanged.Backward compatible. No request changes, and you can ignore the new field.What to change: Nothing. If you read delivery records, you can branch on retry_schedule_reason, and treat next_attempt_at as the earliest time the next attempt runs.
Sharp money flow cursors expire when the filters or the data behind them change
GET /api/v1/markets/sharp-money-flows and its deprecated smart-money-flows alias now tie each opaque smf_ cursor to the filters of the first page, and to the data that page was built from.Before this change, the cursor carried only the first page’s time bound and its position in the results. A changed filter or a late correction could move you onto different data, with nothing in the response to say so.A request answers 400 with error.reason cursor_expired when you change a filter, when 0xinsider rebuilds the wallet rankings, or when it corrects the flow totals. A cursor issued before this change gets the same response.Breaking for smf_ cursors stored before this change. The route aliases, the ETag, and the response rows keep the shapes they had.What to change: Keep storing next_cursor opaquely. When error.reason is cursor_expired, discard the cursor and request the first page again.
Market candles URL-decode the query and refuse a from that is later than to
GET /api/v1/market/{condition_id}/candles now URL-decodes the query fields and values it recognizes. A request whose from is later than its to answers 400.Before this change, the route did not decode a percent-encoded field name or value, and it accepted an inverted interval.
  • from is still an exclusive lower bound in Unix seconds, and to is still inclusive.
  • A recognized field sent more than once uses the last value.
  • An unknown field is ignored.
Breaking for a request that sends from after to. A query written out literally returns the same fields, with the same inclusive and exclusive bounds.What to change: Correct any interval whose from is later than its to.
Trending wallet cursors are tied to the limit, the window, and one ranked board
GET /api/v1/leaderboard/trending now ties next_cursor to the limit and window you sent, and to the ranked board it was issued against.Before this change, the opaque cursor held only a page number. You could change limit or window and keep reading against a different slice, and a board refresh could mix rows from different boards across pages.A cursor sent with a different limit or window, or after the board has been rebuilt, answers 400 with error.reason cursor_expired. A page-only cursor from before this change is refused the same way.Breaking for cursors you already hold. The route and the response fields are unchanged.What to change: Send the same limit and window on every request that follows a next_cursor. When a cursor is refused, discard it and request page one again.
Python SDK 0.2.0: download_trader_export follows the redirect and streams the file
0xinsider 0.2.0 on PyPI (import oxinsider) changes what download_trader_export(address, job_id=...) returns. It now returns a Download that streams the export file.Before this change, the method raised the 302 from GET /api/v1/trader/{address}/export/download as OxinsiderApiError. That route answers 302 with a short-lived signed location once the job is ready, and the SDK went through its JSON call path and never followed it.The API request carries the bearer token. The redirect is followed once, with a fresh request that sends no Authorization and no Cookie, whatever httpx.Client you passed in.
  • iter_bytes() yields the decoded file. The object is served with Content-Encoding: gzip.
  • iter_raw() yields the bytes as they were sent.
  • save(path, decode=) writes the file and returns SavedDownload, with bytes_written and the SHA-256 of exactly what it wrote.
  • read(max_bytes=) refuses more than 64 MiB by default.
  • content_type, content_encoding, content_length, filename (from Content-Disposition), and etag are the file host’s own headers. The API publishes no manifest or checksum for an export, and etag identifies the object, not its contents.
Every download failure raises DownloadError, whose message names the host and never the signed URL.
  • insecure_location: the location is plain http:// on a host other than loopback. Nothing is requested.
  • unexpected_redirect: the file host answered with a second redirect.
  • unavailable: the file host answered with a non-2xx, carried on status. A 403 means the location has expired, so call the operation again.
  • interrupted: the transfer stopped part way.
  • too_large: a read went past max_bytes.
  • not_redirected and missing_location: the API’s answer was not a redirect.
A job that is not ready still raises the API’s own BadRequestError (400), and an unknown job still raises NotFoundError. redirect_api_openapi_spec(), the other redirect-only operation (307 to the web origin), returns a Download the same way.Breaking for those two methods. Every other operation is unchanged, and the server did not change.What to change: If you caught the 302 as OxinsiderApiError and read error.body or the header yourself, take the Download instead and call save or iterate it. Every request to the API origin now sets follow_redirects=False, so a custom httpx.Client with redirect following turned on no longer follows a redirect on the SDK’s behalf.
TypeScript SDK 0.2.7: verifySignature throws on a NaN, infinite, or negative tolerance
@0xinsider/sdk 0.2.7 changes verifySignature() for a misconfigured receiver. A toleranceSeconds of NaN, Infinity, -Infinity, or any negative number now throws a configuration error before any check runs.Before this change, NaN and Infinity turned the replay window off without saying so, because Math.abs(now - timestamp) > NaN is never true. A correctly signed delivery of any age verified as true, and a negative tolerance rejected everything.Nothing else changed:
  • The default is still 300 seconds.
  • A delivery exactly toleranceSeconds old still passes, and 1 second older still fails.
  • 0 still accepts only the current second.
  • A bad signature or a malformed timestamp still returns false.
  • The HMAC recipe and the constant-time compare are the same.
  • The server did not change.
A receiver that passes a fixed, finite, non-negative tolerance sees no difference.What to change: If you compute the tolerance from configuration, expect a throw when that configuration is broken. Log it as your own bug, rather than answering the delivery as though it were forged.
A per-IP 429 answers the error envelope, with reason ip_rate_limited or ip_throttled
A 429 from the per-address budget on any /api/v1 route now answers the standard error envelope. That budget is the 1,200 requests a minute every caller behind one IP address shares, counted before authentication, on public routes and on a refused credential alike.Before this change, those responses carried a flat body, {"error":"too_many_requests","reason":"rate_limited"} or {"error":"rate_limited","reason":"ip_throttled",...}, with no request id and no retry_at. The API reference had never described that shape. It promised the envelope on every 429.
  • error.code is rate_limited, as before.
  • error.reason is ip_rate_limited for the per-minute budget, and ip_throttled for an address in a cooldown, which runs for minutes to days after sustained over-limit traffic.
  • error.retry_at and meta.request_id are now present, and meta.request_id equals X-Request-Id.
  • Retry-After and the RateLimit-* and X-RateLimit-* headers describe the same bucket they described before.
  • A 429 from your own key’s window still carries no error.reason, and the monthly quota keeps monthly_quota_exceeded.
@0xinsider/sdk 0.2.6 lists both reasons in API_ERROR_REASONS, and raises both as RateLimitedError with reason set. Errors and Rate limits have the tables.Backward compatible. rate_limited and Retry-After are unchanged, and the two reasons are new values.What to change: Nothing, if you branch on error.code or sleep for Retry-After. If you read error at the top level of a 429 body, read error.code instead.
TypeScript SDK 0.2.6 retries a write only on the five keyed write operations
@0xinsider/sdk 0.2.6 changes which failed requests it repeats. A write is retried on 429, 502, 503, 504, or a network error only when it is one of the five operations that declare the Idempotency-Key header and it carries a key. Every attempt then sends the same key and the same request bytes.Before this change, any request carrying an Idempotency-Key was retried, including verifyWebhook, which posts a challenge to your URL on every attempt, and submitTraderExport, which starts a job. The API does not read the header on either one.
  • The five keyed writes are createWebhook, updateWebhook, deleteWebhook, rotateWebhookSecret, and redeliverWebhookDelivery, exported as IDEMPOTENT_WRITE_OPERATIONS.
  • idempotencyKey, or an Idempotency-Key header you set yourself, on any other operation now throws before the request is sent, and the message names the five.
  • The batch lookups POST /api/v1/traders/batch and POST /api/v1/markets/intel/batch are now retried the way a GET is. They store nothing, and each attempt is one request against your quota, exactly as a GET is. Before this change, no batch POST was retried.
  • Every other POST, PATCH, and DELETE is retried only as one of those keyed writes.
  • retryEligibility(operation) returns "read", "keyed", or "never", and READ_ONLY_POST_OPERATIONS is exported.
The README now says how to settle an unknown outcome on a keyed write: resend the same key and body, or read the resource. It also says that a key gives you a safe replay, not exactly-once delivery. The server did not change.What to change: Remove idempotencyKey from any operation outside those five.
Python and Go SDKs 0.2.0 send a bearer over https only, or http to loopback
0xinsider 0.2.0 on PyPI and github.com/0xinsider/0xinsider-go 0.2.0 refuse to send an API key or an OAuth access token over plain http:// to any host other than loopback (localhost, 127.0.0.0/8, [::1]).Before this change, both sent the bearer token wherever the base URL pointed, so a mistyped http:// base or a plain-http proxy received the key. The TypeScript SDK has refused that since 0.1.In Python, oxinsider.Client(api_key=..., base_url="http://...") raises InsecureTransportError from the constructor, before any request. Every request is checked again on its final URL, so a key set later through client.api_key = ..., an Authorization passed in headers=, or an auth= on your own httpx.Client is refused the same way.The SDK never follows a redirect on its own, and Client.download refuses a Location that is not https:// or loopback http://, so an https:// answer cannot downgrade a credentialed request.In Go, WithBearerToken fails the request with *InsecureTransportError before it is sent. The HTTP doer that oxinsider.New installs refuses any request carrying Authorization to such a destination, whichever code set the header. The client’s CheckRedirect refuses a credentialed redirect to plain http:// and sets Redirect to true on the error, which is the hop where net/http would otherwise copy the header onto a same-domain downgrade.Both errors name the destination and never the credential.Breaking for one configuration: a credentialed SDK pointed at http://<remote host> now fails before the request, instead of sending the key in clear. A keyless client still calls the public operations (GET /api/v1, health, and platforms) on any base, the sandbox client is unchanged, and the server did not change.What to change: Move that base URL to https://, or to a loopback address when you run the backend yourself.
Go SDK 0.2.0 adds OpenStream and refuses the buffering GetStreamWithResponse
github.com/0xinsider/0xinsider-go 0.2.0 adds OpenStream(ctx, params, opts...), the way to read GET /api/v1/stream from Go. It returns a *StreamReader that hands you each frame as it arrives.Before this change, the only typed method was the generated GetStreamWithResponse, which read the body to EOF. On a healthy stream it never returned, and its buffer grew without bound.
  • Next() returns the next StreamFrame, with Seq, Type, PublishedAt, Data, and Resync.
  • The reader holds at most WithMaxFrameBytes (1 MiB by default) for one undelivered frame, and it closes the connection when the context is cancelled or you call Close().
  • LastSeq() is the sequence to send back as Last-Event-ID. RetryHint() gives the SSE retry: field, and Response() carries the headers.
  • A frame that breaks the SSE contract ends the stream with a *StreamProtocolError carrying LastSeq, FrameID, and a Reason: unexpected_media_type, invalid_json, invalid_envelope, unusable_sequence, invalid_resync, or frame_too_large. A malformed frame never moves the cursor.
  • A non-200 answer is a *StreamHTTPError with the decoded error body and the headers, such as Retry-After. At most 64 KiB of that body is read.
  • Keep-alive comments are consumed silently, multi-line data: is joined, LF and CRLF framing both parse, and the SSE id is the sequence when the envelope carries no seq.
This is the same decoder contract the TypeScript SDK has had since 0.2.5.Breaking for one call: a client built with oxinsider.New now refuses GetStream and GetStreamWithResponse on /api/v1/stream with ErrStreamBuffered, before any request, instead of hanging. Every other operation is unchanged, and the server did not change.What to change: Read the stream with OpenStream. If you want the raw *http.Response, call GetStream with RawStreamContext(ctx), and own the body yourself.
A weekly report range is capped at 31 days and comes back marked ephemeral
GET /api/v1/reports and GET /api/v1/reports/weekly now answer an explicit weekly from,to range with the exact inclusive UTC range you asked for, for up to 31 days. That body carries the new snapshot.storage field set to ephemeral, and snapshot.version set to 0. A wider range answers 400 with error.reason invalid_query.Before this change, every explicit range was stored and identified as a durable snapshot, and a range of any width was accepted.An ISO YYYY-WW request is unchanged. It still returns a durable canonical snapshot, as do the canonical daily and monthly requests.Backward compatible. snapshot.storage is a new field. The one behavior change is that an explicit range wider than 31 days is now refused.What to change: Ask for an ISO week when you need a durable snapshot you can cache. For an explicit range, keep it inside 31 inclusive UTC days, branch on snapshot.storage, and do not treat a snapshot.version of 0 as a durable cache key. Handle invalid_query, or split a wider request into ranges the route accepts.
A report reads final only once its body was built after snapshot.final_after
GET /api/v1/reports and the daily, weekly, and monthly routes now answer snapshot.status final and completeness.status complete only when the served body was read from the source at or after snapshot.final_after. That instant is the range’s UTC close plus 7,530 seconds, which is how long a whale trade has to reach the report’s source.Before this change, a range that had ended on the calendar read final even when its body was built before the range ended. A daily report refreshed at 23:58Z was frozen at midnight, without the last minutes of the day or anything that arrived after them.A closed range still inside that budget now reads rolling and partial. It keeps refreshing every 5 minutes, is rebuilt once after final_after, and is frozen from then on.
  • snapshot.period_closed is new: the calendar range has closed.
  • snapshot.final_after is new: the earliest instant a final body can be built.
  • snapshot.source_read_started_at is new: when the body you are reading was read from the source. It is null for a body stored before that clock existed.
  • snapshot.mutable_until on a rolling body is now the date of final_after rather than the range’s last day. It is still null once the body is final.
Backward compatible. Enums, field names, and types are unchanged.What to change: If you waited for UTC midnight to read a settled daily figure, wait for final_after instead (02:05:30Z for a daily report), or poll until snapshot.status reads final. If you key a cache on snapshot.version, change nothing, because a final version is still immutable.A trade that arrives after the budget is a pipeline fault and does not move a final body, and a correction arrives as a new snapshot.version.
Event replay orders by commit time, so a stored cursor no longer skips a trade
GET /api/v1/events/feed/since now orders events by the moment each write became visible to every reader, then by whale_alerts.id. A page never reads past the oldest write that was still open when the page was built.Before this change, events were ordered by whale_alerts.id alone, and next_cursor was the largest id on the page. Ids are handed out when a write starts, not when it finishes, so a trade with a lower id whose write finished later fell below the stored cursor and was never delivered. It is now delivered on a later request, once every older write has finished.
  • meta.replay.ordering reads commit_visibility_then_id_asc instead of whale_alerts_id_asc.
  • data[].sequence (the whale_alerts.id) no longer always increases across a replay, so a lower id can follow a higher one.
  • data[].cursor and next_cursor use a new opaque format.
  • meta.replay.pending_beyond_horizon is new. It is true when committed trades are still held behind an open write, so a caught_up page with it set to true is not the end of the stream. Poll again.
  • data[].id is unchanged and is still the field to deduplicate on.
Every other field name, type, and the envelope are unchanged. A cursor stored before this change stays accepted for good, and the first page it returns uses the new format.Expect the replay to trail the newest trade by the longest write still open on the database. That is seconds in normal operation, and the length of a backfill while one runs. pending_beyond_horizon tells you which of the two you are looking at.What to change: Nothing, if you store next_cursor opaquely and deduplicate on data[].id. If you ordered or deduplicated on data[].sequence, move to cursor and id, because sequence would drop exactly the trades this change recovers.
Every /api/v1 error now answers the standard envelope, the timeout included
Every /api/v1 error response is now the standard envelope: object, error.code, error.reason, error.param, and meta.request_id. That includes the responses that used to carry plain text or nothing at all.Before this change, a body that was not JSON got a plain-text 400, and a body that missed a required field or had a wrong type got a plain-text 422. The timeout returned an empty body, and the docs told you to check the status before parsing.These all keep error.code bad_request:
  • invalid_body, on a 400: the body is not JSON, misses a required field, or has a wrong type. error.param names the JSON path (traders, traders[0]) or body.
  • invalid_query and invalid_path, on a 400: a query parameter or a path segment does not parse. error.param names it.
  • unsupported_media_type, on a 415: the body arrived without Content-Type: application/json.
  • payload_too_large, on a 413: the body is over 1,048,576 bytes.
  • method_not_allowed, on a 405: the path does not serve that method. The response carries the Allow header.
The 30-second timeout keeps its 408 status and now carries the envelope too, with the new error.code request_timeout. On a GET or HEAD it also carries Retry-After: 5 and error.retry_at. On a POST, PATCH, or DELETE it carries neither, because the request may have finished on the server after the timeout fired.@0xinsider/sdk 0.2.5 throws ServerTimeoutError on a 408 and retries it the way it retries a 503: on a GET, or on a write that carries an Idempotency-Key. Its API_ERROR_CODES and API_ERROR_REASONS carry the new values. Errors has the full table.Backward compatible. request_timeout is a new code, the six reasons are new values under the existing bad_request, and every other response is unchanged.What to change: Nothing, if you switch on error.code and have a default branch. If you parsed the body only when the content type was JSON, you now get a body on these responses. After a 408 on a write, check whether it went through before you repeat it, and reuse its Idempotency-Key.
TypeScript SDK 0.2.5: a malformed stream frame now throws instead of being skipped
@0xinsider/sdk 0.2.5 changes how streamFeed() and streamFeedResilient() handle a GET /api/v1/stream connection that breaks the SSE contract. Five cases now throw StreamProtocolError instead of being passed over, each with its own reason.Before this change, a malformed frame was skipped and the next valid frame moved the resume cursor past the gap. A malformed resync became an empty marker, and an unusable sequence was yielded as NaN. A 2xx response that was not an event stream read as an empty stream, which the reconnect loop retried as an outage.
  • invalid_json: the data frame’s payload is not JSON.
  • invalid_envelope: the payload is not an object.
  • unusable_sequence: the envelope has no finite seq and the frame has no finite SSE id.
  • invalid_resync: a resync frame’s payload is not a resync object.
  • unexpected_media_type: the response is 2xx but its Content-Type is not text/event-stream.
A new maxFrameBytes option (default DEFAULT_MAX_STREAM_FRAME_BYTES, 1 MiB) caps one undelivered frame. Past that size the connection closes and StreamProtocolError frame_too_large is thrown. The error carries lastSeq (the last sequence delivered on that connection, since a malformed frame never moves the cursor), frameId, event, bytes, and mediaType, and never the raw payload.Backward compatible. A client reading a well-formed stream sees no difference. Comment lines (: keep-alive), LF and CRLF framing, unknown SSE fields, and unknown but valid envelope type values are unchanged, and the server did not change.What to change: streamFeedResilient() treats StreamProtocolError as permanent, because resuming from lastSeq would replay the same frame. Decide in your own code whether to resume later, resume after frameId, or refetch state and attach to the live stream. decodeStreamFrame() and isEventStreamMediaType() are now exported if you decode frames yourself.
Pay as you go stops at 1,000,000 requests a month, and usage reports the ceiling
Pay as you go now has an upper bound. From October 1, 2026, an account with pay as you go on can make up to 1,000,000 authenticated requests in a UTC calendar month. Past that, requests answer 429 with error.code rate_limited and error.reason monthly_quota_exceeded until the month resets, exactly as they do for an account without pay as you go past 250,000.Before this change, pay as you go had no ceiling. Nothing is refused before October 1: until then the count and the notices run as they do today.
  • A refused request is not counted and not billed.
  • GET /api/v1/usage adds ceiling to monthly_quota: the number that binds this account once enforcement begins. It is 250000, 1000000 with pay as you go on, or null for an admin account.
  • limit keeps its meaning, the requests Pro includes, and X-Monthly-Quota-Limit is unchanged.
  • The key owner is emailed once when the month reaches the ceiling. A higher ceiling is one reply to that email.
Backward compatible. A client that reads remaining and stops at 0 changes nothing.What to change: If you run pay as you go and meant to send more than 1,000,000 requests in a month, pace yourself under the ceiling or reply to that email to have it raised.
TypeScript SDK 0.2.4: pagination throws on a broken cursor instead of ending early
@0xinsider/sdk 0.2.4 changes how paginate(), paginatePages(), and collect() handle a page that breaks the list protocol. Two broken pages now throw PaginationError before the page is yielded, each with its own reason.Before this change, a page that promised more rows but gave no cursor ended the run as if the collection were exhausted, and a repeated cursor was requested again with no limit.
  • missing_cursor: the page says has_more: true and has no usable next_cursor.
  • repeated_cursor: the page’s next_cursor repeats a cursor already requested, caught before the duplicate request. The last 1,024 cursors are kept for this check (CURSOR_HISTORY_LIMIT).
  • Both errors carry the received page, the cursor it was requested with, nextCursor, pagesFetched, and operationId. paginationResumePoint(error) returns a resume point for them, as it does for a failed request.
  • A page with has_more: false still ends pagination, whatever next_cursor says.
client.list() now throws InvalidResponseError (code invalid_response, a client-side code, with the body on received) when a list response has a non-boolean has_more or a non-string next_cursor. Before, isListEnvelope() only checked that has_more was present.maxPages is checked before the first request: it must be a positive integer or Infinity, and 0, a negative, fractional, or NaN value throws RangeError. A new progress option (PaginationProgress) is updated as pages arrive. When pagination ends it says whether it stopped at maxPages (stoppedBy: "max_pages", with nextCursor to continue from) or at the end of the collection ("exhausted").Backward compatible. A client reading a well-formed server sees no change, and the server did not change.What to change: If your code relied on pagination ending without an error on a broken page, catch PaginationError.
TypeScript SDK 0.2.3: a long Retry-After on the stream no longer reconnects at once
@0xinsider/sdk 0.2.3 changes what streamFeedResilient() does with a 429 or 503 whose Retry-After is longer than the new maxRetryAfterMs option (default 60,000 ms, the ceiling the REST retries already used). It now throws StreamRetryDeferredError and spends none of maxReconnects on the wait. The error carries retryAt (the instant the wait ends), retryAfterMs, lastSeq (the seq to pass as lastEventId when you reconnect), and the refusal as cause.Before this change, it multiplied the header by 1,000 and handed the result to setTimeout. Node schedules any delay above 2,147,483,647 ms (about 24.8 days) as 1 ms, so a wait until the first of next month on monthly_quota_exceeded reconnected immediately. Pass maxRetryAfterMs: Infinity to hold the process instead: the wait then runs in timer-sized chunks and is never shortened.The stream and REST retry loops now read Retry-After through one parser, exported as parseRetryAfter(header, nowMs?).
  • Both delta-seconds and an HTTP-date (RFC 9110) parse.
  • A negative, non-finite, or malformed value reads as no header at all, so the loop’s own backoff applies.
  • A date in the past reads as 0.
  • RETRY_AFTER_CEILING_MS, MAX_TIMER_DELAY_MS, and DEFAULT_MAX_STREAM_RETRY_AFTER_MS are exported.
Backward compatible. Short waits, jitter, and abort behave as before, and the server did not change.What to change: If you catch StreamReconnectsExhaustedError, also catch StreamRetryDeferredError and schedule the reconnect at retryAt.
Remote MCP: GET answers 405, so clients stop reconnecting every second
GET /api/v1/mcp with a credential now returns 405 Method Not Allowed, with Allow: POST, a JSON-RPC error body (code -32004), and X-Mcp-Error-Code: -32004. That is the MCP Streamable HTTP answer for a server with no server-to-client stream, which is the case here: this server sends no notifications. A conforming client, including the reference @modelcontextprotocol/sdk, reads the 405 as “no stream at this endpoint” and stops retrying.Before this change, the same GET returned 200 with Content-Type: text/event-stream and closed the body at once. Clients read the closed stream as a disconnect and reopened it about once a second, so a connected agent could spend tens of thousands of requests a day on empty reconnects. One did.Without a credential the GET still returns 401 with the WWW-Authenticate challenge, which is the entry point for the OAuth flow.Breaking for a client that opened the GET stream, on a request that never carried data. A client that only sends JSON-RPC over POST changes nothing.What to change: Accept 405 on GET /api/v1/mcp as normal; the reference SDK already does. @0xinsider/sdk 0.2.4 drops the openMcpEventStream row from API_CLIENT_OPERATIONS and the Operations types, because the operation no longer has a 200.
Remote MCP notifications receive HTTP 202 with no JSON-RPC reply
POST /api/v1/mcp now answers a supported ID-less ping, notifications/initialized, and notifications/cancelled with HTTP 202 and no body. Other ID-less methods, and unsolicited response messages, answer HTTP 400 with no body. An unknown cancellation ID is ignored and does not stop an in-flight tool call.Before this change, an ID-less ping got a JSON-RPC success body with id: null.Breaking for a client that relied on id: null standing in for an omitted ID. This corrects the notification contract rather than adding a field.What to change: Send an id on initialize, ping, tools/list, or tools/call when you need a JSON-RPC reply, and the server echoes that string, number, or explicit null ID. To send a notification, omit id and do not parse the HTTP 202 body.
TypeScript SDK 0.2.2: a keyless sandbox client, and a base URL keeps its path
@0xinsider/sdk 0.2.2 adds OxinsiderApiClient.sandbox(), a client for the sandbox server https://0xinsider.com/sandbox (the second servers entry in the OpenAPI document). new OxinsiderApiClient({ sandbox: true }) builds the same client, and SANDBOX_BASE_URL and resolveApiUrl() are exported.
  • Every operation runs with no apiKey.
  • The X-Oxi-Sandbox: true response header is lifted to meta.sandbox.
  • query: { sandbox_status: 429 } throws the same RateLimitedError production would, with retryAfterSeconds 60 and retryAt.
  • A sandbox key (oxi_sk_test_... from POST /api/v1/agents/register) is optional and is sent when you give one. A live key (oxi_sk_live_...) is refused by the constructor in sandbox mode.
  • streamFeed() on a sandbox client throws the sandbox’s own 400 BadRequestError, because streams are not simulated there.
A baseUrl with a path now keeps that path, so baseUrl: "https://0xinsider.com/sandbox" sends GET https://0xinsider.com/sandbox/api/v1/leaderboard. Before this change, the /api/v1/... operation path was resolved against the origin, so the client silently called https://0xinsider.com/api/v1/... while the stream builder kept the path.Breaking for a client whose baseUrl carries a path. A trailing /api/v1 is still removed once, so https://api.0xinsider.com/api/v1 keeps working, and https://api.0xinsider.com and a loopback http: base are unchanged.What to change: If your baseUrl carries a path, check that requests still go where you expect. A keyless bearer call on a production client still throws locally before any request is sent, as before.
Every MCP tool result carries the route's meta, and a failed call carries the error fields
On POST /api/v1/mcp and in @0xinsider/mcp 2.1.1, every tools/call result now carries the REST route’s meta next to the payload. That is request_id, cached, cache_age_s, cost, and whatever provenance the route reports: source and completeness on get_whale_trades_history (local_replay, best_effort), or ranking_source, directional_source, and the category_skill_* fields on get_sports_edge_signals. A meta is copied, never invented, so a route that sends none leaves the result without one.Before this change, the remote endpoint kept meta on 3 tools and dropped it on the other 32, and most stdio tools dropped it too. A degraded ranking or a best-effort replay reached an agent looking healthy and complete.A failed tool call keeps its text and adds structuredContent.error:
  • The REST error’s code, reason, param, doc_url, and retry_at, verbatim.
  • retry_after_seconds, taken from the route’s Retry-After.
  • request_id and status.
  • Invalid arguments give bad_request, with param set to the first fault’s JSON Pointer inside arguments.
  • A token without the scope gives insufficient_scope, with param set to the scope.
Backward compatible. Every existing result key and the result text are unchanged. meta is a new key beside them, and structuredContent is new on a failure.What to change: Nothing if you read result.content[0].text. If you parsed retry_at or request_id out of that text, read the fields instead.
The search_markets MCP tool pages by cursor on both transports
The search_markets tool on POST /api/v1/mcp and in @0xinsider/mcp now takes a cursor argument and answers with data, has_more, and next_cursor. That is the page contract GET /api/v1/markets/search has had all along. Send next_cursor back with the same q, status, and category to read the next page, and stop when has_more is false.Before this change, the remote tool advertised no cursor, so a second page could not be asked for, and the stdio tool answered with a bare data array and no cursor at all.An invalid cursor answers with the route’s bad_request and param cursor. It never falls back to page one.Backward compatible on the remote tool. cursor is a new optional argument, and the result keys are unchanged.Breaking on the stdio tool. Its text changes from a JSON array to the {data, has_more, next_cursor, meta} object that every other list tool in the package returns.What to change: If you parsed the stdio tool’s array, read data instead.
Remote MCP answers 400 to an unsupported MCP-Protocol-Version header
POST /api/v1/mcp and GET /api/v1/mcp now read the MCP-Protocol-Version request header that the Streamable HTTP transport says a client sends on every request after initialize. The four revisions this server serves are accepted: 2025-11-25, 2025-06-18, 2025-03-26, and 2024-11-05. Any other value answers 400 with JSON-RPC -32600 and X-Mcp-Error-Code: -32600, before the body or the credential is read, and the message names those four revisions.Before this change, the header was ignored, and MCP-Protocol-Version: invalid-audit-version answered 200. Without the header a request is still served as 2025-03-26, the transport specification’s compatibility default.Two related fixes shipped with it:
  • The CORS preflight on api.0xinsider.com now grants mcp-protocol-version as a request header. A browser client that listed it in Access-Control-Request-Headers used to be refused after the handshake.
  • https://0xinsider.com/api/v1/mcp now forwards Mcp-Session-Id and MCP-Protocol-Version to the server. It had dropped both, so every call started a new session.
Breaking for a client that sends an arbitrary MCP-Protocol-Version. A client that sends the negotiated version, or no header at all, needs no change.What to change: Send the version initialize returned, or omit the header.
Remote MCP checks tools/call arguments against the tool's inputSchema
POST /api/v1/mcp now validates every tools/call against the inputSchema that tools/list advertises for that tool. A bad call answers 200 with result.isError set to true and a content[0].text that names each fault by its JSON Pointer inside arguments, for example at /limit: 500 is greater than the maximum of 100.Before this change, the server read the keys it knew and dropped the rest. {"limit": {"gt": 5}} ran as the default 20-row page, and {"limit": 2, "foo": 1} ran as limit=2.These are the faults it now refuses:
  • An arguments value that is not an object, which now includes arguments: null.
  • An unknown key.
  • A value of the wrong type.
  • A number outside the schema’s range, or a value outside its enum.
  • An argument combination outside a oneOf.
Breaking for a client that sent arguments the schema does not allow: they are refused rather than ignored. A valid call, an omitted optional argument, an absent arguments, and every documented default answer exactly as before. Unknown tools (-32601), bad JSON (-32700), and a missing name (-32602) keep their JSON-RPC protocol errors.What to change: If you sent extra keys, or a string where the schema says integer, send the shape the schema states. The stdio package @0xinsider/mcp already did.
One request ID: X-Request-Id and meta.request_id now carry the same value
Every /api/v1 response now carries one request ID. The X-Request-Id header and meta.request_id in the body hold the same value. That value is what the request’s usage record and its log lines are filed under, so it is the ID to quote in a support request.Before this change, the header and the body carried two different IDs, and meta.request_id, the one the docs told you to quote, matched nothing on the server.
  • The header is now on every /api/v1 response, whatever produced it: 304 Not Modified, 408 Request Timeout, CORS preflights, and every error. A public route or a timeout used to answer without it.
  • The format is unchanged: req_ plus 12 hex characters.
  • An X-Request-Id you send is ignored. The value is never adopted or echoed, so a request cannot be filed under an ID you chose.
Backward compatible. A client that reads either form for support can keep reading it, and now gets the one the server knows.What to change: Nothing.
TypeScript SDK 0.2.1: batchGetTraders now sends the body the route requires
@0xinsider/sdk 0.2.1 fixes batchGetTraders(). It now sends { "traders": [...] }, the body POST /api/v1/traders/batch declares.Before this change, it sent { "identifiers": [...] }, a field the route does not declare, so every call was refused for a missing traders.A new expand option is sent as the body’s expand array and applies to every item: client.batchGetTraders(["swisstony", "trd_123"], { expand: ["strategy"] }). The values are strategy, categories, quant_metrics, and trust, exported as BATCH_TRADER_EXPANSIONS.Backward compatible. The positional signature is unchanged: the first argument is still the array of wallet addresses, usernames, trd_ ids, or integer trader ids, in input order. The route itself did not change.What to change: If you worked around this with client.call("batchGetTraders", { body: { traders } }), you can switch to the method or keep the workaround. Both now send the same request body.