# Testnet 2026-09-24 → 0.92.5

What market makers and trading bots had to change when Testnet moved from its previous engine to the new trading engine in release 0.92.5.

This guide is for market makers and trading bots that use REST, ConnectRPC or the WebSocket connections. It covers the one-time move of Testnet from the build it ran from 24 September 2026 to release 0.92.5, deployed on 5 November 2026, which moved Testnet to the new trading engine. That move was a hard cutover: there was no compatibility period, and clients built for the previous contract stopped working where this guide says so.

> **Which step this guide covers**
>
> This guide covers the move from the 2026-09-24 Testnet build to 0.92.5 only. Changes made after 0.92.5 are in [Testnet 0.92.5 → 0.92.9](https://testnet.polyester.com/docs/developer-docs/migration-guides/testnet-0-92-5-to-0-92-9).

Every route on this page is a path relative to your existing Testnet API and WebSocket endpoints. Build against the published contract, and ignore unknown fields and enum values so later additions do not break your client.

## Who needs to act

- **Everyone:** regenerate types from the new contract, handle the new error codes, and treat "outcome unknown" answers by reading state first. See [Errors and refusals](#errors-and-refusals) and [ConnectRPC contract changes](#connectrpc-contract-changes).
- **Bots that follow their own orders:** order updates moved from the realtime order channels to the private WebSocket. See [Private order updates](#private-order-updates).
- **Event-stream consumers:** market trades, your trades, ledger transfers and trigger events arrive as arrays. See [Event channels carry arrays](#event-channels-carry-arrays).
- **SBE trading clients:** regenerate codecs for schema version 36, and read order event reasons from the new fields. See [Trading WebSocket](#trading-websocket).
- **REST traders:** query parameters are camelCase only, decimal price fields drop `Ticks`, order reads replace the terminal reason fields, too-precise decimals answer step and tick codes, the trigger list renames `stpMode`, and oversized write bodies answer `413`. See [REST requests and responses](#rest-requests-and-responses).
- **Users of attached take-profit, stop-loss and trailing legs, and of triggers:** legs share one exit, a trailing leg on a sell order needs a maximum price, and new per-Pair trigger limits apply. See [Attached take-profit and stop-loss](#attached-take-profit-and-stop-loss) and [Triggers](#triggers).
- **Bots that reuse IDs or retry:** client order IDs and retry windows change. See [Client order IDs and retry windows](#client-order-ids-and-retry-windows).
- **Busy accounts behind one IP:** the per-IP write limit and in-flight cap are lower. See [Rate limits](#rate-limits).
- **Holds and fee readers:** the holds list is removed and fee rates add reserve rates. See [Balances, holds and fees](#balances-holds-and-fees).
- **SDK users:** upgrade to an SDK release built for 0.92.5. See [SDK versions](#sdk-versions).

## Migration checklist

- Send every REST query parameter in camelCase, including `subaccountId` and `pageToken`.
- Rename `trailingDistanceTicks` to `trailingDistance`, batch replace `newPriceTicks` to `newPrice`, and the REST market overview decimal fields.
- Read `rejectReason` and `cancelReason` instead of `terminalReason` and `terminalReasonCode`, and the same fields on ConnectRPC and in SBE order events.
- Read `selfTradePreventionMode` instead of `stpMode` from the REST trigger list.
- Expect step and tick codes for too-precise REST decimals, a per-item rejection in a batch, and a non-admissible preview.
- Move from the `private:spot:orders:*` channels to the private WebSocket at `/v1/ws/private`.
- Decode market trades, your trades, ledger transfers and trigger events as arrays of events.
- Regenerate SBE codecs for schema version 36 and send that version in every message header.
- Regenerate Protobuf and ConnectRPC types from the new contract.
- Handle the new error codes, and treat "outcome unknown" answers, including for creates, by reading state first.
- Do not reuse a client order ID within 60 seconds of its order ending.
- Expect all legs of one order to share one exit, and a resting order to end when its own exit fires.
- Set `limit_price_ticks` on a trailing leg attached to a sell order.
- Expect a leg whose exit self-trade prevention expired to wait until your blocking order leaves the best price.
- Stay within 100 triggers, 20 TWAPs and 5 ladders per account and Pair.
- Expect a trigger create whose trigger or activation price is already reached to fail, and a modify that would act at once to be refused.
- Expect a trigger modify below the Pair's minimums, or with an off-tick price, to be refused with a specific code.
- Expect `404` for the events of a trigger you cannot read.
- Stop calling `ListHolds` and `GET /v1/holds`; read `reserved` from the balances.
- Size buys that pay their fee in quote from the reserve rate in the fee rates.
- Expect `413` for an oversized write body, `409` for a REST trigger conflict, and match REST problems on `code`, not `type`.
- Expect unprefixed enum values on REST and prefixed names on ConnectRPC and WebSocket JSON.
- Retry the retryable `Unavailable` answer that can follow a permission you just granted.
- Expect the lower per-IP limits, and expect a stop refused by your placement budget to wait instead of failing.
- Upgrade to an SDK release built for 0.92.5.

## REST requests and responses

Every public `/v1/*` path keeps its route, request signing and numeric encoding, except where this guide says otherwise. Prices stay decimal strings with up to 9 fractional digits; quantities stay decimal strings at each Pair's quantity scale. See [REST getting started](https://testnet.polyester.com/docs/developer-docs/rest/getting-started).

### Too-precise order decimals answer step and tick codes

**Changed:** REST order writes read a decimal the way ConnectRPC reads the value it spells.

- Zeros beyond the scale are accepted: `"0.000100000"` at base scale 8 is the quantity `"0.0001"`, and `"70000.0000000000"` is the price `"70000"`. They used to answer `400` `INVALID_ARGUMENT`.
- A quantity with a nonzero digit beyond the Pair's base scale, or a limit, take-profit, stop-loss or trailing-stop price with a nonzero digit beyond 9 decimals, answers `QTY_STEP_SIZE` or `PRICE_TICK_SIZE` with a field violation instead of `INVALID_ARGUMENT`.
- Other decimals, such as `maxQuoteDebit`, `clientRefPrice` and `trailingDistance`, still answer `INVALID_ARGUMENT` for a nonzero digit beyond their scale; zeros beyond it are accepted.

How each route answers a too-precise quantity or price:

- **[`POST /v1/orders/batch`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/batch)**\
  Rejects only that item, with a violation such as `items[0].baseQty`; the other items are admitted or rejected on their own. The whole batch used to fail.
- **[`POST /v1/orders/preview`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/preview)**\
  Answers `200` with `"admissible": false` and the code in `rejection`.
- **[`POST /v1/orders/modify`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/modify) and [`POST /v1/orders/replace-batch`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/replace-batch)**\
  Answer `400` `PRICE_TICK_SIZE`, with a violation such as `newPrice` or `items[1].newPrice`.
- **[`POST /v1/orders`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders)**\
  Answers `400` with the code and a violation such as `order.baseQty`.

**What to do:** branch on `QTY_STEP_SIZE` and `PRICE_TICK_SIZE` rather than `INVALID_ARGUMENT` for a too-precise quantity or price. Read every batch item's result even when one item is too precise, and read `admissible` on a preview instead of expecting a `400`. See [REST error responses](https://testnet.polyester.com/docs/developer-docs/rest/getting-started#error-responses).

### The trigger list names the mode selfTradePreventionMode

**Changed:** [`GET /v1/triggers`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/triggers) names each trigger's self-trade prevention mode `selfTradePreventionMode`, as [`GET /v1/triggers/{trigger_id}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/triggers/trigger_id), the trigger create request and ConnectRPC already do. It was `stpMode`. The values are unchanged. REST order reads keep `stpMode`.

**What to do:** read `selfTradePreventionMode` from the trigger list. A client that reads the list and the single-trigger read with one trigger model now gets the field from both.

## Before

```json
{
	"triggers": [{ "triggerId": "<trigger ID>", "stpMode": "EXPIRE_MAKER" }],
	"nextPageToken": "<cursor>"
}
```

## After (0.92.5)

```json
{
	"triggers": [{ "triggerId": "<trigger ID>", "selfTradePreventionMode": "EXPIRE_MAKER" }],
	"nextPageToken": "<cursor>"
}
```

### Query parameters take one camelCase name

**Changed:** every public REST query parameter now has one camelCase name, as in request bodies. The server ignores a snake_case name like any unknown parameter, so a filter sent under its old name is dropped: a Subaccount selector sent as `subaccount_id` reads the Main Account instead, and a required parameter sent under its old name answers `400`. Renamed parameters include:

- **[`GET /v1/balances`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/balances)**\
  `subaccountId`, previously `subaccount_id`.
- **[`GET /v1/balances/history`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/balances/history)**\
  `subaccountId` and `accountCode`.
- **[`GET /v1/equity/history/series`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/equity/history/series)**\
  `subaccountId`, `accountCode` and `groupBy`.
- **[`GET /v1/transfers`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/transfers)**\
  `subaccountId`, `pageToken`, `timestampMin` and `timestampMax`.
- **[`GET /v1/spot/markets/overview`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/markets/overview)**\
  `orderBy`, `includeSparklines` and `sparklineIntervals`.
- **Funding and account lists**\
  Lifecycle flows, deposit addresses, the address book, transfer destinations and the whitelists take `subaccountId`, `pageToken` and their other filters in camelCase.
- **Trigger, heatmap and Subaccount event lists**\
  [`GET /v1/triggers`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/triggers), the trigger events, the heatmap history and the Subaccount events no longer read `page_token`; send `pageToken`.

**What to do:** send every query parameter in camelCase, for example `?subaccountId=<id>&pageToken=<cursor>`, and check that your Subaccount reads return the Subaccount you asked for.

### Decimal price fields drop the Ticks suffix

**Changed:** REST fields that carry a decimal price no longer end in `Ticks`. The values are the same decimal strings in quote units. A request that sends an old name is refused with `400` `BAD_REQUEST`.

- **`trailingDistanceTicks` is now `trailingDistance`**\
  In the attached trailing leg of order create, batch create, preview, modify and batch replace, in trigger create and modify, and in order and trigger reads.
- **`newPriceTicks` is now `newPrice` on batch replace items**\
  As on a single modify.

`maxSlippageTicks` keeps its name: it is an integer count of 10^-9 price units.

**What to do:** rename these fields in your requests and response models.

## Before

```json
{ "items": [{ "clientOrderId": "mm-bid-17", "newPriceTicks": "65432.1" }] }
```

## After (0.92.5)

```json
{ "items": [{ "clientOrderId": "mm-bid-17", "newPrice": "65432.1" }] }
```

### Market overview fields drop scale suffixes

**Changed:** [`GET /v1/spot/markets/overview`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/markets/overview) names its decimal fields without `Ticks` or `Scaled`. The values are the same decimal strings.

- `lastPriceTicks`, `indexPriceTicks`, `high24hTicks` and `low24hTicks` are now `lastPrice`, `indexPrice`, `high24h` and `low24h`.
- `bestBidTicks`, `bestBidQtyScaled`, `bestAskTicks` and `bestAskQtyScaled` are now `bestBid`, `bestBidQty`, `bestAsk` and `bestAskQty`.
- `volume24hBaseScaled`, `volume24hQuoteScaled` and `volume24hUsdScaled` are now `volume24hBase`, `volume24hQuote` and `volume24hUsd`.
- `sparklines[].closeTicks` is now `sparklines[].close`.

ConnectRPC and the market overview channel keep their scaled integer fields.

**What to do:** rename these fields in your REST market overview model.

### Cancel-all lists what it canceled

**Changed:** [`POST /v1/orders/cancel-all`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/cancel-all) now also returns the orders it canceled, up to 200, oldest first, with the total count. `matchedOrders` now also counts orders whose cancellation was already in progress. A retry with the same `requestId` within 120 seconds returns the original counts with an empty list and `listingStatus` `NOT_RETAINED_READ_OPEN_ORDERS`.

**What to do:** use the list to reconcile your local book without waiting for each order update. When `truncated` is `true`, read the remaining state with [`GET /v1/orders/open`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/open).

A reply (abridged; timestamps omitted):

```json
{
	"status": "SUBMITTED",
	"matchedOrders": 3,
	"submittedCancels": 3,
	"failedCancels": 0,
	"cancelledOrders": [
		{ "orderId": "2NEpo7TZRRrLZSi2U", "clientOrderId": "mm-bid-17" },
		{ "orderId": "2NEpo7TZRRrLZSi2V", "clientOrderId": "mm-ask-17" },
		{ "orderId": "2NEpo7TZRRrLZSi2W", "clientOrderId": "mm-bid-18" }
	],
	"totalCancelled": 3,
	"truncated": false,
	"listingStatus": "INCLUDED"
}
```

### Batch replace amends in place and omits missing IDs

**Changed:** each [`POST /v1/orders/replace-batch`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/replace-batch) item amends the order in place or replaces it, as a single modify does by default. An `AMENDED` item now keeps its order under `oldOrderId` with its queue priority; it used to mean the remainder was canceled with no successor. In the results:

- `replacementOrderId` is present only on a `REPLACED` item; it is omitted on an amended or rejected item.
- `oldOrderId` is omitted only when the requested client order ID names no order. Rejected items keep it.
- `actionTaken` is omitted on rejected items. `itemIndex` is always present.

**What to do:** match results to your request by `itemIndex`, keep tracking `oldOrderId` for an `AMENDED` item, and treat a missing ID as "none".

### Gap-free trade backfill

**Added:** [`GET /v1/spot/markets/{symbol}/trades`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/markets/symbol/trades) accepts `afterTradeId`. The response returns every trade after that ID in ascending order, and says how far the history is complete with `completeThroughTradeId`, `nextAfterTradeId` and `epochEnds`.

**What to do:** after a reconnect, fetch missed trades from the last trade ID you applied, and continue from `nextAfterTradeId` until you reach `completeThroughTradeId`. `afterTradeId` cannot be combined with time filters, `side` or `pageToken`. Trade IDs can jump between epochs; use `epochEnds` instead of expecting consecutive IDs.

```http
GET /v1/spot/markets/BTC-USDT/trades?afterTradeId=1099511627790
```

### New candle timeframes and closed candles

**Added:** candles add the `3m`, `2h`, `6h`, `8h` and `3d` timeframes, for 16 in total. All buckets are UTC; `3d` candles count from 1970-01-01 and `1w` candles start on Monday.

**Changed:** ConnectRPC history candles now set `is_closed` to `true`; before, every history row had `false`, and only WebSocket terminal updates set it. Candle history ([`GET /v1/spot/markets/{symbol}/candles`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/markets/symbol/candles)) still returns closed candles only, plus the current one when you set `includeIncomplete`.

**What to do:** read `is_closed` as the bucket's state, and do not assume the last history candle is still moving.

### Order history pages can be short or empty

**Changed:** a page of [`GET /v1/orders/history`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/history) can hold fewer orders than requested, or none, while more remain.

**What to do:** keep paging while `nextPageToken` is not empty. Do not stop on an empty page.

### Order version can be 0

**Changed:** an order's `version` is `0` while it is accepted but not yet on the book (status `PENDING`). Its first book state is version `1`. `version` counts execution-state changes only. The fee total (`feeTotal`) and details a read adds on request can differ between two copies with the same version.

**What to do:** accept `version: 0`, and keep applying an update only when its version is greater than the one you hold. Refresh `feeTotal` from a later read rather than by version.

### Order reads add exact execution totals

**Added:** order reads add `cumQuote`, the quote amount the fills used. Order history and single-order reads also add `feeTotal`, the fees they paid; open-order reads omit it. ConnectRPC adds `avg_price_e18`, the exact average execution price.

**Changed:** REST `avgPx` is the exact average execution price, rounded at 18 decimal places with trailing zeros trimmed.

**What to do:** accept up to 18 decimal places in `avgPx`, or round to your own precision. To show how much of a buy's quote amount was used, read `cumQuote` and `feeTotal` instead of estimating it from `avgPx` and `cumQty`.

### Order reads name reject and cancel reasons

**Changed:** order reads drop `terminalReason` and `terminalReasonCode`. A `REJECTED` order carries `rejectReason`, the error code the order API answers for the same refusal, such as `INSUFFICIENT_FUNDS` or `POST_ONLY_CROSS`. A `CANCELED` order carries `cancelReason`, such as `USER_REQUEST`, `IOC_REMAINDER`, `STP_EXPIRE_MAKER` or `ATTACHED_EXIT_FIRED`. Each is omitted for every other status. The old numeric code had its own numbering, which did not match the error codes.

**What to do:** read `rejectReason` or `cancelReason` by name, according to the order's `status`, instead of the terminal reason fields.

### Order reads return a buy-back trailing stop's maximum

**Added:** order reads ([`GET /v1/spot/orders/{order_id}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/orders/order_id), [`GET /v1/orders/open`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/open) and [`GET /v1/orders/history`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/history)) return the maximum price you set on a trailing leg attached to a sell order, as `attachedRisk.trailingStop.policy.limitPrice`. The field is new in this release. It is absent on a trailing leg that sells. See [Trailing legs on sell orders need a maximum price](#trailing-legs-on-sell-orders-need-a-maximum-price).

**What to do:** read the maximum back from the order instead of keeping your own copy.

### Withdrawal amounts must fit the destination Asset

**Changed:** a withdrawal to another Network whose amount has more decimals than the Asset supports on that Network is rejected with `AMOUNT_EXCEEDS_ROUTE_PRECISION`.

**What to do:** round withdrawal amounts down to the Asset's decimals on the destination Network.

### API key labels

**Clarified, not a wire change:** the contract now states that API key labels cannot contain `<` or `>`; such labels were already refused.

### Enum values have no prefix on REST

**Clarified, not a wire change:** REST sends and returns enum values without their Protobuf prefix, such as a trigger `status` of `CANCELED` and a `cancelReason` of `USER_REQUEST`. ConnectRPC and the WebSocket JSON channels use the prefixed names, such as `STATUS_CANCELED` and `TRIGGER_CANCEL_REASON_USER_REQUEST`. A trigger event read over REST and the same event on the trigger events channel carry its values in these two forms.

**What to do:** map between the forms by the enum's prefix when one client reads both REST and a WebSocket channel. See [Enum values](https://testnet.polyester.com/docs/developer-docs/rest/getting-started#enum-values).

### Account selection and balance scale

**Clarified, not a wire change:** the contract now states these rules.

- Without `subaccountId`, a Subaccount-scoped API key acts on its bound Subaccount, and every other caller on its Main Account.
- Balance amounts use a fixed 18-decimal scale. An Asset's `quantity_scale` applies to order quantities and the fields that name it, not to balances.

**What to do:** check your account selection and balance parsing against these rules.

## Errors and refusals

See [Error codes](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes) and [Error responses](https://testnet.polyester.com/docs/developer-docs/error-handling/error-responses).

### Codes to handle

**Added:** the order error catalog grows from 70 to 195 codes. Each new one replaces a generic error for a specific case. These are the codes bots meet most; `ORDER_ALREADY_TERMINAL`, `TRIGGER_PRICE_INVALID`, `MAX_SLIPPAGE_INVALID`, `MARKET_HALTED`, `PAIR_DISABLED` and `EMPTY_BOOK` existed before and now also cover the cases described here:

- **`ORDER_CREATE_OUTCOME_UNKNOWN`**\
  A create was not confirmed in time and may have been accepted. Read the order by its client order ID before resending.
- **`ORDER_CANCEL_OUTCOME_UNKNOWN`, `ORDER_MODIFY_OUTCOME_UNKNOWN`**\
  A cancel or modify was not confirmed in time and may still apply. Read the order, then retry with the same request ID if needed.
- **`ORDER_ALREADY_TERMINAL`**\
  A cancel names an order that has already ended, for example on a repeated cancel. Treat the order as ended; do not retry.
- **`TRIGGER_CREATE_OUTCOME_UNKNOWN`, `TRIGGER_CONTROL_OUTCOME_UNKNOWN`**\
  A trigger create or change was not confirmed in time and may have applied. Read your triggers (by client trigger ID after a create) before retrying.
- **`TRIGGER_PRICE_INVALID`**\
  A take-profit or stop-loss would sit at or past its order's entry price. Move the leg to the correct side of the entry.
- **`MAX_SLIPPAGE_INVALID`**\
  A 10,000 bps cap on a market order or a trailing stop that sells. Use a cap of 1 to 9,999 bps.
- **`MARKET_HALTED`**\
  The Pair is halted; only cancels are accepted. Wait for the Pair to resume.
- **`PAIR_DISABLED`**\
  The Pair is disabled. Stop quoting the Pair.
- **`EMPTY_BOOK`, `MARKET_QUOTE_STALE`, `MARKET_QUOTE_UNHEALTHY`**\
  A market order has no usable reference price. Retry later, or send a limit order.
- **`PAIR_BACKLOGGED`, `SETTLEMENT_BACKLOGGED`**\
  The Pair is busy with your earlier requests, or too many of your filled or ended orders are still settling. Retry after a short backoff.
- **`ORDER_REPAIRING`, `ACCOUNT_REPAIRING`**\
  An order, or the account's new orders, are paused while balances are repaired; cancels work. Retry later.
- **`ACCOUNT_ORDERS_SUSPENDED`**\
  New orders and changes are suspended until a balance issue resolves. Retry later; cancels work.
- **`MAX_QTY`, `MAX_NOTIONAL`**\
  Above the Pair's maximum quantity or notional. Reduce the order.
- **`MAX_OPEN_ORDERS_PER_PAIR`, `MAX_OPEN_ORDERS_TOTAL`**\
  The account has reached its maximum number of resting orders on the Pair, or across all Pairs. IOC, FOK and market orders never count. Cancel a resting order first.
- **`MARKET_TRIGGER_LIMIT_EXCEEDED`, `MARKET_TWAP_LIMIT_EXCEEDED`, `MARKET_LADDER_LIMIT_EXCEEDED`**\
  The account would pass its trigger, TWAP or ladder limit on the Pair. Cancel a trigger or wait for one to end.
- **`TRIGGER_BUY_REQUIRES_LIMIT`**\
  A buy trailing stop has no `limit_price_ticks`. Set the maximum price.
- **`LADDER_RANGE_TOO_NARROW`**\
  A ladder's price range cannot fit its levels. Widen the range or reduce the levels.
- **`ATTACHED_EXIT_WORKING`**\
  An attached leg's exit is in progress. Retry the edit once the exit ends; removal always works.
- **`INVALID_SIDE`, `INVALID_ORDER_TYPE`, `INVALID_TIME_IN_FORCE`**\
  A side, order type or time in force is unknown or not accepted. Fix the request.

Over SBE, a cancel-all naming more than 100 Pairs now returns `CANCEL_SCOPE_TOO_LARGE`. Over REST and ConnectRPC it stays a request validation error (REST `400` `VALIDATION_ERROR`, ConnectRPC `InvalidArgument`), as does a dead-man switch timeout that is not 0 or 10 to 120 seconds.

The SBE `RejectCode` uses the same numbers as the order error codes. Market-order pricing errors are now distinct (no price, empty book, stale quote, unhealthy quote), so match on the code, never on the message text.

A REST rejection of a wrong-side take-profit (abridged; `type`, `title` and `detail` omitted):

```json
{
	"status": 400,
	"code": "TRIGGER_PRICE_INVALID"
}
```

### Removed code

**Removed:** `TRIGGER_CANCEL_REJECTED` (44). Those cases now return `TRIGGER_STATUS_INVALID` or `TRIGGER_NOT_MODIFIABLE`.

### New trigger failure reasons

**Added:** `TriggerFailureReason` adds 57 reasons: 56 that a failed trigger can report, such as `INDEX_PRICE_UNAVAILABLE`, `LADDER_RANGE_TOO_NARROW`, `MARKET_QUOTE_STALE` and `CHILD_ORDER_INCONSISTENT`, and `SELF_TRADE_PREVENTED`, which is only a fire refusal reason. A trigger that failed for one of them says why instead of reporting an internal error.

### TWAP schedule errors name the field

**Changed:** creating a TWAP with an invalid schedule returns a specific code instead of a generic validation error, the same over REST and ConnectRPC:

- **`TWAP_DURATION_INVALID`**\
  `durationMs` is below 1000 (1 second).
- **`TWAP_INTERVAL_INVALID`**\
  `sliceIntervalMs` is below 100 milliseconds or longer than `durationMs`.

When both fields are invalid, the duration is reported. REST answers `400`; ConnectRPC answers `InvalidArgument` with the code in the error detail.

**What to do:** match on these codes, and fix the field the code names.

### New HTTP status for unknown outcomes

**Changed:** when Polyester cannot confirm in time whether a create, cancel or modify took effect, REST returns **504** with `ORDER_CREATE_OUTCOME_UNKNOWN`, `ORDER_CANCEL_OUTCOME_UNKNOWN` or `ORDER_MODIFY_OUTCOME_UNKNOWN`, and a trigger create or change returns **504** with `TRIGGER_CREATE_OUTCOME_UNKNOWN` or `TRIGGER_CONTROL_OUTCOME_UNKNOWN`. Previously such timeouts returned 503.

**What to do:** on a 504, read the order before retrying: by its client order ID after a create, or with [`GET /v1/spot/orders/{order_id}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/orders/order_id) after a cancel or modify. Do not resend a create automatically; a resent create with the same client order ID is rejected as a duplicate, and one with a new ID can place a second order. Retry a modify only with the same `requestId`. Repeat a batch create with the same `requestId` and payload within 15 minutes to learn each item's result.

Over ConnectRPC, a deadline-exceeded answer to [`ModifyOrder`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/ModifyOrder) with `ERROR_CODE_ORDER_MODIFY_OUTCOME_UNKNOWN` means the modify may still apply. Call [`GetOrder`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetOrder) and compare the order's version before deciding to resend.

### Open orders while an order is unavailable

**Changed:** [`GET /v1/orders/open`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/open), [`GetOpenOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetOpenOrders) and order history fail with a retryable `UNAVAILABLE` (HTTP 503) while the state of an order in scope is temporarily unavailable. They no longer omit the order or return a stale state.

**What to do:** retry with backoff. Do not treat a failed read as "no open orders".

### Oversized write bodies answer 413

**Changed:** a body over the size limit of an order write route, such as `POST /v1/orders` or `POST /v1/orders/batch`, answers `413` with `REQUEST_TOO_LARGE` for session callers too. It was `400` `BAD_REQUEST` for them; API-key callers, and trigger write routes, already got `413`. The body has the usual problem shape.

**What to do:** treat `413` as "too big", not "malformed": send a smaller body or split the batch.

### Trigger conflicts answer 409

**Changed:** a REST trigger write refused with `CONFLICT_DUPLICATE_CLIENT_TRIGGER_ID` or `CONFLICT_IDEMPOTENCY_KEY_REUSE` answers `409`. It used to answer `502`.

**What to do:** treat `409` on a trigger create as a conflict, not a server failure, and branch on `code`.

### The problem type is the same for every code

**Changed:** every REST problem body now builds `type` from `code` with one rule: the problem base URI followed by the code in lowercase, with hyphens for underscores, so `UNAUTHENTICATED` ends in `/problems/unauthenticated`. Some endpoints used the code as written, so one refusal had two types; for example `AUTH_SESSION_KIND_NOT_ALLOWED` on the daily claim endpoints ended in `/problems/AUTH_SESSION_KIND_NOT_ALLOWED`. Refusals for missing or bad credentials now carry a full problem body too, instead of only `{"code":"UNAUTHENTICATED"}`. This change leaves `code` and HTTP statuses as they were; see [Session-only methods refuse API keys the same way](#session-only-methods-refuse-api-keys-the-same-way) for the codes that did change.

**What to do:** branch on `code`. If you compare `type`, build it from `code` with the rule above. Decode `401` answers as problem bodies (`application/problem+json`).

A request without credentials (abridged; `type` and `detail` omitted):

```json
{
	"title": "Unauthorized",
	"status": 401,
	"code": "UNAUTHENTICATED"
}
```

### Session-only methods refuse API keys the same way

**Changed:** every method that accepts only an interactive session now refuses an API key with HTTP 403, `AUTH_SESSION_KIND_NOT_ALLOWED` and the detail `interactive session required`. Before, the wallet trading withdrawal ([`POST /v1/chain/trading-withdraws:wallet`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/chain/trading-withdraws:wallet)) and the portfolio equity reads ([`GetPortfolioEquitySnapshot`](https://testnet.polyester.com/docs/api-docs/connect/ledger.read.v1.LedgerReadService/GetPortfolioEquitySnapshot), [`GetPortfolioEquityHistorySeries`](https://testnet.polyester.com/docs/api-docs/connect/ledger.read.v1.LedgerReadService/GetPortfolioEquityHistorySeries)) answered `PERMISSION_DENIED` with their own error detail. Over ConnectRPC the refusal is `permission_denied` with an `auth.v1.AuthErrorDetail`.

**What to do:** match `AUTH_SESSION_KIND_NOT_ALLOWED` instead of `PERMISSION_DENIED` for these refusals, and call these methods with a session token.

### Trigger events of a trigger you cannot read answer 404

**Changed:** [`GET /v1/triggers/{trigger_id}/events`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/triggers/trigger_id/events) answers `404` `NOT_FOUND`, as `GET /v1/triggers/{trigger_id}` does, when no trigger has the ID, it belongs to another account, or it is outside the selected `subaccountId`. It used to answer `200` with an empty list. A trigger you can read with no events yet still answers an empty list.

**What to do:** treat the `404` as a wrong ID or the wrong Subaccount. Like the trigger read, the events of a trigger created moments ago can answer a retryable `503`; retry it.

### Off-tick trigger modifies answer PRICE_TICK_SIZE

**Changed:** [`PUT /v1/triggers/{trigger_id}`](https://testnet.polyester.com/docs/api-docs/rest/PUT/v1/triggers/trigger_id) with a `triggerPrice` or `activationPrice` off the Pair's tick size answers `400` `PRICE_TICK_SIZE` with a `tick_size` violation naming `triggerPrice` or `activationPrice`, whether or not the request names `symbol`. `symbol` is now optional on a trigger modify; it used to be required. The REST refusal used to carry no `violations`. REST trigger error messages now use REST field names and decimal prices. The trigger keeps its previous values.

**What to do:** branch on `code`, then on `violations[].fieldPath` and `ruleId`. Do not parse the message.

A price of `70000.005` on a `0.01` price grid:

```json
{
	"status": 400,
	"code": "PRICE_TICK_SIZE",
	"violations": [{ "fieldPath": "triggerPrice", "ruleId": "tick_size", "message": "<text>" }]
}
```

### Retryable answer right after a permission grant

**Changed:** a policy change can take a moment to take effect everywhere. A request that a permission you just granted to an API key allows, sent in that moment, is no longer refused with a permission error. Orders and triggers answer `UNAVAILABLE`, and withdrawals and internal transfers answer `SERVICE_UNAVAILABLE`, as a retryable `Unavailable` (REST `503`) with the message "policy is not mirrored yet; retry". Nothing is placed, reserved or moved. A refusal the current policy confirms is still a permission error.

**What to do:** retry these answers with backoff and the same idempotency identity. Keep treating a permission error as final. See [Right after a grant](https://testnet.polyester.com/docs/developer-docs/account/api-key-policies#right-after-a-grant).

## ConnectRPC contract changes

ConnectRPC methods carry the same changes as REST. These are the Protobuf names.

**What to do:** regenerate your types from the published contract, and handle new enum values in every `switch`. A value you do not recognize should fall through to a safe default.

### Orders

- **[`CancelAllOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllOrders)**\
  The response adds `cancelled_orders` (`order_id`, `client_order_id`; up to 200, oldest first), `total_cancelled`, `truncated` and `listing_status`.
- **[`CancelAllAfter`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllAfter)**\
  The response adds `effective_scope`: the account, Pair and side the armed dead-man switch covers.
- **[`BatchReplaceOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchReplaceOrders)**\
  Each item amends in place or replaces, as `ModifyOrder` does by default. `action_taken = AMENDED` now means the order was amended under `old_order_id` and kept its queue priority; it used to mean the remainder was canceled with no successor. `replacement_order_id` is set only for `REPLACED` items.
- **`orders.v1.Order.version`**\
  Can be `0` for a `PENDING` order, and covers execution state only; `fee_total` and requested details can differ at the same version.
- **`orders.v1.Order`**\
  Adds `avg_price_e18`, `cum_notional` and `fee_total`.
- **`orders.v1.TrailingStopPolicy.limit_price_ticks`**\
  New. Required on a trailing leg of a sell order, and returned by order reads for that leg.
- **`orders.v1.Order.terminal_reason_code`, `orders.v1.Order.terminal_reason`**\
  Removed. `reject_reason`, an `orders.v1.ErrorCode` set only on a `REJECTED` order, and `cancel_reason`, an `orders.v1.CancelReason` set only on a `CANCELED` order, replace them.
- **`orders.v1.CancelReason`**\
  New. Values include `CANCEL_REASON_USER_REQUEST`, `CANCEL_REASON_IOC_REMAINDER`, `CANCEL_REASON_STP_EXPIRE_MAKER`, `CANCEL_REASON_REPLACED`, `CANCEL_REASON_ATTACHED_EXIT_FIRED` (an attached exit fired while the order still rested, so its remainder was canceled) and `CANCEL_REASON_MARKET_HALTED` (the Pair was halted).
- **`orders.v1.ErrorCode`**\
  Adds `ERROR_CODE_SELF_TRADE_PREVENTED`, the code of the self-trade fire refusal below; no request is answered with it.
- **[`ModifyOrder`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/ModifyOrder)**\
  Moving an attached exit of an order with a fill to a trigger price, trailing distance or activation price the market has already reached returns `ERROR_CODE_TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (302).

### Triggers

- **[`CreateTrigger`](https://testnet.polyester.com/docs/api-docs/connect/triggers.v1.TriggersService/CreateTrigger)**\
  `client_trigger_id` is limited to 36 bytes. An invalid TWAP schedule returns `ERROR_CODE_TWAP_DURATION_INVALID` or `ERROR_CODE_TWAP_INTERVAL_INVALID`. A trailing stop whose activation price is already reached ends `FAILED` with `TRIGGER_FAILURE_REASON_TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (244).
- **[`ModifyTrigger`](https://testnet.polyester.com/docs/api-docs/connect/triggers.v1.TriggersService/ModifyTrigger)**\
  Setting `limit_price_ticks` is rejected with `TRIGGER_CHILD_PRICE_NOT_MODIFIABLE`. An off-tick trigger or activation price returns `ERROR_CODE_PRICE_TICK_SIZE` with a violation on `trigger_price_ticks` or `activation_price_ticks`, with or without `symbol_id`. A change whose child would be below the Pair's minimums returns `ERROR_CODE_MIN_QTY`, `ERROR_CODE_QTY_STEP_SIZE` or `ERROR_CODE_MIN_NOTIONAL`. A trigger price, trailing distance or activation price the market has already reached returns `ERROR_CODE_TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (302).
- **[`ListTriggerEvents`](https://testnet.polyester.com/docs/api-docs/connect/triggers.v1.TriggersService/ListTriggerEvents)**\
  Answers `NotFound` for a trigger the caller cannot read, as [`GetTrigger`](https://testnet.polyester.com/docs/api-docs/connect/triggers.v1.TriggersService/GetTrigger) does.
- **`triggers.v1.Trigger.completed_at`**\
  Set for every ended trigger, including canceled and failed ones.
- **`triggers.v1.Trigger.version`**\
  New. A per-trigger state version: keep the copy of each trigger with the highest version.
- **`triggers.v1.TriggerEventType`**\
  Adds `EVENT_ARMED`, when an attached leg starts protecting its order's fills, and `EVENT_FIRE_REFUSED`, a fire that was refused while the trigger stayed live, with its reason in the new `TriggerEvent.fire_refusal_reason`. Read the trigger's `status` rather than inferring it from the last event.
- **`triggers.v1.TriggerCancelReason`**\
  Adds `TRIGGER_CANCEL_REASON_SELF_TRADE_PREVENTION`, `TRIGGER_CANCEL_REASON_ACCOUNT_REPAIR`, `TRIGGER_CANCEL_REASON_MARKET_HALTED`, and `TRIGGER_CANCEL_REASON_PAIR_DRAIN_TRIGGER`, `TRIGGER_CANCEL_REASON_PAIR_DRAIN_TWAP`, `TRIGGER_CANCEL_REASON_PAIR_DRAIN_LADDER` and `TRIGGER_CANCEL_REASON_PAIR_DRAIN_ATTACHED_LEG` for a disabled Pair.
- **`triggers.v1.TriggerFailureReason`**\
  Adds `TRIGGER_FAILURE_REASON_SELF_TRADE_PREVENTED`, a fire refusal reason: self-trade prevention expired an attached exit and the leg stays armed.

### Market data

- **[`GetTrades`](https://testnet.polyester.com/docs/api-docs/connect/marketdata.v1.MarketDataService/GetTrades)**\
  The request adds `after_trade_id`; the response adds `complete_through_trade_id`, `next_after_trade_id` and `epoch_ends`.
- **`marketdata.v1.Timeframe`**\
  Adds `MIN_3`, `HOUR_2`, `HOUR_6`, `HOUR_8` and `DAY_3`.
- **`marketdata.v1.PairStatus`**\
  Adds `PAIR_STATUS_HALTED`: only cancels are accepted; new orders get `MARKET_HALTED`.

### Accounts, funding and fees

- **[`GetSubaccount`](https://testnet.polyester.com/docs/api-docs/connect/auth.v1.SubaccountViewService/GetSubaccount)**\
  `include_balances` follows the access rules of `GetBalances`. See [Subaccount balances with API keys](#subaccount-balances-with-api-keys).
- **`auth.v1.SubaccountPermission`, `auth.v1.PolicyAction`**\
  Add auto-withdraw and swap permissions.
- **`auth.v1` Subaccount member changes**\
  Inviting, changing the role of, or removing a Subaccount's owner returns `AUTH_SUBACCOUNT_OWNER_ROLE_LOCKED` (`FailedPrecondition`, REST `412`). Removing the owner used to return `AUTH_INVALID_REQUEST`.
- **`auth.v1.AuthErrorCode`**\
  Adds credential refusal reasons such as `AUTH_SIGNATURE_INVALID`, `AUTH_TIMESTAMP_SKEW`, `AUTH_MISSING_HEADERS` and `AUTH_KEY_EXPIRED`, in the `auth.v1.AuthErrorDetail` of an `unauthenticated` error.
- **`chain.deposit.v1.DepositAddressService`**\
  A Network whose deposits are suspended returns `FailedPrecondition`; an unfiltered list omits it.
- **`ledger.read.v1.LedgerReadService.ListHolds`**\
  Removed, with `ListHoldsRequest`, `HoldRow` and `ListHoldsResponse`; read `reserved` from [`GetBalances`](https://testnet.polyester.com/docs/api-docs/connect/ledger.read.v1.LedgerReadService/GetBalances).
- **[`GetSpotFeeRates`](https://testnet.polyester.com/docs/api-docs/connect/fees.v1.FeeService/GetSpotFeeRates)**\
  `SpotFeeRate` adds `reserve_fee_rate_percent`, `post_only_reserve_fee_rate_percent`, `breakdown` (`SpotFeeRateBreakdown`) and `next_change_at`.
- **Orders, triggers, withdrawals, internal transfers**\
  A request a just-granted API key permission allows can return a retryable `Unavailable` instead of `PermissionDenied`.

## Trading WebSocket

The trading WebSocket offers two encodings: **SBE** at `/ws/trade.sbe` and **Protobuf** at `/ws/trade.proto`. It has no JSON encoding; a text frame closes the connection. JSON order updates are available on the [private WebSocket](#private-order-updates). The paths and the Ed25519 signed upgrade are unchanged.

### SBE: new schema version

**Changed:** the SBE schema (ID `7`) moves from version `12` to version `36`. The server accepts only its exact schema version and answers any other with a `CommandReject` ("unexpected SBE schema version"). Several optional fields and enums now use `0` as their null value.

**What to do:** regenerate your codecs from the published SBE schema, version `36`, set version `36` in every message header, and send `0` for absent optional IDs. Version `12` messages stop working at the upgrade. See [SBE schema version](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/getting-started#schema-version).

A cancel by client order ID leaves `orderId` as `0`:

## Before

```text
header:  templateId=3 schemaId=7 version=12
```

## After (0.92.5)

```text
header:  templateId=3 schemaId=7 version=36
fields:  orderId=0 symbolId=1 clientOrderId="mm-bid-17"
```

### Batch commands

**Added:** both encodings add batch commands.

- **Batch create orders**\
  SBE template 7, Protobuf `ClientFrame.batch_create_orders`. Up to 20 items, any Pairs.
- **Batch replace orders**\
  SBE template 8, Protobuf `ClientFrame.batch_replace_orders`. Up to 50 items, one Pair.
- **Batch cancel orders**\
  SBE template 9, Protobuf `ClientFrame.batch_cancel_orders`. Up to 50 items.

Replies are `BatchCreateAck` (26), `BatchReplaceAck` (27) and `BatchCancelAck` (28) in SBE, and the matching batch responses in Protobuf `AckResponse`. Each reply carries a result per item. The batch `correlationId` (SBE) or `request_id` (Protobuf) is the idempotency key.

**What to do:** optional. Batch commands replace a burst of single commands. Each item counts against your rate limit. If a batch is answered with `UNAVAILABLE`, `ENGINE_BUSY` (Protobuf) or `ORDER_CREATE_OUTCOME_UNKNOWN` (batch create), or no answer arrives, it may have partly applied: resend it with the same key. See [Order entry](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/order-entry) and the [Protobuf trade WebSocket](https://testnet.polyester.com/docs/developer-docs/connectrpc/protobuf-trade-websocket).

### Cancel-all reply lists canceled orders

**Changed:** the cancel-all `CommandAck` (SBE) adds `cancelledOrdersTruncated`, `listingStatus` and a `cancelledOrders` group with up to 200 `orderId` and `clientOrderId` entries, oldest first. The Protobuf reply carries the same list as REST.

**What to do:** use it to clear your local book, as with REST.

### SBE order events name the reason

**Changed:** `OrderEvent` (template 25) drops `reasonCode`. A `REJECTED` order carries `rejectReason`, a `RejectCode` with the same numbers as the order error codes, and a `CANCELED` order carries `cancelReason`, a new `CancelReason` enum with the numbers of ConnectRPC `orders.v1.CancelReason`, such as `USER_REQUEST` (1), `MARKET_HALTED` (17) and `ATTACHED_EXIT_FIRED` (18). Each is `0` otherwise. `reasonCode` used other numbers that could collide with the error codes.

**What to do:** read `rejectReason` or `cancelReason` according to the order's `status`, instead of `reasonCode`.

### Private events gap and MarketResync

**Changed:** if your session subscribed to private order or execution events and the server detects that some of a Pair's events may have been missed, it closes the connection with code `1012`.

**Added:** SBE adds a `marketResync` choice to `Subscribe`. With it, the server instead sends `MarketResync` (template `29`) with the Pair's `symbolId` and keeps the connection open; events of other Pairs are unaffected.

**What to do:** without `marketResync`, reconnect, then reload open orders over REST or ConnectRPC before applying new events. With it, reconcile that Pair's orders and executions over REST or ConnectRPC on each `MarketResync`, then apply its later events.

## Private order updates

### Order updates moved to /v1/ws/private

**Removed:** order updates no longer arrive on the realtime connection at `/connection/websocket`. These channels are no longer published. Subscription tokens are no longer issued for the `:proto` and batch-replace channels; a token for `private:spot:orders:<accountPublicId>` now authorizes a private WebSocket subscription instead (see step 3 below):

- `private:spot:orders:<accountPublicId>`
- `private:spot:orders:<accountPublicId>:proto`
- `private:spot:orders:batch-replace:<accountPublicId>`
- `private:spot:orders:batch-replace:<accountPublicId>:proto`

**Added:** order updates now come from the private WebSocket at `/v1/ws/private`. It sends a snapshot of your orders, then each change as a unit with a resumable cursor. Each order is a complete `orders.v1.Order`, including attached take-profit and stop-loss, and units also carry events such as fills, cancels, rejects and command outcomes.

Choose the encoding with the WebSocket subprotocol:

- **`polyester.v1.json`**\
  Text frames, canonical JSON, lowerCamelCase fields, 64-bit integers as strings.
- **`polyester.v1.proto`**\
  Binary Protobuf `privatews.v1` messages; the default.

For batch replace progress, use [`GetBatchReplaceStatus`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetBatchReplaceStatus) together with the order versions on this socket. The full protocol is described in [Private order updates](https://testnet.polyester.com/docs/developer-docs/shared-concepts/websocket-session-model#private-order-updates).

**What to do:**

1. Get a connect token from `GET /v1/rt/token`, as before.
2. Open `/v1/ws/private` with your subprotocol, and send `auth` with the token within 5 seconds.
3. For each account, get a subscribe token from `GET /v1/rt/subscribe?channel=private:spot:orders:<account>`, where `<account>` is the account's public ID in base58, as REST shows it. Send `subscribe` with channel `ORDERS`.
4. Install the snapshot, then apply each unit in `seq` order. Store the latest cursor. `ready` tells you the stream reflects every order reply you received before subscribing.
5. Refresh the connect token with `auth` and each subscription with `subscribeRefresh` before they expire. The server warns you with `tokenExpiring` 30 seconds ahead and `subscribeExpiring` 15 seconds ahead.
6. On a `seq` gap, subscribe again. To resume after a reconnect, send `subscribe` with your last cursor. If the server answers `resyncRequired`, it sends a fresh snapshot.

Expect a `heartbeat` every second, and treat the connection as stale after 3 seconds without one. One connection holds up to 64 subscriptions.

JSON frames for one Subaccount:

```json
{"auth":{"token":"<token from /v1/rt/token>"}}
{"subscribe":{"id":"r1","channel":"ORDERS","account":"1234567890123","token":"<token from /v1/rt/subscribe>"}}
```

The server answers with frames in this order (abridged):

```text
{"sub":"s1","subscribed":{"id":"r1","account":"1234567890123","expiresTsSec":"1793361600"}}
{"sub":"s1","seq":"1","snapshotBegin":{"epoch":"1"}}
{"sub":"s1","seq":"2","snapshotPage":{"orders":[ ...up to 256 orders... ]}}
{"sub":"s1","seq":"3","snapshotEnd":{"cursor":"<cursor>","count":2, ...}}
{"sub":"s1","seq":"4","ready":{}}
{"sub":"s1","seq":"5","unit":{"prevCursor":"<cursor>","nextCursor":"<next cursor>","orders":[ ... ],"events":[ ... ]}}
```

Close codes:

- **`1001`** · Server draining\
  Reconnect and resubscribe with cursors.
- **`4400`** · Malformed frame, unknown operation or wrong encoding\
  Fix the client.
- **`4401`** · Connect token expired without a refresh\
  Reconnect with a new token.
- **`4403`** · Bad or missing connect token\
  Reconnect with a valid token.
- **`4408`** · Client did not read frames fast enough\
  Reconnect and resubscribe with cursors.

Back off reconnects from 0.5 seconds, doubling up to 10 seconds, with jitter.

### Orders whose state is unknown

**Added:** when the current state of some of your orders is temporarily unknown, the private WebSocket says so instead of letting their earlier rows look current. It sends `unavailable` with reason `ORDER_UNAVAILABLE` and `orderIds`, the public IDs of those orders in ascending order. The rest of the account's view stays current and units keep arriving. `available` follows once every order shows its current state again. Every other reason covers the whole view, with `orderIds` empty.

**What to do:** show the named orders as stale until `available`, and do not act on their last known state. A later `unavailable` replaces the earlier reason and `orderIds`. Do not resubscribe: the subscription recovers by itself.

```json
{"sub":"s1","seq":"7","unavailable":{"reason":"ORDER_UNAVAILABLE","orderIds":["1234567890123"]}}
{"sub":"s1","seq":"9","available":{}}
```

## Event channels and market data

### Event channels carry arrays

**Changed:** four channels on the realtime connection keep their names but now publish an ordered array of events. A single event arrives as an array of one. The `:proto` variants of these channels carry the batch message named below.

- **Market trades** · [stream](https://testnet.polyester.com/docs/api-docs/streams/public-spot-market-trades)\
  JSON `public:spot:market:trades:<symbolId>`; Protobuf `marketdata.v1.MarketTradeBatch`; identity `matchId`.
- **Your trades** · [stream](https://testnet.polyester.com/docs/api-docs/streams/private-spot-trades)\
  JSON `private:spot:trades:<accountPublicId>`; Protobuf `orders.v1.UserTradeBatch`; identity `matchId`, `orderId`.
- **Ledger transfers** · [stream](https://testnet.polyester.com/docs/api-docs/streams/private-ledger-transfers)\
  JSON `private:ledger:transfers:<accountPublicId>`; Protobuf `ledger.read.v1.TransferRowBatch`; identity `transferId`, `account`, `isDebit`.
- **Trigger events** · [stream](https://testnet.polyester.com/docs/api-docs/streams/private-spot-trigger-events)\
  JSON `private:spot:triggers:events:<accountPublicId>`; Protobuf `triggers.v1.TriggerEventBatch`; identity `triggerId`, `eventType`, `childSeq`, `tsNs`.

Each ledger transfer event adds `transferId`. One publication holds at most 256 events and 64 KiB.

**What to do:** decode `{"events":[...]}` (JSON) or the batch message (Protobuf), apply events in array order, and upsert by event identity. See [Event streams](https://testnet.polyester.com/docs/developer-docs/shared-concepts/websocket-session-model#event-streams).

## Before

```json
{
	"symbol": "BTC-USDT",
	"matchId": "1099511627791",
	"side": "BUY",
	"price": "65432.1",
	"qty": "0.015",
	"executedAt": "2026-10-30T12:00:00.123Z"
}
```

## After (0.92.5)

```json
{
	"events": [
		{
			"symbol": "BTC-USDT",
			"matchId": "1099511627791",
			"side": "BUY",
			"price": "65432.1",
			"qty": "0.015",
			"executedAt": "2026-10-30T12:00:00.123Z"
		}
	]
}
```

Balances, trigger state, chain lifecycle and account channels are unchanged.

### Market-data channels

**Changed:** the existing public market-data channels (order book, candles, heatmap, market overview) keep their names and payloads. Market trades now arrive as arrays, as described above, and candles gain the new timeframes, for example `public:spot:market:candles:3m:1`. There is no SBE market-data WebSocket in this release.

## Rate limits

See [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits) for every tier.

### Per-account limits are unchanged

**Clarified:** each trading account (Main Account or Subaccount) keeps its own order placement and cancellation budgets, shared across REST, ConnectRPC and the trading WebSocket. At VIP0, order placement allows **250 per second with bursts of 500**.

### Per-IP limit is halved

**Changed:** one IP address can send at most about **10,000 order and trigger write requests per second**. This was about 20,000 per second. It applies to REST and ConnectRPC order and trigger writes; a batch counts as one request. Reads and commands on the trading WebSocket do not count against it.

**What to do:** if you run many busy Subaccounts behind one IP, keep the total under the limit, or move order entry to the trading WebSocket. The per-IP limit returns a plain HTTP `429` with no Polyester error body and no `Retry-After`; back off and retry. At 250 orders per second each, about 40 fully busy Subaccounts fit behind one IP by rate.

**Changed:** one IP address can have at most about 256 order-placing requests in flight at once; a short burst above that is delayed, then refused with `429`. This was about 2,048 in-flight order and trigger writes of any kind. Cancels, cancel-all, the dead-man switch, trigger cancels and pauses, and `DELETE` requests are no longer counted, so a client at its share can still cancel. `POST /v1/orders/preview` counts toward the per-IP budget. With slow round trips, this cap can bind before the per-second rate: spread busy Subaccounts across connections from more than one IP, or use the trading WebSocket.

### Rate-limited stops wait instead of failing

**Changed:** every order a trigger places (a stop, take-profit, trailing stop, TWAP slice, ladder level or attached exit) still spends one unit of the same placement budget as your manual orders. What changed is a fire refused for that budget: it used to end the trigger `FAILED` with `RATE_LIMITED`. Now the trigger stays armed and fires as soon as the budget allows, and only if its trigger condition still holds then; a TWAP runs its due slice. Each refused attempt appears as an `EVENT_FIRE_REFUSED` trigger event with `fireRefusalReason` `RATE_LIMITED`.

**What to do:** leave headroom in your placement budget for your stops, and do not read a refused fire as the end of the trigger.

### Rate-limit codes

**Added:** `SYSTEM_RATE_LIMITED` and `RATE_LIMIT_POLICY_UNAVAILABLE` are new, and `OVERLOADED` has a new meaning.

- **`RATE_LIMIT_EXCEEDED`** (unchanged)\
  Your account's budget is exhausted. Retry after `Retry-After`.
- **`SYSTEM_RATE_LIMITED`**\
  The venue's shared admission rate is exhausted. Back off and retry.
- **`RATE_LIMIT_POLICY_UNAVAILABLE`**\
  Your limit cannot be evaluated now; refused, not unlimited. Retry shortly.
- **`OVERLOADED`** (new meaning)\
  Too many of your order requests are still in progress: REST `503`, ConnectRPC `ResourceExhausted`, with no `Retry-After` or `RateLimit-*` headers. Retry after one completes. It used to mean a batch replace capacity limit.

### Quota details come with refusals

**Clarified:** successful replies carry no per-account quota headers or fields. A refusal for your account's quota carries the details:

- **REST and ConnectRPC:** `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` and `X-Polyester-RateLimit-Policy-Version`, plus the rate-limit detail in the error body.
- **Trading WebSocket:** the SBE `CommandReject` `rateLimit*` fields, which are null or zero for any other rejection, and the rate-limit detail of the Protobuf order error. Batch acks fill them only for an item rejected with `RATE_LIMIT_EXCEEDED`.

**What to do:** pace your bot from your published limits, and back off by `Retry-After` or `rateLimitRetryAfterNs` when refused.

## Attached take-profit and stop-loss

### Attached legs share one exit

**Changed:** the take-profit and the stop leg (stop-loss or trailing stop) attached to one order now protect one shared position, whether or not `oco` is set. The first leg to fire exits the whole remaining position, rounded down to the step size. Only one exit works at a time: if another leg's condition is met while an exit works, that exit is canceled and the other leg exits what remains. `oco` no longer changes when exits fire; it only decides whether canceling one leg's exit also ends the other legs. Previously, legs without `oco` could both fire and sell twice.

**What to do:** remove any logic that assumed independent legs.

### Legs stay on their side of the entry

**Changed:** a take-profit or stop-loss must stay on its side of the entry price: for a buy entry, the take-profit above and the stop-loss below. Creating an order, changing its price, or changing its legs so that a leg sits at or past the entry is rejected whole with `TRIGGER_PRICE_INVALID`, and nothing changes. There is no minimum distance from the entry. Trailing legs and market entries have no side rule.

**What to do:** when you move an order's price, move its legs in the same request if they would end up on the wrong side.

A buy at 100 with a take-profit at 110 and a stop-loss at 95. Modifying the entry price to 112 without moving the take-profit is rejected with `TRIGGER_PRICE_INVALID`, because the take-profit would sit below the entry.

### Trailing legs on sell orders need a maximum price

**Changed:** a trailing leg attached to a sell order buys back, and now requires `limit_price_ticks` (REST `attachedRisk.trailingStop.limitPrice`), its maximum price. Without it, the order is rejected with `TRIGGER_BUY_REQUIRES_LIMIT`. A trailing leg on a buy order must not set it; if it does, the order is rejected with `BAD_PRICE`. This breaks clients that attached trailing legs to sell orders before. A modify or batch replace that breaks either rule answers `BAD_PRICE`.

**What to do:** set the maximum price on trailing legs of sell orders, and leave it unset on buy orders.

### Editing attached legs

**Changed:** an attached trailing stop can now be changed (distance, activation, slippage cap) after its entry fills, or partly fills with the rest canceled. While a leg's exit is working, edits return `ATTACHED_EXIT_WORKING`.

On an order with a fill, a leg change that would leave its exit below the Pair's minimums is refused with `MIN_QTY`, `QTY_STEP_SIZE` or `MIN_NOTIONAL`, whether you send it with `ModifyTrigger` or `ModifyOrder`, and so is a quantity change that would leave a kept leg's exit below them.

### Canceling an exit ends its leg

**Changed:** canceling an attached leg's exit order yourself, alone or with a cancel-all or the dead-man switch, ends that leg, and with `oco` set the order's other legs too. Previously the leg stayed armed.

### A fired exit cancels the order's resting remainder

**Changed:** an attached exit never trades with the order it protects. When a leg fires while its order still rests with an unfilled remainder, Polyester first cancels that remainder, whatever the order's self-trade prevention mode, then places the exit. The order's fills stand, and it ends `CANCELED` with the new cancel reason `ATTACHED_EXIT_FIRED`; from then on it can no longer be modified (`ORDER_ALREADY_TERMINAL`).

An account at the Pair's open-order limit is not refused a resting limit exit for that limit: the canceled remainder frees its slot.

**What to do:** expect a resting order with legs to end with `ATTACHED_EXIT_FIRED` when one of its legs fires.

### Self-trade blocked legs wait

**Changed:** when self-trade prevention (`EXPIRE_TAKER` or `EXPIRE_BOTH`) expires an attached exit because it would trade with another of your resting orders, the leg stays armed. The exit ends `CANCELED` with its self-trade prevention cancel reason, and the leg's trigger records one `EVENT_FIRE_REFUSED` with the new reason `SELF_TRADE_PREVENTED`. While your blocking order is still first in line at the best price, the leg does not fire again, whatever price it watches: new trades and index price updates do not re-fire it. Once that order is gone or no longer first at the best price, the leg fires once if its trigger condition still holds.

**What to do:** read a leg's trigger status rather than treating a self-trade expired exit as the end of its protection. Treat the single `EVENT_FIRE_REFUSED` with `SELF_TRADE_PREVENTED` as the sign of a blocked leg, not a count of expired exits.

### Dust after the order ends

**Changed:** once the order has ended, a remainder below the Pair's minimums is dust, and no leg stays `RUNNING` on it. If an exit already sold everything it claimed, for example when the order's last small fill settles while that exit works, the leg whose exit ran ends `COMPLETED` and the others `CANCELED` with `OCO`. If no exit ever ran, the legs end `FAILED` with `MIN_QUANTITY` or `MIN_NOTIONAL`.

## Triggers

See [Triggers](https://testnet.polyester.com/docs/developer-docs/shared-concepts/triggers/overview).

### Trigger limits per Pair

**Added:** each account (Main Account or Subaccount) can hold at most 100 triggers on one Pair at once, including attached take-profit, stop-loss and trailing legs. Within that, at most 20 can be TWAPs and at most 5 ladders. A trigger stops counting once it ends. A create, or an order whose attached legs would pass a limit, is rejected whole with `MARKET_TRIGGER_LIMIT_EXCEEDED`, `MARKET_TWAP_LIMIT_EXCEEDED` or `MARKET_LADDER_LIMIT_EXCEEDED` (HTTP `429`, ConnectRPC `ResourceExhausted`). Across all Pairs, an account that holds its maximum number of live and recently ended triggers is refused a create with the new `TRIGGER_LIMIT_EXCEEDED`, also HTTP `429`.

**What to do:** cancel triggers you no longer need, or wait for one to end, before creating more on the same Pair.

### Trailing stops on both sides

**Added:** trailing stops now accept the buy side. A buy trailing stop follows the market's low and fires when the price rises by the trail distance. It requires `limit_price_ticks`, the maximum price, and places an IOC limit order that never pays more. A sell trailing stop keeps its market child. Slippage caps for trailing stops are 1 to 9,999 bps on either side.

**What to do:** stop expecting a `VALIDATION_ERROR` ("trailing_stop side must be SELL") for a buy. Set the maximum price on buy trailing stops; without it, the create is rejected with `TRIGGER_BUY_REQUIRES_LIMIT`. See [Trailing stop](https://testnet.polyester.com/docs/developer-docs/shared-concepts/triggers/trailing-stop).

### Triggers that would act at once are refused

**Changed:** a trigger is no longer accepted in a state where it would act the moment it is placed or changed. A stop-loss or take-profit whose trigger price the watched price has already reached when it is submitted ends `FAILED` with `TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (reason 244), and a modify that moves its trigger price onto a price already reached is refused with `TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (code 302). Trailing stops follow the same rule:

- A create whose activation price the watched price has already reached (at or above it for a sell, at or below it for a buy) ends `FAILED` with `TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (reason 244). It never activates, places no order, and releases its reserved funds.
- A modify that moves a not-yet-activated stop's activation price onto a price already reached, or changes an activated stop's distance so its trigger price is already reached, is refused with `TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (code 302), and nothing changes.
- On an order with a fill, a `ModifyOrder` that moves an attached take-profit, stop-loss or trailing stop this way is refused whole with the same code.

A trailing stop without an activation price, and clearing the activation price (`0`), still start trailing from the market at once.

**What to do:** handle the failure reason after a create and the error code on a modify. Choose a price the market has not reached. To start trailing now, leave the activation price unset or clear it instead of setting one the market has reached.

A REST modify of an unactivated sell trailing stop to an activation price the last trade already passed (abridged; `type`, `title` and `detail` omitted):

```json
{
	"status": 400,
	"code": "TRIGGER_WOULD_TRIGGER_IMMEDIATELY"
}
```

### Trigger modifies meet the Pair's minimums

**Changed:** [`ModifyTrigger`](https://testnet.polyester.com/docs/api-docs/connect/triggers.v1.TriggersService/ModifyTrigger) ([`PUT /v1/triggers/{trigger_id}`](https://testnet.polyester.com/docs/api-docs/rest/PUT/v1/triggers/trigger_id)) now applies the minimums a create applies. The child order is valued on the terms the change leaves: a market buy at its maximum price, a market sell at its protection price, and a limit child at its limit price. A change that would put the child below the Pair's minimum quantity, step size or minimum notional is refused with `MIN_QTY`, `QTY_STEP_SIZE` or `MIN_NOTIONAL` (`400`, ConnectRPC `InvalidArgument`), and the trigger keeps its previous price, cap and reserved funds. Previously such a modify was accepted, and the trigger failed with `MIN_NOTIONAL` when it fired.

**What to do:** handle these codes on a modify, and keep moved stops worth at least the Pair's minimum notional. Read the minimums from [`GET /v1/spot/config`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/config) ([`GetSpotConfig`](https://testnet.polyester.com/docs/api-docs/connect/marketdata.v1.MarketDataService/GetSpotConfig)).

### Buy stops with a market order and a slippage cap

**Added:** a stop-loss or take-profit trigger can now buy with a market child order; a buy used to require a limit child. A market child on either side now accepts a `max_slippage_ticks` or `max_slippage_bps` cap, which you can change with `ModifyTrigger`. Without a cap, the Pair's default market slippage applies. A buy accepts 1 to 10,000 bps, a sell 1 to 9,999 bps.

### Stops on the index price

**Added:** standalone stop-loss, take-profit and trailing stop triggers can set `trigger_price_source` to `INDEX_PRICE` instead of the default last trade price. The source is fixed at creation. A trigger on a Pair without an index price fails with `INDEX_PRICE_UNAVAILABLE`. If the index goes stale, the trigger waits instead of firing. `MARK_PRICE` is not supported.

Attached take-profit, stop-loss and trailing legs accept the same `trigger_price_source`. An order whose legs use `INDEX_PRICE` on a Pair without an index price is rejected with `INDEX_PRICE_UNAVAILABLE` before any fill. A modify that adds such a leg is refused with `TRIGGER_PRICE_SOURCE_UNSUPPORTED`, and the order keeps its legs.

A REST stop-loss ([`POST /v1/triggers`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/triggers)) that sells 0.5 BTC at market when the index price reaches 60,000:

```json
{
	"trigger": {
		"symbol": "BTC-USDT",
		"qty": "0.5",
		"clientTriggerId": "sl-btc-1",
		"stopLoss": {
			"side": "SELL",
			"triggerPrice": "60000",
			"triggerPriceSource": "INDEX_PRICE",
			"child": { "marketIoc": { "maxSlippageBps": 100 } }
		}
	}
}
```

### Cancel-all and the dead-man switch

**Clarified:** cancel-all and the dead-man switch still cancel orders only. They cancel open orders, including orders placed by triggers (TWAP slices, ladder levels and attached exits), and standalone triggers stay active, except a ladder, which ends once a cancel-all leaves none of its levels open. A canceled attached exit now ends its leg, as described in [Canceling an exit ends its leg](#canceling-an-exit-ends-its-leg). The dead-man switch timeout must be `0` (off) or 10 to 120 seconds, with optional Pair and side filters, and its reply reports `effectiveScope`, the account, Pair and side it covers. Cancel-all accepts every Pair status, including disabled and halted Pairs.

**What to do:** cancel triggers separately when you need a full stop. Send dead-man switch heartbeats well before the deadline. See [Cancel all orders](https://testnet.polyester.com/docs/developer-docs/shared-concepts/cancel-all-orders).

### How a ladder ends

**Changed:** a ladder ends once none of its levels remains open:

- **Any level filled**\
  `COMPLETED`, however the other levels ended.
- **Nothing filled, and you canceled any of its levels** (one by one, with a cancel-all, or by the dead-man switch)\
  `CANCELED` with `USER_REQUEST`.
- **Nothing filled, and its levels were canceled only by self-trade prevention or an account repair**\
  `COMPLETED`, with no cancel reason.
- **The book refused every level**\
  `FAILED`, with the first refusal's reason.

Canceling the ladder itself ends it `CANCELED` at once and cancels its open levels.

**Added:** Polyester can cancel a trigger's child orders while it repairs your account's balances; each such order's own record says `ACCOUNT_REPAIR`. An attached leg whose working exit is canceled this way stays active and protects the position again.

**What to do:** read each level's own status and cancel reason from its order, not from the ladder.

## Orders and idempotency

### Client order IDs and retry windows

**Changed:** a client order ID is now reserved for 60 seconds after its order ends, previously 12 hours. The windows at 0.92.5:

- **Reuse a client order ID after its order ends**\
  60 seconds, previously 12 hours.
- **Identical modify retry recognized (`requestId`)**\
  60 seconds.
- **Identical batch replace retry recognized (`requestId`)**\
  60 seconds from acceptance.
- **Identical batch create retry recognized (`requestId`)**\
  15 minutes.
- **Client trigger ID recognized after its trigger ends**\
  60 seconds.

A client order ID stays reserved for at least 60 seconds after its order ends, and a reuse within that window returns `CONFLICT_DUPLICATE_CLIENT_ORDER_ID`. After the window, a new order with that ID can be accepted. A single create is never retried for you: never resend an unknown create with a new client order ID.

A client trigger ID protects a trigger create while the trigger is active and for 60 seconds after it ends. After that, a create with the same ID creates a new trigger.

**What to do:** generate a fresh client order ID per order, or wait at least 60 seconds before reusing one. Retry a modify or batch replace with the same request ID within 60 seconds. To learn the outcome of an earlier trigger create, list your triggers with [`GET /v1/triggers`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/triggers) and compare `clientTriggerId` instead of retrying the create. See [Client trigger IDs](https://testnet.polyester.com/docs/developer-docs/shared-concepts/client-order-ids#client-trigger-ids).

### Order preview

**Changed:** order preview still reports whether an order would be admitted, without placing it, and does not return fees. Over REST, a quantity or price with a nonzero digit beyond its scale is now a completed preview: `200` with `"admissible": false` and `QTY_STEP_SIZE` or `PRICE_TICK_SIZE` in `rejection`, not a `400`.

**What to do:** read `admissible` and `rejection` for too-precise values. See [Too-precise order decimals](#too-precise-order-decimals-answer-step-and-tick-codes).

### Self-trade prevention across Subaccounts

**Changed:** self-trade prevention covers the Main Account and all its Subaccounts, so two Subaccounts of one owner never trade with each other. The incoming order's self-trade prevention mode decides which side is canceled.

**What to do:** if you deliberately trade between your own Subaccounts, stop: those orders expire instead of matching. See [Self-trade prevention](https://testnet.polyester.com/docs/developer-docs/shared-concepts/self-trade-prevention).

### Price and quantity scales

**Clarified:** the scales are unchanged: prices stay at 9 decimals and quantities at each Pair's scale. REST order writes now accept zeros beyond those scales, and answer a nonzero digit beyond them with a step or tick code; see [Too-precise order decimals](#too-precise-order-decimals-answer-step-and-tick-codes).

- **REST**\
  Price: a decimal string, up to 9 decimals. Quantity: a decimal string at the Pair's quantity scale.
- **ConnectRPC and trading WebSocket**\
  Price: `*_ticks` or `priceTicks`, an integer × 10^9. Quantity: `*_scaled` or `*QtyScaled`, an integer × 10^scale.

Balance amounts are separate: ConnectRPC carries them at a fixed 18-decimal scale whatever the Asset's `quantity_scale`, and REST as decimal strings.

**What to do:** read each Pair's scales from [`GET /v1/spot/config`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/config). If your SDK sends prices at 6 decimals, upgrade it; see [SDK versions](#sdk-versions).

On a Pair with a base quantity scale of 8, a price of 65,432.10 is `priceTicks = 65432100000000`, and 0.015 of the base Asset is `baseQtyScaled = 1500000`. Over REST they are `"65432.1"` and `"0.015"`.

## Balances, holds and fees

### Subaccount balances with API keys

**Changed:** the REST Subaccount read ([`GET /v1/auth/subaccounts/{subaccountId}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/auth/subaccounts/subaccount_id)) takes camelCase query flags (`includeApiKeys`, `includeMembers`, `includeInvites`, `includePolicy`, `includeBalances`), and each one defaults to `false`. Before, the flags were snake_case (`include_balances` and so on) and defaulted to `true`, so a request without flags returned every expansion.

The balances expansion follows the access rules of `GetBalances`: the caller needs `READ_BALANCES` on the Subaccount, and a root-scoped API key cannot read a Subaccount's balances. A caller without that access gets `403` `AUTH_SUBACCOUNT_ACCESS_DENIED` for the whole request. When balances cannot be read right now, the request answers `503` `AUTH_TEMPORARILY_UNAVAILABLE`; retry it.

**What to do:** send each expansion you need, for example `?includeBalances=true`. Read a Subaccount's balances with a key bound to that Subaccount, or with a session. See [Subaccounts and roles](https://testnet.polyester.com/docs/developer-docs/account/subaccounts-and-roles).

### The holds list is removed

**Removed:** `ListHolds` on ConnectRPC, which always answered `UNIMPLEMENTED`, is removed from the contract, and the unused `GET /v1/holds` route is gone. Individual holds were never readable through the public API.

**What to do:** read each Asset's `reserved` from [`GET /v1/balances`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/balances) for the amount held in Trading, and your order, trigger and movement records for the cause. Pass `subaccountId` to read a Subaccount. See [Balances and holds](https://testnet.polyester.com/docs/developer-docs/account/balances-and-holds#available-and-reserved).

One row of `GET /v1/balances` (abridged; revisions and `funding` omitted):

```json
{
	"balances": [
		{ "symbol": "USDT", "trading": "1000", "reserved": "250.225", "available": "749.775" }
	]
}
```

### Fee rates add reserve rates and a breakdown

**Added:** each row of [`GET /v1/spot/fee-rates`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/fee-rates) ([`GetSpotFeeRates`](https://testnet.polyester.com/docs/api-docs/connect/fees.v1.FeeService/GetSpotFeeRates)) adds:

- `reserveFeeRatePercent` and `postOnlyReserveFeeRatePercent`: the rate a new order reserves its fee at when the fee is paid in quote. It is the highest rate of every VIP tier under the Pair's fee schedule, so it can be above your effective rate.
- `breakdown`: your tier rates, the Pair's fee schedule multiplier, your account discounts and your referral discount. The multiplier and each discount carry `effectiveFrom` and, when scheduled, `effectiveTo`.
- `nextChangeAt`: the earliest scheduled change, when one is scheduled.

`makerFeeRatePercent` and `takerFeeRatePercent` are unchanged: fills are charged them, and the unused reserve is released.

**What to do:** size the largest buy that pays its fee in quote from the reserve rate `r`, in units of 10⁻¹⁸ of the quote Asset: `N = ceil(price × qty)`, `F = ceil(N × r / 100)`, and the order has sufficient quote balance when `N + F ≤ available`. Do not round to the quote Asset's own decimals. Other order checks still apply. Fetch the rates again at `nextChangeAt`. See [Size a buy](https://testnet.polyester.com/docs/developer-docs/account/fees-and-vip#size-a-buy-that-pays-its-fee-in-quote).

```json
{
	"feeRates": [
		{
			"symbol": "BTC-USDT",
			"makerFeeRatePercent": "0.072",
			"takerFeeRatePercent": "0.027",
			"vipTier": 1,
			"reserveFeeRatePercent": "0.09",
			"postOnlyReserveFeeRatePercent": "0.09",
			"breakdown": {
				"tierMakerFeeRatePercent": "0.08",
				"tierTakerFeeRatePercent": "0.135",
				"marketMultiplier": {
					"makerMultiplierPercent": "90",
					"takerMultiplierPercent": "20",
					"effectiveFrom": "2026-11-01T00:00:00Z"
				},
				"accountDiscounts": []
			}
		}
	]
}
```

## SDK versions

**Changed:** these SDK releases, published on 5 November 2026, are built for the 0.92.5 contract. Use them or a later release.

- **Go** · `github.com/Fabric-Labs/polyester-sdk-go`\
  v0.6.0
- **TypeScript** · `@polyester/sdk`\
  0.33.0
- **Python** · `polyester-sdk`\
  0.6.0
- **Rust** · `polyester-sdk`\
  0.7.0

The earlier alpha releases, such as Python `polyester-sdk` 0.1.0a55 and Rust `polyester-sdk` 0.1.0-alpha.49, do not work with 0.92.5: they send prices at 6 decimals instead of 9, and they subscribe to the retired order channels. No official SDK includes an SBE trading client; generate SBE codecs from the published schema.

## Final client checks

1. Regenerate from the new contract

   Regenerate Protobuf, ConnectRPC and SBE (version 36) bindings, and confirm unknown enum values fall through to a safe default.

2. Follow your orders on the private WebSocket

   Subscribe on `/v1/ws/private`, install the snapshot, apply units in order, and resume from a stored cursor after a reconnect.

3. Decode event arrays

   Decode market trades, your trades, ledger transfers and trigger events as arrays, and upsert by event identity.

4. Handle unknown outcomes

   Force a timeout path and confirm your client reads the order before retrying, and never resends a create with a new client order ID.

5. Check attached legs and triggers

   Place a sell order with a trailing leg and a maximum price, and confirm your bot reads leg status rather than assuming independent legs.

6. Rehearse a shutdown

   Cancel triggers, then cancel open orders, and reconcile with the cancel-all list.

7. Check your limits

   Confirm your per-IP write rate stays under the new limit and your placement budget leaves headroom for fired stops.
