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.
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 and 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.
- Event-stream consumers: market trades, your trades, ledger transfers and trigger events arrive as arrays. See 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.
- 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 renamesstpMode, and oversized write bodies answer413. See 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 and Triggers.
- Bots that reuse IDs or retry: client order IDs and retry windows change. See 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.
- Holds and fee readers: the holds list is removed and fee rates add reserve rates. See Balances, holds and fees.
- SDK users: upgrade to an SDK release built for 0.92.5. See SDK versions.
Migration checklist
- Send every REST query parameter in camelCase, including
subaccountIdandpageToken. - Rename
trailingDistanceTickstotrailingDistance, batch replacenewPriceTickstonewPrice, and the REST market overview decimal fields. - Read
rejectReasonandcancelReasoninstead ofterminalReasonandterminalReasonCode, and the same fields on ConnectRPC and in SBE order events. - Read
selfTradePreventionModeinstead ofstpModefrom 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_tickson 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
404for the events of a trigger you cannot read. - Stop calling
ListHoldsandGET /v1/holds; readreservedfrom the balances. - Size buys that pay their fee in quote from the reserve rate in the fee rates.
- Expect
413for an oversized write body,409for a REST trigger conflict, and match REST problems oncode, nottype. - Expect unprefixed enum values on REST and prefixed names on ConnectRPC and WebSocket JSON.
- Retry the retryable
Unavailableanswer 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.
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 answer400INVALID_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_SIZEorPRICE_TICK_SIZEwith a field violation instead ofINVALID_ARGUMENT. - Other decimals, such as
maxQuoteDebit,clientRefPriceandtrailingDistance, still answerINVALID_ARGUMENTfor 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
Rejects only that item, with a violation such asitems[0].baseQty; the other items are admitted or rejected on their own. The whole batch used to fail.POST /v1/orders/preview
Answers200with"admissible": falseand the code inrejection.POST /v1/orders/modifyandPOST /v1/orders/replace-batch
Answer400PRICE_TICK_SIZE, with a violation such asnewPriceoritems[1].newPrice.POST /v1/orders
Answers400with the code and a violation such asorder.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.
The trigger list names the mode selfTradePreventionMode
Changed: GET /v1/triggers names each trigger's
self-trade prevention mode selfTradePreventionMode, as 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.
{
"triggers": [{ "triggerId": "<trigger ID>", "stpMode": "EXPIRE_MAKER" }],
"nextPageToken": "<cursor>"
}{
"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/balancessubaccountId, previouslysubaccount_id.GET /v1/balances/historysubaccountIdandaccountCode.GET /v1/equity/history/seriessubaccountId,accountCodeandgroupBy.GET /v1/transferssubaccountId,pageToken,timestampMinandtimestampMax.GET /v1/spot/markets/overvieworderBy,includeSparklinesandsparklineIntervals.- Funding and account lists
Lifecycle flows, deposit addresses, the address book, transfer destinations and the whitelists takesubaccountId,pageTokenand their other filters in camelCase. - Trigger, heatmap and Subaccount event lists
GET /v1/triggers, the trigger events, the heatmap history and the Subaccount events no longer readpage_token; sendpageToken.
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.
trailingDistanceTicksis nowtrailingDistance
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.newPriceTicksis nownewPriceon 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.
{ "items": [{ "clientOrderId": "mm-bid-17", "newPriceTicks": "65432.1" }] }{ "items": [{ "clientOrderId": "mm-bid-17", "newPrice": "65432.1" }] }Market overview fields drop scale suffixes
Changed: GET /v1/spot/markets/overview names its decimal fields without Ticks or Scaled. The values are the same decimal strings.
lastPriceTicks,indexPriceTicks,high24hTicksandlow24hTicksare nowlastPrice,indexPrice,high24handlow24h.bestBidTicks,bestBidQtyScaled,bestAskTicksandbestAskQtyScaledare nowbestBid,bestBidQty,bestAskandbestAskQty.volume24hBaseScaled,volume24hQuoteScaledandvolume24hUsdScaledare nowvolume24hBase,volume24hQuoteandvolume24hUsd.sparklines[].closeTicksis nowsparklines[].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 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.
A reply (abridged; timestamps omitted):
{
"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 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:
replacementOrderIdis present only on aREPLACEDitem; it is omitted on an amended or rejected item.oldOrderIdis omitted only when the requested client order ID names no order. Rejected items keep it.actionTakenis omitted on rejected items.itemIndexis 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 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.
GET /v1/spot/markets/BTC-USDT/trades?afterTradeId=1099511627790New 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)
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 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}, GET /v1/orders/open and 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.
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.
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_scaleapplies 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 and 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 nolimit_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):
{
"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_INVALIDdurationMsis below 1000 (1 second).TWAP_INTERVAL_INVALIDsliceIntervalMsis below 100 milliseconds or longer thandurationMs.
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} 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 with ERROR_CODE_ORDER_MODIFY_OUTCOME_UNKNOWN means the modify may still apply. Call GetOrder and compare the order's
version before deciding to resend.
Open orders while an order is unavailable
Changed: GET /v1/orders/open, 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 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):
{
"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)
and the portfolio equity reads
(GetPortfolioEquitySnapshot, 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 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} 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:
{
"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.
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
The response addscancelled_orders(order_id,client_order_id; up to 200, oldest first),total_cancelled,truncatedandlisting_status.CancelAllAfter
The response addseffective_scope: the account, Pair and side the armed dead-man switch covers.BatchReplaceOrders
Each item amends in place or replaces, asModifyOrderdoes by default.action_taken = AMENDEDnow means the order was amended underold_order_idand kept its queue priority; it used to mean the remainder was canceled with no successor.replacement_order_idis set only forREPLACEDitems.orders.v1.Order.version
Can be0for aPENDINGorder, and covers execution state only;fee_totaland requested details can differ at the same version.orders.v1.Order
Addsavg_price_e18,cum_notionalandfee_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, anorders.v1.ErrorCodeset only on aREJECTEDorder, andcancel_reason, anorders.v1.CancelReasonset only on aCANCELEDorder, replace them.orders.v1.CancelReason
New. Values includeCANCEL_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) andCANCEL_REASON_MARKET_HALTED(the Pair was halted).orders.v1.ErrorCode
AddsERROR_CODE_SELF_TRADE_PREVENTED, the code of the self-trade fire refusal below; no request is answered with it.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 returnsERROR_CODE_TRIGGER_WOULD_TRIGGER_IMMEDIATELY(302).
Triggers
CreateTriggerclient_trigger_idis limited to 36 bytes. An invalid TWAP schedule returnsERROR_CODE_TWAP_DURATION_INVALIDorERROR_CODE_TWAP_INTERVAL_INVALID. A trailing stop whose activation price is already reached endsFAILEDwithTRIGGER_FAILURE_REASON_TRIGGER_WOULD_TRIGGER_IMMEDIATELY(244).ModifyTrigger
Settinglimit_price_ticksis rejected withTRIGGER_CHILD_PRICE_NOT_MODIFIABLE. An off-tick trigger or activation price returnsERROR_CODE_PRICE_TICK_SIZEwith a violation ontrigger_price_ticksoractivation_price_ticks, with or withoutsymbol_id. A change whose child would be below the Pair's minimums returnsERROR_CODE_MIN_QTY,ERROR_CODE_QTY_STEP_SIZEorERROR_CODE_MIN_NOTIONAL. A trigger price, trailing distance or activation price the market has already reached returnsERROR_CODE_TRIGGER_WOULD_TRIGGER_IMMEDIATELY(302).ListTriggerEvents
AnswersNotFoundfor a trigger the caller cannot read, asGetTriggerdoes.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
AddsEVENT_ARMED, when an attached leg starts protecting its order's fills, andEVENT_FIRE_REFUSED, a fire that was refused while the trigger stayed live, with its reason in the newTriggerEvent.fire_refusal_reason. Read the trigger'sstatusrather than inferring it from the last event.triggers.v1.TriggerCancelReason
AddsTRIGGER_CANCEL_REASON_SELF_TRADE_PREVENTION,TRIGGER_CANCEL_REASON_ACCOUNT_REPAIR,TRIGGER_CANCEL_REASON_MARKET_HALTED, andTRIGGER_CANCEL_REASON_PAIR_DRAIN_TRIGGER,TRIGGER_CANCEL_REASON_PAIR_DRAIN_TWAP,TRIGGER_CANCEL_REASON_PAIR_DRAIN_LADDERandTRIGGER_CANCEL_REASON_PAIR_DRAIN_ATTACHED_LEGfor a disabled Pair.triggers.v1.TriggerFailureReason
AddsTRIGGER_FAILURE_REASON_SELF_TRADE_PREVENTED, a fire refusal reason: self-trade prevention expired an attached exit and the leg stays armed.
Market data
GetTrades
The request addsafter_trade_id; the response addscomplete_through_trade_id,next_after_trade_idandepoch_ends.marketdata.v1.Timeframe
AddsMIN_3,HOUR_2,HOUR_6,HOUR_8andDAY_3.marketdata.v1.PairStatus
AddsPAIR_STATUS_HALTED: only cancels are accepted; new orders getMARKET_HALTED.
Accounts, funding and fees
GetSubaccountinclude_balancesfollows the access rules ofGetBalances. See Subaccount balances with API keys.auth.v1.SubaccountPermission,auth.v1.PolicyAction
Add auto-withdraw and swap permissions.auth.v1Subaccount member changes
Inviting, changing the role of, or removing a Subaccount's owner returnsAUTH_SUBACCOUNT_OWNER_ROLE_LOCKED(FailedPrecondition, REST412). Removing the owner used to returnAUTH_INVALID_REQUEST.auth.v1.AuthErrorCode
Adds credential refusal reasons such asAUTH_SIGNATURE_INVALID,AUTH_TIMESTAMP_SKEW,AUTH_MISSING_HEADERSandAUTH_KEY_EXPIRED, in theauth.v1.AuthErrorDetailof anunauthenticatederror.chain.deposit.v1.DepositAddressService
A Network whose deposits are suspended returnsFailedPrecondition; an unfiltered list omits it.ledger.read.v1.LedgerReadService.ListHolds
Removed, withListHoldsRequest,HoldRowandListHoldsResponse; readreservedfromGetBalances.GetSpotFeeRatesSpotFeeRateaddsreserve_fee_rate_percent,post_only_reserve_fee_rate_percent,breakdown(SpotFeeRateBreakdown) andnext_change_at.- Orders, triggers, withdrawals, internal transfers
A request a just-granted API key permission allows can return a retryableUnavailableinstead ofPermissionDenied.
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. 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.
A cancel by client order ID leaves orderId as 0:
header: templateId=3 schemaId=7 version=12header: 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, ProtobufClientFrame.batch_create_orders. Up to 20 items, any Pairs. - Batch replace orders
SBE template 8, ProtobufClientFrame.batch_replace_orders. Up to 50 items, one Pair. - Batch cancel orders
SBE template 9, ProtobufClientFrame.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 and the 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>:protoprivate: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 Protobufprivatews.v1messages; the default.
For batch replace progress, use GetBatchReplaceStatus together with the order versions on this socket. The full protocol is described in Private order updates.
What to do:
- Get a connect token from
GET /v1/rt/token, as before. - Open
/v1/ws/privatewith your subprotocol, and sendauthwith the token within 5 seconds. - 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. Sendsubscribewith channelORDERS. - Install the snapshot, then apply each unit in
seqorder. Store the latest cursor.readytells you the stream reflects every order reply you received before subscribing. - Refresh the connect token with
authand each subscription withsubscribeRefreshbefore they expire. The server warns you withtokenExpiring30 seconds ahead andsubscribeExpiring15 seconds ahead. - On a
seqgap, subscribe again. To resume after a reconnect, sendsubscribewith your last cursor. If the server answersresyncRequired, 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:
{"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):
{"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.
{"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
JSONpublic:spot:market:trades:<symbolId>; Protobufmarketdata.v1.MarketTradeBatch; identitymatchId. - Your trades · stream
JSONprivate:spot:trades:<accountPublicId>; Protobuforders.v1.UserTradeBatch; identitymatchId,orderId. - Ledger transfers · stream
JSONprivate:ledger:transfers:<accountPublicId>; Protobufledger.read.v1.TransferRowBatch; identitytransferId,account,isDebit. - Trigger events · stream
JSONprivate:spot:triggers:events:<accountPublicId>; Protobuftriggers.v1.TriggerEventBatch; identitytriggerId,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.
{
"symbol": "BTC-USDT",
"matchId": "1099511627791",
"side": "BUY",
"price": "65432.1",
"qty": "0.015",
"executedAt": "2026-10-30T12:00:00.123Z"
}{
"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 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 afterRetry-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: REST503, ConnectRPCResourceExhausted, with noRetry-AfterorRateLimit-*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-AfterandX-Polyester-RateLimit-Policy-Version, plus the rate-limit detail in the error body. - Trading WebSocket: the SBE
CommandRejectrateLimit*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 withRATE_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.
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.
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
FAILEDwithTRIGGER_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
ModifyOrderthat 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):
{
"status": 400,
"code": "TRIGGER_WOULD_TRIGGER_IMMEDIATELY"
}Trigger modifies meet the Pair's minimums
Changed: ModifyTrigger (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 (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) that sells 0.5 BTC
at market when the index price reaches 60,000:
{
"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. 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.
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)
CANCELEDwithUSER_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 and compare clientTriggerId instead of
retrying the create. See 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.
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.
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.
- REST
Price: a decimal string, up to 9 decimals. Quantity: a decimal string at the Pair's quantity scale. - ConnectRPC and trading WebSocket
Price:*_ticksorpriceTicks, an integer × 10^9. Quantity:*_scaledor*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. If your SDK sends prices at 6
decimals, upgrade it; see 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})
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.
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 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.
One row of GET /v1/balances (abridged; revisions and funding omitted):
{
"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 (GetSpotFeeRates) adds:
reserveFeeRatePercentandpostOnlyReserveFeeRatePercent: 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 carryeffectiveFromand, 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.
{
"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
Regenerate from the new contract
Regenerate Protobuf, ConnectRPC and SBE (version 36) bindings, and confirm unknown enum values fall through to a safe default.
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.
Decode event arrays
Decode market trades, your trades, ledger transfers and trigger events as arrays, and upsert by event identity.
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.
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.
Rehearse a shutdown
Cancel triggers, then cancel open orders, and reconcile with the cancel-all list.
Check your limits
Confirm your per-IP write rate stays under the new limit and your placement budget leaves headroom for fired stops.