WebSocket Protocol
Both multiplexed endpoints speak the same JSON protocol over one TLS connection: a request envelope from the client, a frame envelope from the server, and a per-channel sequence you must verify.
Connection
| Endpoint | Purpose |
|---|---|
wss://api.sentico-labs.xyz/api/v1/ws/public | Public market channels |
wss://api.sentico-labs.xyz/api/v1/ws/private/{account} | Private account channels; /api/v1/ws/mm/{account} and /api/v1/ws/bsl/{account} are aliases |
Inbound messages are capped at 64 KiB. Private connections must authenticate
within authDeadlineMs from the session frame or they are closed.
Session frame
After the upgrade the server sends the schema announcement and then the session frame. Everything a client needs to size itself is in it:
{
"event": "session",
"stream": "senticore_private",
"streamVersion": 1,
"schemaId": "senticore_private_ws_json",
"schemaVersion": 1,
"schemaEncoding": "json",
"cursor": null,
"resumeFromSeq": null,
"rotateByMs": 1786259008273,
"heartbeatMs": 20000,
"idleTimeoutMs": 90000,
"authDeadlineMs": 5000,
"maxIdleBeforeSubscribeMs": 30000,
"controlMessageWindowMs": 60000,
"maxControlMessagesPerWindow": 60,
"maxSubscriptionsPerConnection": 32,
"publicMaxMarketsPerConnection": 16,
"reconnectPolicy": { "supportsStreamResume": true, "supportsCursorResume": true, "baseBackoffMs": 250, "maxBackoffMs": 5000 },
"signals": { "mempoolLen": 0, "commitQueueDepth": 0 }
}
The numbers above are examples; the deployed values are whatever the frame
says. stream is senticore_public or senticore_private.
Request envelope
Every client message is a JSON object with a method. id is optional and is
echoed on the acknowledgement so you can correlate requests.
{ "id": "sub-1", "method": "subscribe", "subscription": { "type": "l2Book", "marketId": 3, "depth": 20 } }
method | Body | Reply |
|---|---|---|
auth | authorization: "Bearer spws1…" (or token) | ack on channel auth |
subscribe | subscription object (below) | ack on channel subscription, then replay/snapshot, then snapshot_end |
unsubscribe | same subscription object | ack on channel subscription with "method": "unsubscribe" |
ping | — | ack on channel pong with data.ts |
resend | channel, fromSeq, toSeq (private, at most 10,000 frames) | ack on channel resend with deliveredThroughSeq, then the replayed frames |
A shorthand without the nested object is accepted:
{"method": "subscribe", "channel": "l2Book", "marketId": 3}; the gateway lifts
marketId, depth, limit, cursor, fromSeq and account into the
subscription.
Subscription object
| Field | Public channels | Private channels |
|---|---|---|
type | channel name (marketSnapshot, l2Book, trades, bbo, activeAssetCtx); snake_case and the aliases snapshot, book, assetCtx are accepted | channel name (account, userEvents, orderUpdates, userFills, dropCopy); fills is an alias for userFills |
marketId | numeric market id | not used |
depth, limit | book depth / trade limit for the initial snapshot | not used |
fromSeq (aliases cursor, resumeFrom) | resume the market stream after this sequence | resume the account stream after this sequence |
account | not used | optional; must equal the account in the URL (error 1004 otherwise) |
Subscribing to a private channel on the public endpoint, or a public channel on
the private endpoint, is rejected with a message pointing at the right
endpoint. Public subscriptions are capped by maxSubscriptionsPerConnection
and publicMaxMarketsPerConnection; control messages are rate-limited per
controlMessageWindowMs / maxControlMessagesPerWindow, and exceeding that
closes the socket with code 4409.
Frame envelope
Every server frame after the session frame has this shape:
{
"type": "event",
"channel": "l2Book",
"seq": 184273,
"prev_seq": 184272,
"ts": 1788558400818,
"data": { "marketId": 3, "seq": 184273, "prevSeq": 184272, "stateVersion": 545718, "topOfBookChecksum": "…", "book": { "spot": { "bids": [["2513937400", "40"]], "asks": [] } } },
"id": null
}
| Field | Meaning |
|---|---|
type | event for data; ack, error, warning, snapshot_end, resume_required, gap_fill, rotate for control |
channel | Channel name; control frames use auth, subscription, pong, resend, error, warning |
seq / prev_seq | Monotonic per channel scope; null on control frames |
ts | Server time in unix milliseconds |
data | Channel payload |
id | The request id this frame answers, when applicable |
Acknowledgements
Subscription ack, with the replay plan the server chose:
{ "type": "ack", "channel": "subscription", "seq": null, "prev_seq": null, "ts": 1788558400818,
"data": { "method": "subscribe", "subscription": { "type": "orderUpdates", "account": "0x…", "fromSeq": 4810 },
"channel": "orderUpdates", "fromSeq": 4810, "snapshotSeq": 4812, "liveFromSeq": 4813 }, "id": "sub-1" }
Frames with seq in (fromSeq, snapshotSeq] are replayed history; a
snapshot_end frame on that channel marks the switch to live delivery.
Auth ack:
{ "type": "ack", "channel": "auth", "data": { "ok": true, "account": "0x…", "scope": "read", "tokenType": "ws_session_token", "agentId": "agt_…", "credentialId": "…" }, "id": "auth-1" }
A failed auth returns ok: false with status (401/403), an error text and
code AuthFailed (105); subscribing before a successful auth returns error
1001.
Sequence handling
- Verify
seq == prev_seq + 1per channel scope (market for public, account for private). - On a gap, stop applying, fetch a REST snapshot (public) or gap-fill from
/api/v1/bsl/accounts/{account}/executions?cursor=…and account reads (private), then resubscribe withfromSeq. resume_requiredorgap_fillmeans the requested cursor is outside the retention window:datacarriesrequestedFromSeq,oldestAvailableSeq,newestAvailableSeqandreplayRetentionMs. Rebuild from REST and resume fromoldestAvailableSeqor later.- A
rotateframe ("event": "rotate") precedes a planned disconnect. Its payload includes the resubscribe journal (subscriptions[]with cursors); reconnect and replay it.
Heartbeat and liveness
The server emits heartbeats every heartbeatMs and closes idle connections
after idleTimeoutMs. A client may send {"method": "ping"} at any time and
receives {"type": "ack", "channel": "pong", "data": {"ts": …}}. A slow
consumer first receives a warning frame (data.reason = "slow_consumer")
and is then closed with 4408.
Errors and close codes
Errors are frames of type error:
{ "type": "error", "channel": "error", "data": { "message": "subscription.type is required", "code": 1004 }, "id": "sub-1" }
data.code | Meaning |
|---|---|
1000 | Malformed message |
1001 | Authentication required |
1002 | Permission denied (credential lacks the channel's scope) |
1003 | Unsupported operation |
1004 | Invalid subscription |
1005 | Replay gap |
1006 | Invalid resend range |
1007 | Unsupported channel |
103 / 104 / 105 | Reused order-entry reject codes: trading halted, ledger degraded, auth failed |
| Close code | Meaning |
|---|---|
4401 | Auth required and not provided before the deadline |
4408 | Policy violation (slow consumer, protocol misuse) |
4409 | Limit exceeded (subscriptions, markets, control-message rate) |
Resilience
See Reconnect Strategy for production-grade client behavior and the SDK helpers that implement this protocol.