Skip to main content

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​

EndpointPurpose
wss://api.sentico-labs.xyz/api/v1/ws/publicPublic 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 } }
methodBodyReply
authauthorization: "Bearer spws1…" (or token)ack on channel auth
subscribesubscription object (below)replay/snapshot bootstrap, then ack on channel subscription and snapshot_end before live delivery
unsubscribesame subscription objectack on channel subscription with "method": "unsubscribe"
ping—ack on channel pong with data.ts
resendchannel, 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​

FieldPublic channelsPrivate channels
typechannel name (marketSnapshot, l2Book, trades, bbo, activeAssetCtx); snake_case and the aliases snapshot, book, assetCtx are acceptedchannel name (account, userEvents, orderUpdates, userFills, dropCopy); fills is an alias for userFills
marketIdnumeric market idnot used
depth, limitbook depth / trade limit for the initial snapshotnot used
fromSeq (aliases cursor, resumeFrom)resume the market stream after this sequenceresume the account stream after this sequence; send it as a u64 decimal string
cursorNamespacenot used"execution-v1"; required together with fromSeq on orderUpdates (error 1004 otherwise)
accountnot usedoptional; 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
}
FieldMeaning
typeevent for data; ack, checkpoint, error, warning, snapshot_end, resume_required, gap_fill, rotate for control
channelChannel name; control frames use auth, subscription, pong, resend, error, warning
seq / prev_seqMonotonic 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
tsServer time in unix milliseconds
dataChannel payload
idThe 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", compare prev_seq to your last processed cursor; seq can jump because RESULT cursors are sparse. Large integers are decimal strings, to be compared losslessly with BigInt. 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 ±1 arithmetic. See private channels for checkpoints and the mandatory orderUpdates cursor 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 with fromSeq.
  • resume_required or gap_fill means the requested cursor is outside the retention window: data carries requestedFromSeq, oldestAvailableSeq, newestAvailableSeq and replayRetentionMs. Rebuild from REST and resume from oldestAvailableSeq or later. Expect resume_required (1005) right after a deploy even with a recent cursor; treat it as routine, not as data loss.
  • A rotate frame ("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.codeMeaning
1000Malformed message
1001Authentication required
1002Permission denied (credential lacks the channel's scope)
1003Unsupported operation
1004Invalid subscription — including a resumed orderUpdates cursor without cursorNamespace: "execution-v1"
1005Replay gap / resume_required; routine right after a deploy — rebuild from REST
1006Invalid resend range
1007Unsupported channel
103 / 104 / 105Reused order-entry reject codes: trading halted, ledger degraded, auth failed
Close codeMeaning
1000Normal close: the server answers a client Close(1000) with 1000 (verified 2026-09-13), and uses it for proactive rotation; complete the Close handshake
1013Retained private replay is unavailable; rebuild state before resuming
4401Auth required and not provided before the deadline
4408Policy violation (slow consumer, protocol misuse)
4409Limit 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.