This guide is a map for building a market-making system on Polyester. Each section names the decision you have to make, states the rule that shapes it, and links to the page with the details. Work through it in order the first time, then use it as a review before you go live.
Build and test on Testnet: the API host is api.testnet.polyester.com and the trade host is trade.api.testnet.polyester.com. Mainnet is coming soon. If you have not placed an order yet,
start with the Quickstart.
Choose an interface
Most market makers combine three paths: one for order entry, one for order state, and one for reads and recovery. Every order path reaches the same order handling and shares one rate-limit budget.
| Need | Use |
|---|---|
| Lowest-overhead order entry | SBE trade WebSocket: pipelined binary commands, with your order and execution events on the same socket |
| Order entry with Protobuf types | Protobuf trade WebSocket: the Orders API messages over one signed session |
| Order entry over HTTP | REST or ConnectRPC |
| Your order state | The private WebSocket |
| Market data | The realtime channels |
| Your trades and balances | The realtime connection in the WebSocket session model |
| Setup, reads, and reconciliation | REST or ConnectRPC |
Both trade WebSockets run on the trade host. The SBE socket is described in Connection and authentication, Order entry, and Responses and events. Compare every interface in the Connectivity matrix.
Set up keys and Subaccounts per strategy
Give each strategy its own Subaccount. A Subaccount has its own balances, orders, rate-limit pools, and dead-man switch, so one strategy's failure stays contained. See Subaccounts and roles.
A new Subaccount is read-only until you attach a Subaccount policy that allows trading. Each API key also needs an API key policy, and the effective access is the intersection of both. Grant a quoting
key only TRADE_SPOT on the markets it quotes. See API key policies.
Bind each key to the Subaccount it trades. A trade WebSocket session trades for the key's account only, so open one session per Subaccount. Restrict each key to your servers' addresses with an IP allowlist, and sign every request as in Ed25519 API keys.
Load Pair rules and scales
GetSpotConfig (GET /v1/spot/config) returns each Pair's tick size,
step size, minimum and maximum sizes, open-order limits, and trading status. Load it at startup,
refresh it on an interval, and reload it whenever an order is rejected for a Pair rule or status.
A Pair can be POST_ONLY, which accepts only post-only orders, or HALTED, which cancels your
open orders. ConnectRPC, Protobuf, and SBE carry prices and quantities as scaled integers, and
identify a Pair by its numeric symbolId. See Pairs and trading rules and Scaled integers.
Build the quoting loop
Quote post-only. A post-only limit order is refused with POST_ONLY_CROSS instead of taking
liquidity. Post-only is set on good-till-cancelled and good-till-date limit orders. See Order types and lifecycle.
Refresh with batch replace. BatchReplaceOrders (POST /v1/orders/replace-batch) modifies up
to 50 orders of one Pair in one request. An item that keeps its price and does not raise its
quantity is amended in place and keeps its queue priority; any other change replaces the order with
a new orderId. Batch create places up to 20 orders and batch cancel removes up to 50. See Batch orders.
Keep your identifiers stable. Give every order a clientOrderId, up to 36 characters. It stays
reserved for at least 60 seconds after the order ends, and a reused ID is rejected, not replayed.
Give every batch a new requestId; repeating it within the batch's retry window returns the
original result. See Client order IDs.
Read before you resend. An answer ending in _OUTCOME_UNKNOWN, a timeout, or a dropped
connection means the command may have applied. Read the order before you send anything new. See Error responses.
Set up risk controls
| Control | What it does |
|---|---|
| Dead-man switch | Cancels the account's open orders when your heartbeats stop. Arm it with a timeout of 10 to 120 seconds and refresh it before it runs out. Each Subaccount has its own switch |
| Cancel all orders | Cancels every matching open order, by Pair and side. It does not cancel standalone triggers, so cancel those first when you stop a strategy |
| Self-trade prevention | Stops your orders from matching each other across the Main Account and all its Subaccounts. The default is EXPIRE_MAKER; set the mode on each order |
| Market order price protection | Bounds the price of market orders, such as hedges, with a slippage tolerance |
Closing a connection does not trigger the dead-man switch, and an open connection does not keep it
alive: only a heartbeat call does. Send each heartbeat with a new requestId.
Stay within rate limits
Placements and cancellations draw from separate pools per trading account. Every key and transport
of that account shares them, so extra keys add no capacity. Each batch item costs as much as a
single command, and a cancel-all costs more. Read your effective limits with GET /v1/trading/rate-limits.
On RATE_LIMIT_EXCEEDED, wait for Retry-After or retry_after_ms, then retry with the same
identifiers and a fresh signature. Two refusals are not your quota: SYSTEM_RATE_LIMITED means the
venue-wide rate is exhausted, so back off and retry, and OVERLOADED means too many of your order
requests are outstanding, so retry once one completes. A per-IP limit also applies to REST and ConnectRPC order writes, and trade WebSocket
commands do not count toward it. See Rate limits.
Capacity above the published tiers needs an account-specific override; retail VIP status does not grant it.
Consume your private stream and reconcile
The private WebSocket sends a snapshot of an
account's orders, then every change as a unit with a cursor. Each order carries a version: keep
the copy with the higher version when you merge stream updates with reads. Send sync after a
command to wait until the stream reflects it.
Check seq on every frame. On a gap, subscribe again with your last cursor. When the server
answers resyncRequired instead, install the new snapshot and read fills from your trade history,
because the events in between are not delivered. See Gaps, resume, and resync.
After any reconnect, reconcile from the read APIs before you quote again: open orders with GET /v1/orders/open and fills with GET /v1/spot/trades. Rebuild your order book from a
snapshot and its bookSeq, as in Order book, and
backfill missed public trades by trade ID, as in Trades.
Your trades and balances on the realtime connection follow the delivery and recovery rules of the WebSocket session model.
Know your fees
GetSpotFeeRates (GET /v1/spot/fee-rates) returns the maker and taker rates that apply to an account, per Pair. A negative maker rate is a rebate. Rates follow the
VIP tier of the Main Account, which every Subaccount inherits. Read rates from the API instead of
hard-coding them. To size the largest buy that pays its fee in quote, use the row's reserveFeeRatePercent (postOnlyReserveFeeRatePercent for a post-only order), not your effective
rate. See Fees and VIP.
Run it in production
Before you go live
- Clocks are synchronized; signed timestamps must be within 10 seconds of server time
- Every request attempt is signed again with a fresh timestamp
- Reconnects back off with jitter and fetch new tokens or a fresh signed upgrade
- The dead-man switch is armed for every quoting account
- Alerts branch on error codes, never on message text
- Outcome-unknown answers trigger a read before any resend
- Your client ignores unknown fields and enum values