# Private WebSocket

Stream your own orders, fills, and command outcomes with snapshots, ordered units, and resumable cursors.

The private WebSocket streams the orders of your accounts. Each subscription starts with a snapshot of one account's orders, then sends every change as a unit: the orders as they now are, plus the events that changed them, such as fills, cancels, and rejects. A cursor lets you resume after a short disconnect without reloading.

Use it alongside any order-entry interface when your system needs to follow its own orders without polling. The contract is `privatews.v1` (`privatews/v1/private_ws.proto`) in the [public Protobuf contracts](https://github.com/Fabric-Labs/polyester-proto). This page is the full reference for the socket; the realtime connection for market data and other account streams is described in the [WebSocket session model](https://testnet.polyester.com/docs/developer-docs/shared-concepts/websocket-session-model).

## What it carries

| Stream                                            | Where                                                                                                                |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Your orders, including attached TP/SL legs        | This socket, channel `ORDERS`                                                                                        |
| Fills of your orders                              | This socket, as `FILLED` events in units                                                                             |
| Outcomes of refused amends, cancels, replaces     | This socket, as command outcomes in units                                                                            |
| Your trades, balances, ledger transfers, triggers | The [realtime connection](https://testnet.polyester.com/docs/developer-docs/shared-concepts/websocket-session-model) |

On the realtime connection, follow [user trade executions](https://testnet.polyester.com/docs/api-docs/streams/private-spot-trades), [account balances](https://testnet.polyester.com/docs/api-docs/streams/private-ledger-balances), [ledger transfers](https://testnet.polyester.com/docs/api-docs/streams/private-ledger-transfers), and [trigger events](https://testnet.polyester.com/docs/api-docs/streams/private-spot-trigger-events); the stream reference lists their channels and payloads.

`ORDERS` is the only channel today. Place orders over ConnectRPC, REST, or a trade WebSocket; this socket only reads.

## Endpoint

Connect to the API host of your environment. On Testnet, the endpoint is `wss://api.testnet.polyester.com/v1/ws/private`. Mainnet is coming soon.

> **Testnet access is being enabled**
>
> API clients that are not browsers may currently be refused with HTTP `403` on the Testnet upgrade while access is being enabled. A `403` on the upgrade itself is not an authentication failure; authentication happens after the socket opens.

Choose the encoding with the WebSocket subprotocol. Both carry the same messages:

| Subprotocol          | Frames                                                                                                              |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `polyester.v1.proto` | Binary `privatews.v1.ClientMessage` and `ServerMessage`. Used when you offer no subprotocol                         |
| `polyester.v1.json`  | Text frames of their canonical JSON: lowerCamelCase names, enum names, 64-bit integers as strings, defaults omitted |

In JSON, a field at its default value is left out: a missing `seq` is `0`, a missing `version` is `0`, and a missing enum is its `..._UNSPECIFIED` value. Ignore unknown fields and enum names.

## Get the tokens

The socket accepts only realtime tokens. Request them from the API host with a JWT session or a [signed API-key request](https://testnet.polyester.com/docs/developer-docs/authentication-security/ed25519-api-keys):

- **Connect token** · `GET /v1/rt/token` · lifetime 5 min\
  Used in `auth`.
- **Subscribe token** · `GET /v1/rt/subscribe?channel=private:spot:orders:<account>` · lifetime 1 min\
  Used in `subscribe` and `subscribeRefresh`.

Both answer `{"token": "...", "expires_unix": 1762171260}`. `<account>` is the Main Account's or Subaccount's public ID in base58, as REST shows it. Use the base form only: the order channel has no `:proto` variant, and an unsupported channel is answered with `400`.

Access is checked when the subscribe token is issued, not on the socket. A caller without access to the account gets `403`, and right after a policy change a `503` with `{"code":"UPSTREAM_ERROR"}` that you retry with backoff. An unauthenticated request gets `401` with a problem body whose `code` is `UNAUTHENTICATED`.

When you sign `GET /v1/rt/subscribe` with an API key, the canonical query percent-encodes the colons:

```text
1762171200000
GET
/v1/rt/subscribe
channel=private%3Aspot%3Aorders%3A3sDK21t5nHJ
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

Both tokens name your Main Account, also when you authenticate with a Subaccount-bound key. One connection can therefore subscribe every account you can read, the Main Account and its Subaccounts, each with its own subscribe token. A subscribe token issued to another user is refused with error `FORBIDDEN`.

## Connection flow

1. Authenticate

   Send `auth` with the connect token within 5 seconds of opening the socket. Any other frame first, a bad token, or no `auth` in time closes the connection with `4403`. A frame the server cannot decode, or an unknown operation, closes it with `4400` instead. The server answers `authenticated` with `expiresTsSec`. Heartbeats start as soon as the socket opens.

2. Subscribe each account

   Send `subscribe` with your request `id`, channel `ORDERS`, the account's public ID as a decimal number (a string in JSON), and its subscribe token. The base58 ID and the number are the same value: base58 uses the alphabet `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`, most significant digit first, so `3sDK21t5nHJ` is `1234567890123456789`. Add `cursor` to resume.

3. Install the snapshot

   `subscribed` names the subscription in `sub`. A snapshot follows: `snapshotBegin`, zero or more `snapshotPage` frames of up to 256 orders in ascending order ID, and `snapshotEnd` with a cursor. Stage the pages and replace your state with them at `snapshotEnd`.

4. Wait for ready, then apply units

   `ready` means the stream reflects every acknowledgement given before you subscribed, including your earlier order reads. Apply each `unit` in order and store its `nextCursor`.

5. Keep the tokens fresh

   Send a new `auth` before `tokenExpiring` runs out, and `subscribeRefresh` for each subscription before `subscribeExpiring` runs out.

```json
{"auth":{"token":"<connect token>"}}
{"authenticated":{"expiresTsSec":"1762171500"}}
{"subscribe":{"id":"r1","channel":"ORDERS","account":"1234567890123456789","token":"<subscribe token>"}}
{"sub":"s1","subscribed":{"id":"r1","account":"1234567890123456789","expiresTsSec":"1762171260"}}
{"sub":"s1","seq":"1","snapshotBegin":{"epoch":"3"}}
{"sub":"s1","seq":"2","snapshotPage":{"orders":[{"orderId":"8215573541720371","status":"WORKING","version":3}]}}
{"sub":"s1","seq":"3","snapshotEnd":{"cursor":"AQID","count":1,"digest":"9934217"}}
{"sub":"s1","seq":"4","ready":{}}
```

Orders in these examples are shortened; each is a complete `orders.v1.Order`.

## Client operations

| Operation          | Fields                                        | Answer                                       |
| ------------------ | --------------------------------------------- | -------------------------------------------- |
| `auth`             | `token`                                       | `authenticated`                              |
| `subscribe`        | `id`, `channel`, `account`, `token`, `cursor` | `subscribed`, then the stream                |
| `subscribeRefresh` | `id`, `sub`, `token`                          | `subscribeRefreshed`                         |
| `unsubscribe`      | `id`, `sub`                                   | `unsubscribed` with reason `REQUESTED`       |
| `sync`             | `id`, `sub`                                   | `synced`, or error `BEHIND` after 10 seconds |
| `ping`             | `id`                                          | `pong`                                       |

A refresh must be for the same user. A refused `subscribeRefresh` is answered with error `FORBIDDEN`, and the subscription keeps its current expiry. A subscription whose token expires without a refresh ends with `unsubscribed` reason `TOKEN_EXPIRED`; the connection stays open.

## Server frames

Every frame is a `ServerMessage` with `sub`, `seq`, and one frame:

- **Stream frames** · `sub` is the subscription · `seq` from `1`, then +1\
  `snapshotBegin`, `snapshotPage`, `snapshotEnd`, `unit`, `covered`, `ready`, `synced`, `resyncRequired`, `unavailable`, and `available`.
- **Subscription replies** · `sub` is the subscription · `seq` `0`\
  `subscribed`, `subscribeRefreshed`, `unsubscribed`, `subscribeExpiring`, and an `error` about a subscription.
- **Connection frames** · no `sub` · `seq` `0`\
  `authenticated`, `tokenExpiring`, `heartbeat`, `pong`, and an `error` about no subscription.

`tokenExpiring` arrives 30 seconds before the connect token expires, and `subscribeExpiring` 15 seconds before a subscribe token does. `heartbeat` arrives every second with `serverTsNs`.

## Units

A unit is one change to the account, applied whole:

```json
{
	"sub": "s1",
	"seq": "5",
	"unit": {
		"prevCursor": "AQID",
		"nextCursor": "AQIE",
		"orders": [{ "orderId": "8215573541720371", "status": "FILLED", "version": 4 }],
		"removedOrderIds": ["8215573541720371"],
		"events": [
			{
				"order": {
					"orderId": "8215573541720371",
					"version": 4,
					"kind": "FILLED",
					"fill": {
						"matchId": "77120045",
						"ordinal": 1,
						"role": "MAKER",
						"qtyScaled": "1000",
						"priceTicks": "65000000000000"
					}
				}
			}
		]
	}
}
```

Apply `orders` first, replacing each order by `orderId` when its `version` is newer. Then remove the IDs in `removedOrderIds`: those orders left the live view, either ended and kept in order history or never admitted. A removal is not a cancel. `prevCursor` equals the cursor you held. Store `nextCursor`. A `covered` frame moves the cursor without any change; store its `cursor` too.

Each unit's `events` list what caused the change, in order and never merged. An event is either an `order` event or a command `outcome`.

- **`ACCEPTED`** · no extra fields\
  The order, or an amend of it, was accepted.
- **`FILLED`** · extra fields `fill`\
  The order filled, partly or fully.
- **`CANCELED`** · extra fields `cancelReason`\
  The order ended without filling its remainder.
- **`REJECTED`** · extra fields `rejectReason`\
  The order was refused after admission.
- **`REPLACED`** · extra fields `replacementOrderId`, `cancelReason` on the ended order\
  A replace ended the order or created it as the successor.
- **`ATTACHED_RISK`** · no extra fields\
  One of the order's take-profit, stop-loss, or trailing-stop legs changed.

Every order event carries `orderId` and the order's `version` after it. `rejectReason` is an `orders.v1.ErrorCode` and `cancelReason` an `orders.v1.CancelReason`, the same values the order and the Orders API show. An order's `ACCEPTED` comes before its fills.

A `fill` has `matchId` (the `match_id` of the trade in your user trades), `ordinal` (the fill's number for this order, from `1`), `role` (`MAKER` or `TAKER`), `qtyScaled` in the Pair's base quantity scale, and `priceTicks` in quote units scaled by 1e9. See [Scaled integers](https://testnet.polyester.com/docs/developer-docs/connectrpc/scaled-integers).

| Outcome `kind`     | Meaning                                                                     |
| ------------------ | --------------------------------------------------------------------------- |
| `AMEND_REJECTED`   | An amend was refused; the order is unchanged                                |
| `CANCEL_REJECTED`  | A cancel was refused                                                        |
| `REPLACE_REJECTED` | A replace's successor was refused; the original order is unchanged          |
| `REJECTED`         | An order was refused before it existed, such as a duplicate                 |
| `CANCEL_ALL_DONE`  | A cancel by criteria completed; its cancels arrived earlier as order events |

An outcome carries `orderId`, `newOrderId` for a replace, `clientOrderId`, and `reason`, the `orders.v1.ErrorCode` of the refusal. `CANCEL_ALL_DONE` names no request: with several in flight, you cannot tell which one completed. An order refused when you send it, for example for insufficient funds, is answered on the request and never appears on this socket.

## Order versions and your own writes

Every order carries `version`. Version `0` is an order accepted but not yet on the book; version `1` is its first book state, and each execution-state change increases it. A higher `version` is newer, and an equal `version` is the same execution state. It does not cover the fee total or details a read adds on request, which can differ between copies with the same `version`. Order reads over REST and ConnectRPC return the same `version`, so merge a read with the stream by keeping the higher version.

An order's `ACCEPTED` event always comes before its fills. An IOC order that fills in part shows `ACCEPTED`, `FILLED`, then `CANCELED`. A BUY sized by a quote amount that spends its amount ends with its `FILLED` event and has no `CANCELED` event; see [Order sizing](https://testnet.polyester.com/docs/developer-docs/shared-concepts/order-sizing#maximum-quote-debit).

A market halt can republish an order that had already ended, for example `FILLED`, at its next `version` with the same status, fills, and time, and its attached legs shown ended. Treat it as any newer version.

Orders on this socket carry the exact average price `avg_price_e18` and the quote amount of the fills `cum_notional`, but not the fee total `fee_total`; read it from order history or `GetOrder`. See [Scaled integers](https://testnet.polyester.com/docs/developer-docs/connectrpc/scaled-integers#trading-quantities).

`sync` gives read-your-writes. After an order command is acknowledged, over any interface, send `sync` on the account's subscription. When `synced` arrives, every unit for acknowledgements given before the `sync` has been delivered. A subscription holds up to 16 unanswered syncs.

## Gaps, resume, and resync

Check `seq` on every subscription frame: it increases by exactly 1. On a gap or a repeat, drop the subscription and subscribe again with your last applied cursor. Skip a frame kind you do not know, but count its `seq`.

To resume after a reconnect, send `subscribe` with that cursor. Units continue from it, with no snapshot, when the server still holds them: about the last 1,024 changes or 30 seconds, on the same server that issued the cursor. An account stays resumable for 30 seconds after its last subscription ends. Otherwise the server sends `resyncRequired` and a new snapshot:

| Reason           | Cause                                                |
| ---------------- | ---------------------------------------------------- |
| `CURSOR_UNKNOWN` | The cursor comes from another server or history      |
| `CURSOR_EXPIRED` | The cursor is older than the replay window           |
| `HISTORY_RESET`  | The account's history restarted; `epoch` changes too |

> **A resync skips events**
>
> The events between your old state and the new snapshot are not delivered, so amend, cancel, and reject outcomes in that gap are lost. Read fills from your trade history with [`GetUserTrades`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetUserTrades), and read any order you were waiting on with [`GetOrder`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetOrder) or [`GET /v1/spot/orders/{order_id}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/spot/orders/order_id).

Cursors are opaque: store them, never parse them. An unreadable cursor is answered with error `CURSOR_INVALID`.

`unavailable` means the account's live view, or with reason `ORDER_UNAVAILABLE` the orders listed in `orderIds`, is not current for now. The other reasons are `VIEW_BEHIND`, `NOT_READY`, `FAILOVER`, and `BOUNDARY_UNAVAILABLE`. Show what it names as stale until `available`. The subscription recovers by itself; do not resubscribe.

## Errors and limits

A failed request is answered with an `error` frame carrying the request `id`, a `code`, and a `message`; the connection stays open. The codes are in [Private WebSocket requests](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes#private-websocket-requests).

| Limit                             | Value                                             |
| --------------------------------- | ------------------------------------------------- |
| Subscriptions per connection      | 64, then error `TOO_MANY_SUBSCRIPTIONS`           |
| Unanswered syncs per subscription | 16, then error `INVALID_REQUEST`                  |
| Client frame size                 | 64 KiB                                            |
| Frames queued for you             | 2 MiB or 2,048 frames, and 5 seconds per write    |
| Open connections per IP address   | 100, then HTTP `429` on upgrade                   |
| New connections per IP address    | 10 per second with a burst of 50, then HTTP `429` |

Snapshot pages go out at your pace and do not count toward the 2 MiB, so an account of any size can be subscribed. Read continuously: a full queue closes the connection with `4408`.

## Close codes

Branch on the code; the reason text is diagnostic.

- **`1001`** · `GOING_AWAY`\
  The server is draining. Reconnect, then resubscribe with your cursors.
- **`4400`** · `BAD_FRAME`\
  A malformed frame, an unknown operation, or a frame of the other encoding. Fix the client.
- **`4401`** · `TOKEN_EXPIRED`\
  The connect token expired without a new `auth`. Get a new connect token, then reconnect.
- **`4403`** · `AUTH_FAILED`\
  A bad connect token, another user's, a frame before `auth`, or no `auth` within 5 seconds. Get a new connect token, then reconnect.
- **`4408`** · `SLOW_CONSUMER`\
  Your client did not read fast enough. Read faster, then resubscribe with your cursors.
- **`1006`** · no reason\
  Network failure, no close frame. Reconnect with backoff.

Treat the connection as stale after 3 seconds without a `heartbeat`. Reconnect with backoff from 0.5 seconds, doubling up to 10 seconds, with jitter. On every reconnect, fetch a new connect token and new subscribe tokens, then subscribe with your last cursors.

## Example: follow one account

This Python client fetches both tokens with a signed API key, subscribes one account over JSON, installs the snapshot, applies units, and stops on a sequence gap.

**private_ws_client.py**

```python
import asyncio
import hashlib
import json
import os
import time
import urllib.parse
import urllib.request

import websockets
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

API = os.environ.get("POLYESTER_API_HOST", "api.testnet.polyester.com")
EMPTY_BODY_SHA256 = hashlib.sha256(b"").hexdigest()
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def base58_to_int(text: str) -> int:
    value = 0
    for char in text:
        value = value * 58 + BASE58.index(char)
    return value


def signed_get(path: str, query: dict, key_id: str, key: Ed25519PrivateKey) -> dict:
    timestamp = str(int(time.time() * 1000))
    canonical_query = "&".join(
        f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(v, safe='')}" for k, v in sorted(query.items())
    )
    canonical = f"{timestamp}\nGET\n{path}\n{canonical_query}\n{EMPTY_BODY_SHA256}"
    request = urllib.request.Request(
        f"https://{API}{path}" + (f"?{canonical_query}" if canonical_query else ""),
        headers={
            "X-API-KEY-ID": key_id,
            "X-API-TIMESTAMP": timestamp,
            "X-API-SIGNATURE": key.sign(canonical.encode()).hex(),
        },
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        return json.load(response)


async def main() -> None:
    key_id = os.environ["POLYESTER_API_KEY_ID"]
    key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(os.environ["POLYESTER_API_PRIVATE_KEY_HEX"]))
    account = os.environ["ACCOUNT_PUBLIC_ID"]  # base58, as REST shows it

    connect = signed_get("/v1/rt/token", {}, key_id, key)
    subscribe = signed_get("/v1/rt/subscribe", {"channel": f"private:spot:orders:{account}"}, key_id, key)

    orders, staged, cursor, last_seq = {}, None, None, 0
    async with websockets.connect(f"wss://{API}/v1/ws/private", subprotocols=["polyester.v1.json"], compression=None) as ws:
        await ws.send(json.dumps({"auth": {"token": connect["token"]}}))
        await ws.send(json.dumps({"subscribe": {
            "id": "r1", "channel": "ORDERS",
            "account": str(base58_to_int(account)), "token": subscribe["token"],
        }}))
        async for text in ws:
            frame = json.loads(text)
            seq = int(frame.get("seq", "0"))
            if seq:
                if seq != last_seq + 1:
                    raise RuntimeError(f"sequence gap: {last_seq} then {seq}; subscribe again")
                last_seq = seq
            if "snapshotBegin" in frame:
                staged = {}
            elif "snapshotPage" in frame:
                staged.update({o["orderId"]: o for o in frame["snapshotPage"].get("orders", [])})
            elif "snapshotEnd" in frame:
                orders, staged, cursor = staged, None, frame["snapshotEnd"]["cursor"]
                print(f"snapshot installed: {len(orders)} orders")
            elif "unit" in frame:
                unit = frame["unit"]
                for order in unit.get("orders", []):
                    held = orders.get(order["orderId"])
                    if held is None or order.get("version", 0) > held.get("version", 0):
                        orders[order["orderId"]] = order
                for order_id in unit.get("removedOrderIds", []):
                    orders.pop(order_id, None)
                cursor = unit["nextCursor"]
                for event in unit.get("events", []):
                    print("event", event)
            elif "covered" in frame:
                cursor = frame["covered"]["cursor"]
            elif "ready" in frame:
                print("ready: the stream reflects every earlier acknowledgement")
            elif "error" in frame:
                print("request error", frame["error"])


if __name__ == "__main__":
    asyncio.run(main())
```

Set `POLYESTER_API_KEY_ID`, `POLYESTER_API_PRIVATE_KEY_HEX`, and `ACCOUNT_PUBLIC_ID`, then run it with `websockets` and `cryptography` installed. It prints the snapshot size, `ready`, and each event as your orders change. A production client also refreshes both tokens before they expire and resubscribes with `cursor` after a reconnect.
