The Protobuf trade WebSocket is an order-entry session. Each binary frame wraps one orders.v1 request, the same message you send to OrdersService over ConnectRPC, and
each answer wraps the
matching orders.v1 response. If you already generate types from the public contracts, you can
trade over a long-lived socket without learning a second order model.
The contract is tradews.v1 (tradews/v1/trade_ws.proto) in the public Protobuf contracts. It imports orders.v1.
When to use it
| Interface | Choose it when |
|---|---|
| ConnectRPC | You want typed request-response calls, an official SDK, or the full service set |
| Protobuf trade WebSocket | You want ConnectRPC's order messages over one persistent signed session, without HTTP per call |
| SBE WebSocket | You need the smallest wire format, pipelined commands, and order events on the same socket |
Both trade WebSockets authenticate and limit sessions the same way, and both reach the same order handling as the Orders API. They differ in encoding, pipelining, and events; see Differences from SBE WebSocket.
Endpoint
Open the socket on the trade host of your environment, not on the API host. On Testnet, connect to wss://trade.api.testnet.polyester.com/ws/trade.proto. Mainnet is coming soon.
The socket accepts binary Protobuf frames only. It has no JSON encoding, and a text frame closes the
connection. Generate your types from the testnet branch of the public contracts.
Sign the upgrade
The server authenticates the HTTP upgrade itself. Send the three Ed25519 API-key headers on the GET upgrade, with no Authorization header. The canonical string uses the path /ws/trade.proto, an empty query, and the SHA-256 of an empty body:
1762171200000
GET
/ws/trade.proto
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Sign each connection attempt with a fresh timestamp. A refused upgrade is a plain HTTP response
whose body is the code as text and a newline, for example 401 with UNAUTHENTICATED when no API-key headers
are sent. The codes, the account a session trades for, and the session limits are the same as on
SBE WebSocket: see Upgrade failures and Connection limits.
Protobuf and SBE sessions are counted separately.
The session trades for the account its key is bound to. Leave subaccount_id unset in every
request: a request that sets it is rejected with SUBACCOUNT_SELECTOR_UNSUPPORTED. To trade a Subaccount, connect with a key bound to that Subaccount.
Frames
Send one tradews.v1.ClientFrame per binary frame. The server answers each with one tradews.v1.ServerFrame. A frame may not exceed 64 KiB, and WebSocket compression is not
negotiated.
ClientFrame field | Content |
|---|---|
seq | Your sequence number on this connection, from 1 |
correlation_id | Your own string, copied into the answer |
payload | Exactly one command: ping or one of the orders.v1 requests below |
Each payload command, its request message, and its answer:
ping·tradews.v1.PingRequest· answered bypong, echoingpayloadcreate_order·CreateOrderRequest· answered byackwithCreateOrderResponsecancel_order·CancelOrderRequest· answered byackwithCancelOrderResponsemodify_order·ModifyOrderRequest· answered byackwithModifyOrderResponsecancel_all_orders·CancelAllOrdersRequest· answered byackwithCancelAllOrdersResponsebatch_create_orders·BatchCreateOrdersRequest· answered byackwithBatchCreateOrdersResponsebatch_replace_orders·BatchReplaceOrdersRequest· answered byackwithBatchReplaceOrdersResponsebatch_cancel_orders·BatchCancelOrdersRequest· answered byackwithBatchCancelOrdersResponse
The request and response messages are the orders.v1 messages of the matching OrdersService method; each link opens its full schema in the API reference.
Fields keep their orders.v1 meaning: IDs are fixed64 public IDs,
and prices and quantities are scaled integers.
A batch create carries up to 20 orders, and a batch replace or batch cancel up to 50 items; a batch
replace targets one Pair. See Batch orders.
A ServerFrame copies seq and correlation_id from the frame it answers and carries one of:
ServerFrame payload | Meaning |
|---|---|
pong | Answer to ping |
ack | The command was accepted; result holds the orders.v1 response |
reject | The command was refused; the connection stays open unless noted below |
overload, terminal | Defined in the schema; the current server does not send them |
Sequence and correlation
The first command on a connection uses seq 1, and each command the server takes uses the next
number. A command with the wrong number is rejected with SEQ_OUT_OF_ORDER and not processed; the
expected number does not change. A new connection starts again at 1.
A command that fails the server's frame and field checks is rejected without using its number. That
covers an undecodable frame (BAD_PROTO, answered with seq 0), a 0 sequence (SEQ_REQUIRED),
a missing or malformed field (the validation codes in Rejects, BAD_REQUEST, or INVALID_TIME_IN_FORCE), and a batch above its item limit (BATCH_TOO_LARGE). Every other
answer, an ack or a reject, uses the number. When unsure, send a ping as described in Sequence numbers.
correlation_id only matches answers to commands. It is not an idempotency key. Retries rely on
the request's own fields:
- Create: set
client_order_id. A create that may have been placed is found withOrdersReadService.GetOrder, and resending the sameclient_order_idis rejected withCONFLICT_DUPLICATE_CLIENT_ORDER_ID, whoseerror.existing_order_idnames the order. See Client order IDs. - Modify and cancel-all:
request_idis required, 1 to 64 characters fromA-Z a-z 0-9 . _ : / -. Keep it when you retry. - Batches:
request_idis the same account-scoped key as on the Orders API. Resending the samerequest_idand items, over this socket or the Orders API, returns the original per-item results.
Acknowledgements and order state
The server reads the next frame only after it has written the answer to the previous one. You can write several frames without waiting; they are answered one at a time, in the order sent. For more concurrent commands, open more sessions within the connection limits.
An ack confirms admission, as the ConnectRPC response does. CreateOrderResponse returns the order_id, not whether the order rests, fills, or is later rejected. A cancel ack with status ACCEPTED means the cancel was accepted for processing. A batch ack holds one result per item,
in request order, and an item can be rejected while the others take effect.
Follow the outcome on the private WebSocket.
To make sure it reflects an ack you received, send sync on that socket and wait for synced.
Rejects
A reject carries code (a string), a detail for diagnostics, and, for some codes, error, an orders.v1.ErrorDetail. Branch on code, never on detail.
For refusals by the order service, code is the orders.v1.ErrorCode name without
its ERROR_CODE_ prefix, such as INSUFFICIENT_FUNDS or POST_ONLY_CROSS. error is set only
for RATE_LIMIT_EXCEEDED, where error.rate_limit holds the quota and the retry delay, and for CONFLICT_DUPLICATE_CLIENT_ORDER_ID with a known holder. Commands spend from the same trading pools
as the Orders API; see Rate limits.
This socket adds its own codes. Each maps to the public code that names the same failure:
SEQ_OUT_OF_ORDER· no public codeseqis not the next expected number.SEQ_REQUIRED· public codeINVALID_ARGUMENTseqis0.BAD_PROTO· public codeBAD_REQUEST
The frame is not a validClientFrame.SUBACCOUNT_SELECTOR_UNSUPPORTED· public codeINVALID_ARGUMENT
The request setssubaccount_id.SYMBOL_REQUIRED· public codeINVALID_ARGUMENTsymbol_idis missing.CANCEL_KEY_INVALID,MODIFY_KEY_INVALID· public codeINVALID_ARGUMENT
Not exactly one oforder_idand a validclient_order_id.MODIFY_PATCH_REQUIRED· public codeINVALID_ARGUMENT
A modify changes nothing.QTY_INVALID· public codeBAD_QTY
A quantity or quote amount is missing or not positive.PRICE_REQUIRED,PRICE_INVALID· public codeBAD_PRICE
A limit price is missing or not positive.ENGINE_BUSY· public codeUNAVAILABLE
The Pair cannot take the order now; retry with backoff (a batch with the samerequest_id).FEE_CEILING_EXCEEDED· public codeRISK_LIMIT
Handle asRISK_LIMIT.
Outcome unknown
A command can be answered with ORDER_CREATE_OUTCOME_UNKNOWN, ORDER_CANCEL_OUTCOME_UNKNOWN, or ORDER_MODIFY_OUTCOME_UNKNOWN when it was sent but its result was not confirmed, or with UNAVAILABLE when it could not be sent. The server then closes the connection with 1013 and
answers nothing you sent after it.
1013, read every unanswered command's state by client order ID or open orders before you
resend. A resent create without that check can place the order twice. Batches can be resent with
the same request_id and items to learn each item's result.Keepalive and close codes
The server sends a WebSocket ping every 20 seconds, which standard libraries answer automatically.
Keep reading the socket. The liveness and write timeouts are the same as on SBE WebSocket. Use ping to measure round-trip time; it uses a sequence number like any command.
Branch on the close code; the reason text may change.
1003·binary frames required
Send binary frames only.1008·pong_timeout; reconnect and resync required
Reconnect and reconcile.1008·write_timeout; reconnect and resync required
Read faster, then reconnect and reconcile.1009· library-defined reason text
Keep frames within 64 KiB.1011·encode reject failed
Reconnect and reconcile.1013·command without a definite outcome; reconcile before resending
Reconcile every unanswered command, then reconnect.1006· no close frame, so no reason text
Reconnect with backoff and reconcile.
Reconnect with backoff and jitter, for example from 0.5 seconds doubling to 10 seconds. Sign a fresh
upgrade, start again at seq 1, and read open orders before you resend anything that was
unanswered.
Differences from SBE WebSocket
| Topic | Protobuf trade WebSocket | SBE WebSocket |
|---|---|---|
| Messages | tradews.v1 frames around orders.v1 messages | Fixed-layout SBE templates |
| Order events | None; use the private WebSocket | Subscribe adds order and execution events |
| Commands in flight | One at a time, answered in order | Up to 1,024 pipelined |
| Correlation | correlation_id string, not an idempotency key | 64-bit correlationId, idempotency for some commands |
| Idempotency | client_order_id and request_id fields | clientOrderId and correlationId |
| Reject code | String, such as SEQ_OUT_OF_ORDER | Number, such as SEQUENCE_OUT_OF_ORDER (1001) |
Close 1012 | Not used | Private event gap or source unavailable |
Upgrade signing, upgrade failures, session and IP limits, frame size, keepalive, and the outcome-unknown close work the same way on both.
Example: place and cancel one order
This Python client signs the upgrade, places a post-only limit sell, cancels it by client order ID, and prints both answers.
Generate the types
Clone the public contracts on the branch of your
environment (testnet for Testnet) and generate Python with Buf:
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/python
out: genbuf generate --template buf.gen.yaml --include-imports --path proto/public/tradews
pip install websockets cryptography protobuf
export PYTHONPATH=genRun the client
Set POLYESTER_API_KEY_ID, POLYESTER_API_PRIVATE_KEY_HEX (the 32-byte Ed25519 private key seed in
hex), SYMBOL_ID, QTY_SCALED, and PRICE_TICKS. Choose a price above the best ask so the
post-only order rests. The client connects to trade.api.testnet.polyester.com unless you set POLYESTER_TRADE_HOST.
trade_proto_client.py
import asyncio
import hashlib
import os
import time
import websockets
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from orders.v1 import orders_pb2
from tradews.v1 import trade_ws_pb2
HOST = os.environ.get("POLYESTER_TRADE_HOST", "trade.api.testnet.polyester.com")
PATH = "/ws/trade.proto"
EMPTY_BODY_SHA256 = hashlib.sha256(b"").hexdigest()
def upgrade_headers(key_id: str, private_key: Ed25519PrivateKey) -> dict:
timestamp = str(int(time.time() * 1000))
canonical = f"{timestamp}\nGET\n{PATH}\n\n{EMPTY_BODY_SHA256}"
signature = private_key.sign(canonical.encode()).hex()
return {"X-API-KEY-ID": key_id, "X-API-TIMESTAMP": timestamp, "X-API-SIGNATURE": signature}
def post_only_sell(seq, symbol_id, qty_scaled, price_ticks, client_order_id):
frame = trade_ws_pb2.ClientFrame(seq=seq, correlation_id=f"c{seq}")
order = frame.create_order.order
order.symbol_id = symbol_id
order.side = orders_pb2.SELL
order.base_qty_scaled = qty_scaled
order.limit_gtc.price_ticks = price_ticks
order.limit_gtc.post_only = True
order.client_order_id = client_order_id
return frame.SerializeToString()
def cancel(seq, client_order_id):
frame = trade_ws_pb2.ClientFrame(seq=seq, correlation_id=f"c{seq}")
frame.cancel_order.client_order_id = client_order_id
return frame.SerializeToString()
def describe(data: bytes) -> str:
frame = trade_ws_pb2.ServerFrame.FromString(data)
kind = frame.WhichOneof("payload")
head = f"seq={frame.seq} correlation_id={frame.correlation_id}"
if kind == "ack":
result = frame.ack.WhichOneof("result")
body = getattr(frame.ack, result)
return f"ack {result} {head} order_id={getattr(body, 'order_id', 0)}"
if kind == "reject":
return f"reject {head} code={frame.reject.code} detail={frame.reject.detail!r}"
return f"{kind} {head}"
async def main() -> None:
key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(os.environ["POLYESTER_API_PRIVATE_KEY_HEX"]))
headers = upgrade_headers(os.environ["POLYESTER_API_KEY_ID"], key)
symbol_id = int(os.environ["SYMBOL_ID"])
qty_scaled, price_ticks = int(os.environ["QTY_SCALED"]), int(os.environ["PRICE_TICKS"])
client_order_id = f"quickstart-{int(time.time())}"
async with websockets.connect(f"wss://{HOST}{PATH}", additional_headers=headers, compression=None) as ws:
await ws.send(post_only_sell(1, symbol_id, qty_scaled, price_ticks, client_order_id))
print(describe(await ws.recv()))
await ws.send(cancel(2, client_order_id))
print(describe(await ws.recv()))
if __name__ == "__main__":
asyncio.run(main())A successful run prints an ack create_order with seq=1 and the new order_id, then an ack cancel_order with seq=2. A refused order prints a reject with its code instead, and the cancel is then answered with a reject too, because no order holds that
client order ID. If the upgrade is refused, websockets raises InvalidStatus with the HTTP status;
the response body holds the code.