# Protobuf trade WebSocket

Send orders over one signed WebSocket using the same orders.v1 request and response messages as ConnectRPC.

The Protobuf trade WebSocket is an order-entry session. Each binary frame wraps one `orders.v1` request, the same message you send to [`OrdersService`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CreateOrder) 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](https://github.com/Fabric-Labs/polyester-proto). It imports `orders.v1`.

> **Commands and answers only**
>
> This socket carries your commands and their acknowledgements. It does not stream order state, fills, or balances. Read those from the [private WebSocket](https://testnet.polyester.com/docs/developer-docs/connectrpc/private-websocket), and use ConnectRPC or REST to reconcile state.

## When to use it

| Interface                                                                                        | Choose it when                                                                                 |
| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| [ConnectRPC](https://testnet.polyester.com/docs/developer-docs/connectrpc/getting-started)       | 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](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/getting-started) | 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](#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](https://github.com/Fabric-Labs/polyester-proto).

## Sign the upgrade

The server authenticates the HTTP upgrade itself. Send the three [Ed25519 API-key](https://testnet.polyester.com/docs/developer-docs/authentication-security/ed25519-api-keys) 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:

```text
1762171200000
GET
/ws/trade.proto

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

Sign 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](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/connection-and-authentication#upgrade-failures) and [Connection limits](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/connection-and-authentication#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 by `pong`, echoing `payload`
- **`create_order`** · [`CreateOrderRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CreateOrder) · answered by `ack` with `CreateOrderResponse`
- **`cancel_order`** · [`CancelOrderRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelOrder) · answered by `ack` with `CancelOrderResponse`
- **`modify_order`** · [`ModifyOrderRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/ModifyOrder) · answered by `ack` with `ModifyOrderResponse`
- **`cancel_all_orders`** · [`CancelAllOrdersRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllOrders) · answered by `ack` with `CancelAllOrdersResponse`
- **`batch_create_orders`** · [`BatchCreateOrdersRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchCreateOrders) · answered by `ack` with `BatchCreateOrdersResponse`
- **`batch_replace_orders`** · [`BatchReplaceOrdersRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchReplaceOrders) · answered by `ack` with `BatchReplaceOrdersResponse`
- **`batch_cancel_orders`** · [`BatchCancelOrdersRequest`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchCancelOrders) · answered by `ack` with `BatchCancelOrdersResponse`

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](https://testnet.polyester.com/docs/developer-docs/connectrpc/identifiers), and prices and quantities are [scaled integers](https://testnet.polyester.com/docs/developer-docs/connectrpc/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](https://testnet.polyester.com/docs/developer-docs/shared-concepts/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](#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](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/connection-and-authentication#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 with [`OrdersReadService.GetOrder`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetOrder), and resending the same `client_order_id` is rejected with `CONFLICT_DUPLICATE_CLIENT_ORDER_ID`, whose `error.existing_order_id` names the order. See [Client order IDs](https://testnet.polyester.com/docs/developer-docs/shared-concepts/client-order-ids).
- **Modify and cancel-all:** `request_id` is required, 1 to 64 characters from `A-Z a-z 0-9 . _ : / -`. Keep it when you retry.
- **Batches:** `request_id` is the same account-scoped key as on the Orders API. Resending the same `request_id` and 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](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/connection-and-authentication#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](https://testnet.polyester.com/docs/developer-docs/connectrpc/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`](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes#orders-and-triggers) 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](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits#trading).

This socket adds its own codes. Each maps to the public code that names the same failure:

- **`SEQ_OUT_OF_ORDER`** · no public code\
  `seq` is not the next expected number.
- **`SEQ_REQUIRED`** · public code `INVALID_ARGUMENT`\
  `seq` is `0`.
- **`BAD_PROTO`** · public code `BAD_REQUEST`\
  The frame is not a valid `ClientFrame`.
- **`SUBACCOUNT_SELECTOR_UNSUPPORTED`** · public code `INVALID_ARGUMENT`\
  The request sets `subaccount_id`.
- **`SYMBOL_REQUIRED`** · public code `INVALID_ARGUMENT`\
  `symbol_id` is missing.
- **`CANCEL_KEY_INVALID`, `MODIFY_KEY_INVALID`** · public code `INVALID_ARGUMENT`\
  Not exactly one of `order_id` and a valid `client_order_id`.
- **`MODIFY_PATCH_REQUIRED`** · public code `INVALID_ARGUMENT`\
  A modify changes nothing.
- **`QTY_INVALID`** · public code `BAD_QTY`\
  A quantity or quote amount is missing or not positive.
- **`PRICE_REQUIRED`, `PRICE_INVALID`** · public code `BAD_PRICE`\
  A limit price is missing or not positive.
- **`ENGINE_BUSY`** · public code `UNAVAILABLE`\
  The Pair cannot take the order now; retry with backoff (a batch with the same `request_id`).
- **`FEE_CEILING_EXCEEDED`** · public code `RISK_LIMIT`\
  Handle as `RISK_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.

> **Reconcile before resending**
>
> After `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](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/connection-and-authentication#keepalive). 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.

1. Generate the types

   Clone the [public contracts](https://github.com/Fabric-Labs/polyester-proto) on the branch of your environment (`testnet` for Testnet) and generate Python with [Buf](https://buf.build/docs/):

   ```yaml
   # buf.gen.yaml
   version: v2
   plugins:
       - remote: buf.build/protocolbuffers/python
         out: gen
   ```

   ```bash
   buf generate --template buf.gen.yaml --include-imports --path proto/public/tradews
   pip install websockets cryptography protobuf
   export PYTHONPATH=gen
   ```

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

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