> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xinsider.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Markdown of an API reference page leaves out the OpenAPI schema.
> Every object an endpoint returns or a webhook carries, with each field's type, presence and meaning, is on https://docs.0xinsider.com/api-reference/objects.md.
> The full OpenAPI 3.1 spec names each operation's and each webhook's exact parameters, request body and response schema: https://docs.0xinsider.com/api-reference/openapi.json.

# API changelog: September 22, 2026

> 52 API, SDK, CLI, MCP, OAuth, and feed changes from September 22, 2026, newest first, each with its effect on clients. Newer changes are on the API changelog.

These are the API changelog entries from September 22, 2026, newest first. The newest changes are on the [API changelog](https://docs.0xinsider.com/changelog.md).

**September 22, 2026**

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.

**September 22, 2026**

Official Rust client, and the TypeScript client's public source repository

Two official clients are now public repositories: [`0xinsider/0xinsider-rust`](https://github.com/0xinsider/0xinsider-rust), the Rust client (crate `oxinsider`), and [`0xinsider/0xinsider-node`](https://github.com/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](https://docs.0xinsider.com/integrations/rust-client.md) 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](https://docs.0xinsider.com/integrations/typescript-client.md) 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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/get-trader-context-markdown) 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`](https://docs.0xinsider.com/api-reference/endpoint/get-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`.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/get-trader-context-markdown) and [`GET /api/v1/market/{condition_id}/context.md`](https://docs.0xinsider.com/api-reference/endpoint/get-market-context-markdown) 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`](https://docs.0xinsider.com/api-reference/endpoint/remote-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`](https://docs.0xinsider.com/api-reference/endpoint/register-agent) is `client.registerAgent()`. It needs no credential and answers `201` with the `oxi_sk_test_` sandbox key.
* [`GET /api/v1/trader/{address}/export/download`](https://docs.0xinsider.com/api-reference/endpoint/download-trader-export) 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`](https://docs.0xinsider.com/api-reference/endpoint/submit-trader-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.

**September 22, 2026**

The sandbox pages through a coherent world and refuses what the live API refuses

The [sandbox](https://docs.0xinsider.com/sandbox.md) 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`](https://docs.0xinsider.com/api-reference/endpoint/batch-get-traders) and [`POST /api/v1/markets/intel/batch`](https://docs.0xinsider.com/api-reference/endpoint/batch-get-market-intel) 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.

**September 22, 2026**

The OpenAPI document now matches what the server does on 6 points

[`/api/v1/openapi.json`](https://0xinsider.com/api/v1/openapi.json) now describes what the server already does in 6 places. Nothing on the wire changed.

* [`GET /api/v1/webhooks/{id}/deliveries`](https://docs.0xinsider.com/api-reference/endpoint/list-webhook-deliveries): `limit` defaults to `20`, not `50`. A value outside 1 to 100 is clamped into that range.
* [`GET /api/v1/pick-of-the-day/ledger`](https://docs.0xinsider.com/api-reference/endpoint/get-pick-of-the-day-ledger) no longer lists `401`, `402`, `403`, or `423`. The route is public and cannot return them.
* [`GET /api/v1/positions`](https://docs.0xinsider.com/api-reference/endpoint/get-positions) now lists the `404` it returns, with `error.param` `wallet`, when a `wallet` value matches no wallet.
* [`GET /api/v1/market/{condition_id}/holders`](https://docs.0xinsider.com/api-reference/endpoint/get-market-holders): the `cursor` description said a cursor starts with `mh_`. A served cursor never does, so treat it as opaque.
* [`GET /api/v1/whale-trades/history`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trades-history): `trader` also accepts a `trd_` id.
* A `?token=` key is refused on every route that needs a key. The public routes ignore it.

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

**September 22, 2026**

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.

**September 22, 2026**

Large trades: market\_volume\_share never exceeds 1

`market_volume_share` on [`GET /api/v1/whale-trades`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trades), [`GET /api/v1/whale-trades/history`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trades-history), [`GET /api/v1/whale-trades/{id}`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade), 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.

**September 22, 2026**

The OpenAPI document separates fields a response leaves out from fields it sends as null

[The OpenAPI document](https://0xinsider.com/api/v1/openapi.json) 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`](https://docs.0xinsider.com/api-reference/endpoint/get-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.

**September 22, 2026**

Every whale trade carries the grade its wallet held when the trade happened

[`GET /api/v1/whale-trades`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trades), [`GET /api/v1/whale-trades/history`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trades-history), [`GET /api/v1/whale-trades/{id}`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade), and the `trade` object from `expand=trade` on [`GET /api/v1/events/feed/since`](https://docs.0xinsider.com/api-reference/endpoint/get-event-replay-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.

**September 22, 2026**

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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/get-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`.

**September 22, 2026**

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`](https://docs.0xinsider.com/errors), 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](https://docs.0xinsider.com/rate-limits.md) 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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/download-trader-export), 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`](https://docs.0xinsider.com/api-reference/endpoint/get-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)`.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade-counterparty-executions) and [its makers route](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade-counterparty-makers.md) take a `cursor` but answer `object: "counterparty_analysis"` with an object `data`, so call their methods directly.

**September 22, 2026**

The Pick of the Day ledger is public: no API key, no subscription

[`GET /api/v1/pick-of-the-day/ledger`](https://docs.0xinsider.com/api-reference/endpoint/get-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](https://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.

**September 22, 2026**

GET /api/v1/positions filters by wallet, so a portfolio no longer pages the whole board

[`GET /api/v1/positions`](https://docs.0xinsider.com/api-reference/endpoint/get-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](https://docs.0xinsider.com/recipes/portfolio-tracker.md) 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}`](https://docs.0xinsider.com/api-reference/endpoint/get-trader).
* 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.

**September 22, 2026**

Event replay takes four filters, and expand=trade adds the full trade to each event

[`GET /api/v1/events/feed/since`](https://docs.0xinsider.com/api-reference/endpoint/get-event-replay-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}`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade) 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}`](https://docs.0xinsider.com/api-reference/endpoint/get-whale-trade) 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.

**September 22, 2026**

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.

**September 22, 2026**

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.

* [`POST /api/v1/agents/register`](https://docs.0xinsider.com/api-reference/endpoint/register-agent): `register_agent()` in Python, `RegisterAgentWithResponse` in Go, both answering `201` with the sandbox key.
* [`GET /api/v1/trader/{address}/categories`](https://docs.0xinsider.com/api-reference/endpoint/get-trader-category-records)
* [`GET /api/v1/pick-of-the-day/ledger`](https://docs.0xinsider.com/api-reference/endpoint/get-pick-of-the-day-ledger)
* [`GET /api/v1/market/{condition_id}/holders`](https://docs.0xinsider.com/api-reference/endpoint/get-market-holders)
* [`POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/redeliver`](https://docs.0xinsider.com/api-reference/endpoint/redeliver-webhook-delivery), which takes `Idempotency-Key`.
* [`GET /api/v1/market/{condition_id}/context.md`](https://docs.0xinsider.com/api-reference/endpoint/get-market-context-markdown): Python returns the Markdown as `str` and asks for it with `Accept: text/markdown`, and Go leaves it in `Body`.
* [`GET /api/v1/me`](https://docs.0xinsider.com/api-reference/endpoint/get-account-identity)

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](https://0xinsider.com/api/v1/openapi.json).
* 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.

**September 22, 2026**

The stream closes with an error frame when its key stops working

[`GET /api/v1/stream`](https://docs.0xinsider.com/api-reference/endpoint/get-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`.

**September 22, 2026**

Pick of the Day is priced at a flat $1,000 stake instead of $100

[`GET /api/v1/pick-of-the-day`](https://docs.0xinsider.com/api-reference/endpoint/get-pick-of-the-day) and [`GET /api/v1/pick-of-the-day/archive`](https://docs.0xinsider.com/api-reference/endpoint/get-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](https://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.

**September 22, 2026**

GET /api/v1/me reports whether paid data access is active or lapsed

[`GET /api/v1/me`](https://docs.0xinsider.com/api-reference/endpoint/get-account-identity) 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`](https://docs.0xinsider.com/api-reference/endpoint/get-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.

**September 22, 2026**

Insider Radar takes mode=stable, a cursor tied to one published set of scores

[`GET /api/v1/insider-radar`](https://docs.0xinsider.com/api-reference/endpoint/get-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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/prepare-webhook-secret), then [`activate`](https://docs.0xinsider.com/api-reference/endpoint/activate-webhook-secret), then [`retire`](https://docs.0xinsider.com/api-reference/endpoint/retire-webhook-secret). 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`](https://docs.0xinsider.com/api-reference/endpoint/rotate-webhook-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.

**September 22, 2026**

Webhook delivery records add retry\_schedule\_reason beside next\_attempt\_at

[`GET /api/v1/webhooks/{id}/deliveries`](https://docs.0xinsider.com/api-reference/endpoint/list-webhook-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.

**September 22, 2026**

Sharp money flow cursors expire when the filters or the data behind them change

[`GET /api/v1/markets/sharp-money-flows`](https://docs.0xinsider.com/api-reference/endpoint/sharp-money-flows) and its deprecated [`smart-money-flows` alias](https://docs.0xinsider.com/api-reference/endpoint/smart-money-flows) 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.

**September 22, 2026**

Market candles URL-decode the query and refuse a from that is later than to

[`GET /api/v1/market/{condition_id}/candles`](https://docs.0xinsider.com/api-reference/endpoint/get-market-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`.

**September 22, 2026**

Trending wallet cursors are tied to the limit, the window, and one ranked board

[`GET /api/v1/leaderboard/trending`](https://docs.0xinsider.com/api-reference/endpoint/list-trending-wallets) 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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/download-trader-export) 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.

**September 22, 2026**

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.

**September 22, 2026**

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](https://docs.0xinsider.com/errors.md) and [Rate limits](https://docs.0xinsider.com/rate-limits.md) 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.

**September 22, 2026**

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.

**September 22, 2026**

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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/get-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.

**September 22, 2026**

A weekly report range is capped at 31 days and comes back marked ephemeral

[`GET /api/v1/reports`](https://docs.0xinsider.com/api-reference/endpoint/get-reports) and [`GET /api/v1/reports/weekly`](https://docs.0xinsider.com/api-reference/endpoint/get-weekly-report-snapshot) 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.

**September 22, 2026**

A report reads final only once its body was built after snapshot.final\_after

[`GET /api/v1/reports`](https://docs.0xinsider.com/api-reference/endpoint/get-reports) and the [daily](https://docs.0xinsider.com/api-reference/endpoint/get-daily-report-snapshot.md), [weekly](https://docs.0xinsider.com/api-reference/endpoint/get-weekly-report-snapshot.md), and [monthly](https://docs.0xinsider.com/api-reference/endpoint/get-monthly-report-snapshot.md) 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`.

**September 22, 2026**

Event replay orders by commit time, so a stored cursor no longer skips a trade

[`GET /api/v1/events/feed/since`](https://docs.0xinsider.com/api-reference/endpoint/get-event-replay-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.

**September 22, 2026**

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](https://docs.0xinsider.com/errors.md) 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`.

**September 22, 2026**

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.

**September 22, 2026**

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.

**September 22, 2026**

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

**September 22, 2026**

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

**September 22, 2026**

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

**September 22, 2026**

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.

**September 22, 2026**

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.

**September 22, 2026**

Every MCP tool result carries the route's meta, and a failed call carries the error fields

On [`POST /api/v1/mcp`](https://docs.0xinsider.com/api-reference/endpoint/remote-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.

**September 22, 2026**

The search\_markets MCP tool pages by cursor on both transports

The `search_markets` tool on [`POST /api/v1/mcp`](https://docs.0xinsider.com/api-reference/endpoint/remote-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`](https://docs.0xinsider.com/api-reference/endpoint/search-markets) 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.

**September 22, 2026**

Remote MCP answers 400 to an unsupported MCP-Protocol-Version header

[`POST /api/v1/mcp`](https://docs.0xinsider.com/api-reference/endpoint/remote-mcp) and [`GET /api/v1/mcp`](https://docs.0xinsider.com/api-reference/endpoint/remote-mcp-stream) 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.

**September 22, 2026**

Remote MCP checks tools/call arguments against the tool's inputSchema

[`POST /api/v1/mcp`](https://docs.0xinsider.com/api-reference/endpoint/remote-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.

**September 22, 2026**

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.

**September 22, 2026**

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`](https://docs.0xinsider.com/api-reference/endpoint/batch-get-traders) 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.