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 (public endpoint, as observed on 2026-09-14):
{
"event": "session",
"stream": "senticore_public",
"streamVersion": 1,
"schemaId": "senticore.websocket.public.control.v1",
"schemaVersion": 1,
"schemaEncoding": "json",
"cursor": null,
"resumeFromSeq": null,
"rotateByMs": 1789455003112,
"heartbeatMs": 20000,
"idleTimeoutMs": 90000,
"authDeadlineMs": 5000,
"maxIdleBeforeSubscribeMs": 30000,
"controlMessageWindowMs": 60000,
"maxControlMessagesPerWindow": 60,
"maxSubscriptionsPerConnection": 32,
"publicMaxMarketsPerConnection": 16,
"reconnectPolicy": {
"supportsStreamResume": true, "supportsCursorResume": true, "proactiveRotate": true, "resubscribeJournal": true,
"baseBackoffMs": 250, "jitterMs": 500, "maxBackoffMs": 10000,
"primaryEndpoint": "/api/v1/ws/public", "endpoints": ["/api/v1/ws/public"], "failoverEndpoint": null
},
"signals": { "mempoolLen": 0, "commitQueueDepth": 0, "droppedEventsTotal": 0, "reconcileFailuresTotal": 0, "slowConsumerClosesTotal": 0, "checksumMismatchesTotal": 0, "reconnectSignalsTotal": 0 }
}
The numbers above are one observation; the deployed values are whatever the
frame says. stream is senticore_public or senticore_private, and the
private endpoint carries the same policy fields with its own schemaId.
reconcileFailuresTotal counts failed durable background checks in this server
process. It is separate from droppedEventsTotal: a failed check does not by
itself prove that a live event was dropped.
If three consecutive background checks fail for a public subscription, the
server sends a warning control frame with code: PUBLIC_DURABLE_RECONCILE_UNAVAILABLE, the affected streamKey, and action: treat_stream_as_unverified. Treat an affected l2Book as potentially stale until a later
PUBLIC_DURABLE_RECONCILE_RECOVERED warning for the same key confirms a
successful check. These control frames have channel: warning and no sequence
number; they do not advance the market-data cursor.
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) | replay/snapshot bootstrap, then ack on channel subscription and snapshot_end before live delivery |
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; send it as a u64 decimal string |
cursorNamespace | not used | "execution-v1"; required together with fromSeq on orderUpdates (error 1004 otherwise) |
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. Exceeding any of
those limits first sends an error frame with code 1008 and the request
id, then 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, checkpoint, 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. Public frames carry numbers (the first snapshot frame is seq: 0, prev_seq: null); private execution channels carry u64 decimal strings |
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", "cursorNamespace": "execution-v1" },
"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 continuity per channel scope. For execution channels carrying
data.cursorMode: "durable-predecessor", compareprev_seqto your last processed cursor;seqcan jump because RESULT cursors are sparse. Large integers are decimal strings, to be compared losslessly withBigInt. Legacy unmarked frames use contiguous sequences. Cursors are namespaced: never derive an execution cursor from an account-snapshot cursor (or the other way round) with±1arithmetic. See private channels for checkpoints and the mandatoryorderUpdatescursor namespace when resuming. - 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. Expectresume_required(1005) right after a deploy even with a recent cursor; treat it as routine, not as data loss.- 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 — including a resumed orderUpdates cursor without cursorNamespace: "execution-v1" |
1005 | Replay gap / resume_required; routine right after a deploy — rebuild from REST |
1006 | Invalid resend range |
1007 | Unsupported channel |
103 / 104 / 105 | Reused order-entry reject codes: trading halted, ledger degraded, auth failed |
| Close code | Meaning |
|---|---|
1000 | Normal close: the server answers a client Close(1000) with 1000 (verified 2026-09-13), and uses it for proactive rotation; complete the Close handshake |
1013 | Retained private replay is unavailable; rebuild state before resuming |
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
Private control-error data.code: 1005 means a replay gap. It is separate from
WebSocket Close codes. 1006 is a client's abnormal-closure observation and is
never sent as a Close frame. Rotation journals contain decimal strings for
cursors above JavaScript's safe integer range and record delivered progress.
Clients must prefer their own successfully processed cursor when reconnecting.
See Reconnect Strategy for production-grade client behavior and the SDK helpers that implement this protocol.