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. 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.
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 |
On the realtime connection, follow user trade executions, account balances, ledger transfers, and 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.
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:
- Connect token ·
GET /v1/rt/token· lifetime 5 min
Used inauth. - Subscribe token ·
GET /v1/rt/subscribe?channel=private:spot:orders:<account>· lifetime 1 min
Used insubscribeandsubscribeRefresh.
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:
1762171200000
GET
/v1/rt/subscribe
channel=private%3Aspot%3Aorders%3A3sDK21t5nHJ
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Both 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
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.
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.
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.
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.
Keep the tokens fresh
Send a new auth before tokenExpiring runs out, and subscribeRefresh for each subscription
before subscribeExpiring runs out.
{"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 ·
subis the subscription ·seqfrom1, then +1snapshotBegin,snapshotPage,snapshotEnd,unit,covered,ready,synced,resyncRequired,unavailable, andavailable. - Subscription replies ·
subis the subscription ·seq0subscribed,subscribeRefreshed,unsubscribed,subscribeExpiring, and anerrorabout a subscription. - Connection frames · no
sub·seq0authenticated,tokenExpiring,heartbeat,pong, and anerrorabout 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:
{
"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 fieldsfill
The order filled, partly or fully.CANCELED· extra fieldscancelReason
The order ended without filling its remainder.REJECTED· extra fieldsrejectReason
The order was refused after admission.REPLACED· extra fieldsreplacementOrderId,cancelReasonon 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.
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.
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.
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 |
GetUserTrades, and read any
order you were waiting on with GetOrder or 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.
| 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 newauth. Get a new connect token, then reconnect.4403·AUTH_FAILED
A bad connect token, another user's, a frame beforeauth, or noauthwithin 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
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.