Skip to main content

WebSocket

Environment​

EnvironmentBase URL
Productionwss://api-studio.r25.xyz

Changelog​

Dates use the YYYY-MM-DD format, listed in descending order. The date of a historical entry reflects when the document change was submitted, not necessarily when the interface went live.

DateChange
2026-10-30Added: the market data subscription market.orderbook (Section 10, sources HYPERLIQUID / STOVE); server-initiated marginCall.alert margin-level warning push (Section 11).

1. Document Overview & Capabilities​

This document is for external partners integrating the OpenAPI trading WebSocket interface. It describes the trading endpoints exposed in the current version, market data subscriptions, the server-initiated margin-call warning push, the handshake signing method, the message protocol, and the callback structure. The Host, Client ID, and signature in the examples are placeholders; replace them with the values assigned by the platform or agreed upon by both parties.

The same AK/SK can be used for both WebSocket and REST: WebSocket handles trading write operations and real-time subscription pushes; for scenarios without a persistent connection, you can use the equivalent REST endpoints for order create/cancel and position close (see the REST documentation).

Current WebSocket capabilities include:

  • Trading writes: order.create, order.cancel, position.close; currently connected to HYPERLIQUID and HABITTRADE.
  • Trading queries: order.query, position.query.
  • Real-time subscriptions: order.updated and position.updated trading topics, pushed per vault frame.
  • Market data subscriptions: the market.orderbook (order book) topic on data sources HYPERLIQUID (perp/spot) and STOVE (US equities etc.); see Section 10.
  • Server-initiated push: marginCall.alert margin-level warning; no subscription required, automatically pushed for HYPERLIQUID vaults operable by the current AK once connected; see Section 11.
  • Account-level authorization: data.vaultAddress of every business op must belong to a vault operable by the current AK, otherwise VAULT_NOT_PERMITTED.

2. Base URL, Content-Type, Time/Number/Null Conventions​

2.1 General Conventions​

  • Base URL: pick the corresponding wss address from the environment table at the top; the trading endpoint path is fixed at /api/openapi/v1/ws.
  • Transport: UTF-8 JSON text frames; binary frames are closed. WebSocket does not use Content-Type or a request body.
  • Uplink frame limit: 5 KiB (exceeding returns UPLINK_TOO_LARGE, connection not closed); exceeding 64 KiB closes the connection (code 1009).
  • Authentication signs once during the handshake only; after the connection is established, no further auth is needed (see §4).
  • Handshake URL query params (clientId/timestamp/nonce/signature) should be UTF-8 percent-encoded.
  • This document only covers the WebSocket protocol available to partners (trading, market data subscriptions, and margin-call warning pushes).

2.2 Time Conventions​

  • Handshake timestamp: Unix seconds (string); accepts yyyy-MM-dd HH:mm:ss or a plain number (≥1e12 treated as milliseconds), valid window ±300 seconds.
  • Uplink request ts: Unix millisecond timestamp, must be strictly increasing (ts ≤ the cached previous ts → dropped with REQUEST_TS_STALE).
  • Downlink response/event ts, acceptedAt, and order/position createdAt/updatedAt/exchangeTime/serverTime: all epoch milliseconds.

2.3 Number & Null Conventions​

  • Trading numbers (quantity, price, leverage, slippage, USD value, fee, etc.) use decimal strings; do not parse them with lossy types such as JavaScript Number. leverage.value/rawUsd, maxSlippageBps, etc. are also decimal strings.
  • Quantity must be a multiple of sizeStep and limit price a multiple of priceTick (see the overview table in §3); otherwise INVALID_SIZE_STEP / INVALID_PRICE_TICK is returned.
  • Price fields are in USD per asset unless otherwise noted.
  • Downlink serialization omits fields whose value is null by default (see §5 Message Protocol).

3. Overview​

This document is for external partners integrating the OpenAPI trading WebSocket interface. The WebSocket interface supports order create/query/cancel, position query/close, real-time order/position push subscriptions, the order book market data subscription, and the server-initiated margin-call warning push. The same AK/SK can be used for both WebSocket and REST.

ItemValue
WS endpointwss://<host>/api/openapi/v1/ws
TransportUTF-8 JSON text frames (binary frames are closed)
Protocol versionv field fixed to "1.0.0"
ExchangesHYPERLIQUID (HL, perpetual contracts) / HABITTRADE (HT, spot rebalancing vault)
Business opsorder.create / order.query / order.cancel / position.query / position.close
Trading subscription topicsorder.updated / position.updated (type=subscribe, op is the topic)
Market data sourcesHYPERLIQUID (perp/spot) / STOVE (US equities etc.)
Market data topicmarket.orderbook (order book, see Section 10)
Server-initiated pushmarginCall.alert margin-level warning (no subscription required, see Section 11)
Quantity precisionHL sizeStep=0.001; HT sizeStep=1 (must be a multiple, otherwise INVALID_SIZE_STEP)
Price precisionHL priceTick=0.1; HT priceTick=0.01 (limit price must be a multiple)

Only one active connection is allowed per AK: opening a new connection with the same AK closes the old one (code 4001). The new connection does not inherit the old connection's subscriptions or in-flight requests.

4. Authentication & Handshake Signing​

WebSocket reuses the HTTP OpenAPI AK/SK: clientId is the AK and the Ed25519 private key is the SK. Signing happens only at the handshake; no auth afterwards.

Handshake Signing​

wss://<host>/api/openapi/v1/ws?clientId=<AK>&timestamp=<ts>&nonce=<nonce>&signature=<sig>
ParamDescription
clientIdAK (same Access Key as HTTP OpenAPI)
timestampUnix seconds (string). Accepts yyyy-MM-dd HH:mm:ss or a plain number (≥1e12 treated as ms). Valid window ±300 seconds
nonceRandom string (UUID recommended), one-time, must not repeat within 300 seconds
signatureEd25519 signature of the string to sign, lowercase hex (64 bytes ≡ 128 chars)

String to sign (three lines separated by \n):

<clientId>\n<timestamp>\n<nonce>

i.e. the UTF-8 bytes of clientId + "\n" + timestamp + "\n" + nonce are signed with Ed25519 and hex-encoded.

Any check failure → the handshake returns HTTP 401 and does not upgrade to WS. After a successful handshake, no further auth is required for the connection.

5. Message Protocol​

Serialization omits null fields. Allowed uplink type values: request / subscribe / unsubscribe.

{
"v": "1.0.0",
"type": "request",
"id": "order.create-4",
"op": "order.create",
"ts": 1787623689692,
"exchange": "HYPERLIQUID",
"data": { }
}
FieldRequiredDescription
vYesProtocol version, fixed 1.0.0
typeYesrequest / subscribe / unsubscribe
idYesRequest ID, unique per connection, used to match responses. Reuse on the same connection → DUPLICATE_REQUEST_ID
opYesFor request, the business op; for subscribe/unsubscribe, the topic/fixed string
tsYesClient time (ms), must be strictly increasing (ts ≤ cached previous ts → REQUEST_TS_STALE dropped)
exchangeNoHYPERLIQUID / HABITTRADE; omit only for order.query/position.query cross-exchange merged query
dataDepends on opBusiness params of each op, see §7/§8

If a required protocol field (v / type / id / op) is missing or invalid, an INVALID_REQUEST error response is returned; the connection is not closed.

The uplink frame limit is 5 KiB; exceeding returns UPLINK_TOO_LARGE (connection not closed); exceeding 64 KiB closes the connection (code 1009).

{
"v": "1.0.0",
"type": "response",
"id": "order.create-4",
"op": "order.create",
"ts": 1787623689692,
"code": "SUCCESS",
"message": "accepted",
"data": { }
}

code is an error code name (see §12); code=SUCCESS means success.

Error responses (code ≠ SUCCESS) echo the request's id and op; when not provided in the request they are null — for request-response matching and troubleshooting:

{
"v": "1.0.0",
"type": "response",
"id": "req-1",
"op": "order.create",
"ts": 1787623690000,
"code": "INVALID_REQUEST",
"message": "unsupported type: subscribe2",
"data": { }
}
{
"v": "1.0.0",
"type": "event",
"topic": "position.updated",
"subscriptionId": "sub_52a6f1e5-6555-41b2-bc65-19eed11ea9fd",
"seq": 41,
"exchange": "HYPERLIQUID",
"ts": 1787638868358,
"data": { }
}

seq is monotonically increasing within a connection, used by the client for dedup/alignment, reset to zero on reconnect.

6. Connection, Rate Limit, Heartbeat & Close Codes​

ConventionDefaultDescription
Max uplink frame5 KiBExceeds → UPLINK_TOO_LARGE; over 64 KiB → 1009
Max downlink frame64 KiBExceeds → dropped
In-flight requests per connection10Duplicate id → DUPLICATE_REQUEST_ID; full → TOO_MANY_INFLIGHT_REQUESTS
Max trading subscriptions per connection4order.updated / position.updated; re-subscribing the same exchange+topic overwrites the old (does not add quota)
Max market data subscriptions per connection200Total market.orderbook subscriptions; max 50 tickers per subscribe request, over-limit rejects the whole batch (see §10)
Heartbeat Ping period30sServer sends Ping periodically
Pong timeout60sNo Pong for 60s → close code 4002

Rate limit (clientId + IP dimensions): IP 60/60s; clientId 120/1h. Either hit → RATE_LIMIT.

Close codes:

CodeMeaning
1000Normal close
1008Auth/config policy rejection; binary frame; transport error
1009Uplink frame over 64 KiB
4001Replaced by a new connection on the same AK
4002Pong timeout (no Pong for 60s)

7. HYPERLIQUID (HL) Business Endpoints​

General: data.vaultAddress is required for every business op; the server checks whether it is operable by the current AK. No permission → VAULT_NOT_PERMITTED; data.deniedVaultAddresses lists the denied addresses. vaultAddress is case-insensitive.

7.1 order.create​

HL supports MARKET and LIMIT order types.

Request data:

FieldRequiredDescription
vaultAddressYesVault address
symbolYesCoin
sideYesBUY / SELL
orderTypeYesMARKET / LIMIT (other → INVALID_REQUEST)
quantityYesCoin quantity, must be a multiple of sizeStep (0.001)
leverageNoLeverage
isCrossNoCross-margin flag: true = cross / false = isolated; omitted → upstream default
priceLIMIT requiredLimit price, must be a multiple of priceTick (0.1)
maxSlippageBpsMARKET requiredMarket slippage, 1–1000
timeInForceNoe.g. GTC / FrontendMarket
reduceOnlyNodefaults to false

Request example (market buy):

{
"v": "1.0.0",
"type": "request",
"id": "order.create-4",
"op": "order.create",
"ts": 1787623689692,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"symbol": "ETH",
"side": "BUY",
"orderType": "MARKET",
"quantity": "0.01",
"leverage": 1,
"isCross": true,
"maxSlippageBps": "500",
"timeInForce": "FrontendMarket",
"reduceOnly": false
}
}

Response data:

FieldDescription
orderIdHYPERLIQUID:<exchangeOrderId>
exchangeOrderIdUpstream order id (same value used for cancel/query)
statusAlways PENDING on success
acceptedAtUpstream accept time (epoch ms)
encodedDataAlways null for HL (omitted)

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "order.create-4",
"op": "order.create",
"ts": 1787623689692,
"code": "SUCCESS",
"message": "accepted",
"data": {
"orderId": "HYPERLIQUID:525764570803",
"exchangeOrderId": "525764570803",
"status": "PENDING",
"acceptedAt": 1787623689690
}
}

7.2 order.cancel​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
exchangeOrderIdYesexchangeOrderId returned by create/query
symbolYesSame coin as the order

Request example:

{
"v": "1.0.0",
"type": "request",
"id": "order.cancel-3",
"op": "order.cancel",
"ts": 1787625671772,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"exchangeOrderId": "525796683535",
"symbol": "ETH"
}
}

Response data:

FieldDescription
exchangeOrderIdSame as request
statusPENDING (cancel accepted)

Cancel takes effect asynchronously: after acceptance, HL executes the cancellation asynchronously (converges to CANCELED within about 1 minute). The status of the cancel response being PENDING only means the instruction was accepted, not that the order is already canceled; treat order.query or order.updated pushes as the source of truth for the final state.

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "order.cancel-3",
"op": "order.cancel",
"ts": 1787625672636,
"code": "SUCCESS",
"message": "success",
"data": {
"exchangeOrderId": "525796683535",
"status": "PENDING"
}
}

7.3 position.close​

HL close supports full close only: the close request carries no quantity parameter and always closes the entire position of the given symbol.

Request data:

FieldRequiredDescription
vaultAddressYesVault address
symbolYesCoin
maxSlippageBpsYes1–1000

Request example (full close):

{
"v": "1.0.0",
"type": "request",
"id": "position.close-2",
"op": "position.close",
"ts": 1787625645550,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"symbol": "ETH",
"maxSlippageBps": "50"
}
}

Response data:

FieldDescription
orderIdHYPERLIQUID:<exchangeOrderId>
exchangeOrderIdUpstream order id
statusAlways PENDING on success (accepted; the final state is determined by order.query / order.updated)
acceptedAtUpstream accept time (epoch ms)
encodedDataAlways null for HL (omitted)

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "position.close-2",
"op": "position.close",
"ts": 1787625646517,
"code": "SUCCESS",
"message": "accepted",
"data": {
"orderId": "HYPERLIQUID:525796683535",
"exchangeOrderId": "525796683535",
"status": "PENDING",
"acceptedAt": 1787625646517
}
}

7.4 order.query​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
exchangeOrderIdNoExchange order id
limitNo1–100; default 10; out-of-range or non-integer → INVALID_REQUEST (no auto-truncation)
startDate / endDateNoTime range: Unix seconds/milliseconds or yyyy-MM-dd HH:mm:ss; range over 7 days or start > end → INVALID_TIME_RANGE; invalid format → INVALID_REQUEST; omitted = no filter. Effective at day granularity on HL (the server converts to yyyy-MM-dd for the upstream)
symbolNoCoin
statusNoUnified status: PENDING/PARTIALLY_FILLED/FILLED/CANCELED/REJECTED
exchangeNoOmit to merge HL/HT queries, take limit sorted by createdAt descending

Request example:

{
"v": "1.0.0",
"type": "request",
"id": "order.query-2",
"op": "order.query",
"ts": 1787629031208,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"limit": 20
}
}

Response data.orders array, element fields:

FieldDescription
exchangeHYPERLIQUID
symbolCoin
tokenAddressAlways null for HL
exchangeOrderIdExchange id
sideBUY / SELL
orderTypeMARKET / LIMIT / CANCEL, etc.
quantity / filledQuantity / price / averagePrice / feeDecimal strings
statusPENDING / PARTIALLY_FILLED / FILLED / CANCELED / REJECTED
reduceOnlybool
createdAt / updatedAtepoch ms
vaultAddressVault address

Response example (data.orders** array, two shown):**

{
"v": "1.0.0",
"type": "response",
"id": "order.query-2",
"op": "order.query",
"ts": 1787629031344,
"code": "SUCCESS",
"message": "success",
"data": {
"orders": [
{
"exchange": "HYPERLIQUID",
"symbol": "ETH",
"tokenAddress": null,
"exchangeOrderId": null,
"side": null,
"orderType": "CANCEL",
"quantity": null,
"filledQuantity": null,
"price": null,
"averagePrice": null,
"status": "PENDING",
"reduceOnly": false,
"createdAt": 1787625672000,
"updatedAt": 1787625672000,
"fee": null,
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc"
},
{
"exchange": "HYPERLIQUID",
"symbol": "ETH",
"tokenAddress": null,
"exchangeOrderId": "525796683535",
"side": "SELL",
"orderType": "MARKET",
"quantity": "0.018100000000000000",
"filledQuantity": null,
"price": "2507.800000000000000000",
"averagePrice": null,
"status": "FILLED",
"reduceOnly": true,
"createdAt": 1787625646000,
"updatedAt": 1787625650000,
"fee": "0.020528000000000000",
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc"
}
]
}
}

7.5 position.query​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
exchangeNoOmit to merge both exchanges

Request example:

{
"v": "1.0.0",
"type": "request",
"id": "position.query-1",
"op": "position.query",
"ts": 1787637839577,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc"
}
}

Response data.positions array, element fields (contract position):

FieldDescription
exchangeHYPERLIQUID
symbolCoin
tokenAddressAlways null for HL
sideLONG / SHORT (net position direction; SHORT = short)
quantityPosition size (decimal string)
entryPriceEntry price
markPriceAlways null for HL
unrealizedPnlUnrealized PnL
leverage{ type, value, rawUsd } (e.g. { "type": "cross", "value": "20", "rawUsd": null })
marginModeCROSS / ISOLATED
positionValue / marginUsed / returnOnEquity / maxLeverageDecimal strings
exchangeTime / serverTimeUpstream/server time (ms)

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "position.query-1",
"op": "position.query",
"ts": 1787637839823,
"code": "SUCCESS",
"message": "success",
"data": {
"positions": [
{
"exchange": "HYPERLIQUID",
"symbol": "ETH",
"side": "LONG",
"quantity": "0.01",
"entryPrice": "2496.6",
"markPrice": null,
"unrealizedPnl": "0.131",
"leverage": { "type": "cross", "value": "20", "rawUsd": null },
"marginMode": "CROSS",
"positionValue": "25.097",
"marginUsed": "1.25485",
"returnOnEquity": "0.1049427221",
"maxLeverage": "25",
"exchangeTime": null,
"serverTime": 1787637839822
}
]
}
}

8. HABITTRADE Business Endpoints​

HT (HabitTrade, spot rebalancing vault) semantics differ from HL:

  • orderType (MARKET/LIMIT) is an order type, not a direction; direction is determined by side (BUY/SELL).
  • Identify the traded asset with either symbol or tokenAddress (consistent for create/close; when both are provided, both are forwarded upstream; both missing → INVALID_REQUEST).
  • HT does not support cancel → order.cancel returns FEATURE_UNSUPPORTED.
  • Create/close ack carries encodedData (ABI payload to sign; the client signs and submits on-chain); HL always omits it.
  • Precision: sizeStep = 1, priceTick = 0.01.

8.1 order.create​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
symbolEither symbol or tokenAddressAsset symbol
tokenAddressEither symbol or tokenAddressAsset contract address; both missing → INVALID_REQUEST
sideYesBUY / SELL, mapped to upstream action
orderTypeYesMARKET / LIMIT (type, not direction)
quantityYesShare quantity, must be a multiple of sizeStep (1)
maxSlippageBpsYesSlippage 1–1000
priceLIMIT requiredLimit price, must be a multiple of priceTick (0.01)

Request example (market buy):

{
"v": "1.0.0",
"type": "request",
"id": "order.create-ht-1",
"op": "order.create",
"ts": 1787751481326,
"exchange": "HABITTRADE",
"data": {
"vaultAddress": "0xd12041f181cbd0ec66b459de1f5ce219254c62df",
"tokenAddress": "0x804688f293c15a5454586217a69f1cb36c20e15b",
"symbol": "SPCHs",
"side": "BUY",
"quantity": "1",
"orderType": "MARKET",
"maxSlippageBps": "3000"
}
}

Response data:

FieldDescription
orderIdHABITTRADE:<exchangeOrderId>
exchangeOrderIdUpstream order id
statusFixed BUILT on success
acceptedAtUpstream accept time (epoch ms)
encodedDataABI payload to sign (0x… hex); client signs and submits on-chain

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "order.create-ht-1",
"op": "order.create",
"ts": 1787751486434,
"code": "SUCCESS",
"message": "accepted",
"data": {
"orderId": "HABITTRADE:62f917df0a7f483e9783d8fd4b7b8115",
"exchangeOrderId": "62f917df0a7f483e9783d8fd4b7b8115",
"status": "BUILT",
"acceptedAt": 1787751486433,
"encodedData": "0xfca9bd9e0000000000000000000000...(ABI to sign, actual length is longer)"
}
}

8.2 order.cancel — Unsupported​

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "order.cancel-ht-1",
"op": "order.cancel",
"ts": 1787626010000,
"code": "FEATURE_UNSUPPORTED",
"message": "HabitTrade does not support order cancel",
"data": null
}

8.3 position.close​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
symbolEither symbol or tokenAddressAsset to close
tokenAddressEither symbol or tokenAddressContract address of the asset to close; both missing → INVALID_REQUEST
maxSlippageBpsYesSlippage 1–1000
quantityNo"-1" or omitted = full close (only valid for SELL); positive = partial close (must be a multiple of sizeStep)

Request example (full close, quantity omitted):

{
"v": "1.0.0",
"type": "request",
"id": "position.close-ht-1",
"op": "position.close",
"ts": 1787736302848,
"exchange": "HABITTRADE",
"data": {
"vaultAddress": "0xd12041f181cbd0ec66b459de1f5ce219254c62df",
"symbol": "SPCHs",
"tokenAddress": "0x804688f293c15a5454586217a69f1cb36c20e15b",
"maxSlippageBps": "50"
}
}

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "position.close-ht-1",
"op": "position.close",
"ts": 1787736303000,
"code": "SUCCESS",
"message": "accepted",
"data": {
"orderId": "HABITTRADE:62f917df0a7f483e9783d8fd4b7b8115",
"exchangeOrderId": "62f917df0a7f483e9783d8fd4b7b8115",
"status": "BUILT",
"acceptedAt": 1787736302999,
"encodedData": "0xfca9bd9e0000000000000000000000...(ABI to sign)"
}
}

8.4 order.query​

Request data:

FieldRequiredDescription
vaultAddressYesVault address
exchangeOrderIdNoExchange order id (for HT, the transaction hash)
limitNo1–100; default 10; out-of-range or non-integer → INVALID_REQUEST (no auto-truncation)
startDate / endDateNoTime range: Unix seconds/milliseconds or yyyy-MM-dd HH:mm:ss; range over 7 days or start > end → INVALID_TIME_RANGE; invalid format → INVALID_REQUEST; omitted = no filter. Precise to the second on HT
symbolNoAsset contract address
tokenAddressNoAsset contract address
statusNoUnified status

Request example:

{
"v": "1.0.0",
"type": "request",
"id": "order.query-ht-1",
"op": "order.query",
"ts": 1787664502488,
"exchange": "HABITTRADE",
"data": {
"vaultAddress": "0xd12041f181cbd0ec66b459de1f5ce219254c62df",
"limit": 20
}
}

Response data.orders array, element fields:

FieldDescription
exchangeHABITTRADE
symbolAsset contract address
tokenAddressAsset contract address
exchangeOrderIdTransaction hash
sideBUY / SELL
orderTypePassed through from the upstream (uppercase, e.g. MARKET / LIMIT); null if the upstream does not return it
quantity / filledQuantity0
price / averagePriceFill price (same value); decimal strings (no scientific notation)
statusFILLED / PENDING / ERROR / PARTIALLY_FILLED
reduceOnlytrue for SELL, false for BUY
createdAt / updatedAtepoch ms
feeAlways null
vaultAddressVault address

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "order.query-ht-1",
"op": "order.query",
"ts": 1787664502653,
"code": "SUCCESS",
"message": "success",
"data": {
"orders": [
{
"exchange": "HABITTRADE",
"symbol": "0x804688f293c15a5454586217a69f1cb36c20e15b",
"tokenAddress": "0x804688f293c15a5454586217a69f1cb36c20e15b",
"exchangeOrderId": "0x66560a584e68f8f9385d35acb950bfc0285fc3e739b708b8d71d6da628e54e33",
"side": "BUY",
"orderType": null,
"quantity": "0",
"filledQuantity": "0",
"price": null,
"averagePrice": null,
"status": "PENDING",
"reduceOnly": false,
"createdAt": 1787385339000,
"updatedAt": 1787385399000,
"fee": null,
"vaultAddress": "0xd12041f181cbd0ec66b459de1f5ce219254c62df"
}
]
}
}

8.5 position.query​

Request data:

FieldRequiredDescription
vaultAddressYesVault address

Request example:

{
"v": "1.0.0",
"type": "request",
"id": "position.query-ht-1",
"op": "position.query",
"ts": 1787662054736,
"exchange": "HABITTRADE",
"data": {
"vaultAddress": "0xd12041f181cbd0ec66b459de1f5ce219254c62df"
}
}

Response data.positions array, element fields (vault portfolio):

FieldDescription
exchangeHABITTRADE
symbolAsset name
tokenAddressAsset contract address
sideAlways LONG (held by the vault)
quantityCurrent quantity
markPriceCurrent quote
entryPriceEntry price (from upstream)
unrealizedPnl / returnOnEquitynull (spot has none)
leverage{ "type": "cross", "value": "1", "rawUsd": "0" }
marginModeCROSS
positionValueCurrent USD value
marginUsed"0" (spot has no margin)
maxLeverage"1"
exchangeTime / serverTimems

Response example:

{
"v": "1.0.0",
"type": "response",
"id": "position.query-ht-1",
"op": "position.query",
"ts": 1787667041781,
"code": "SUCCESS",
"message": "success",
"data": {
"positions": [
{
"exchange": "HABITTRADE",
"symbol": "USDC",
"tokenAddress": "0xc879c018db60520f4355c26ed1a6d572cdac1815",
"side": "LONG",
"quantity": "16.435225",
"entryPrice": null,
"markPrice": "1",
"unrealizedPnl": null,
"leverage": { "type": "cross", "value": "1", "rawUsd": "0" },
"marginMode": "CROSS",
"positionValue": "16.435225",
"marginUsed": "0",
"returnOnEquity": null,
"maxLeverage": "1",
"exchangeTime": null,
"serverTime": 1787667041780
}
]
}
}

9. Trading Subscription Endpoints​

type=subscribe, op is the topic (order.updated / position.updated), data.vaultAddresses is an array; exchange is HYPERLIQUID or HABITTRADE. Market data subscriptions (market.orderbook) take a different request shape; see Section 10.

9.1 Subscribe Request & ack​

Request example (position subscription):

{
"v": "1.0.0",
"type": "subscribe",
"id": "position.updated-6",
"op": "position.updated",
"ts": 1787638868184,
"exchange": "HYPERLIQUID",
"data": {
"vaultAddresses": ["0x7097732c73b74e94d6e8cc9e77c287757a17afdc"]
}
}

ack (note the ack op is "subscribe", not the topic):

{
"v": "1.0.0",
"type": "subscribe",
"id": "position.updated-6",
"op": "subscribe",
"ts": 1787638868357,
"code": "SUCCESS",
"message": "subscribed",
"data": {
"subscription": {
"subscriptionId": "sub_52a6f1e5-6555-41b2-bc65-19eed11ea9fd",
"exchange": "HYPERLIQUID",
"topic": "position.updated"
}
}
}
  • data.vaultAddresses is required; each address must be operable by the AK. Any without permission → VAULT_NOT_PERMITTED; data.deniedVaultAddresses lists the denied addresses (does not affect existing subscriptions).
  • Re-subscribing the same exchange+topic overwrites the old subscription; only brand-new subscriptions check the limit (≤ 4).
  • For HT subscriptions, change exchange to HABITTRADE; handshake/heartbeat are the same as HL.

9.2 Event Frames​

Position event (one push immediately after subscribing with reason=INITIAL, then every 60s with reason=PERIODIC, one frame per vault, including empty snapshots):

{
"v": "1.0.0",
"type": "event",
"topic": "position.updated",
"subscriptionId": "sub_52a6f1e5-6555-41b2-bc65-19eed11ea9fd",
"seq": 41,
"exchange": "HYPERLIQUID",
"ts": 1787638868358,
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"snapshot": true,
"reason": "INITIAL",
"stale": false,
"asOf": 1787638868357,
"positions": [
{
"exchange": "HYPERLIQUID",
"symbol": "ETH",
"side": "LONG",
"quantity": "0.01",
"entryPrice": "2496.6",
"markPrice": null,
"unrealizedPnl": "0.131",
"leverage": { "type": "cross", "value": "20", "rawUsd": null },
"marginMode": "CROSS",
"positionValue": "25.097",
"marginUsed": "1.25485",
"returnOnEquity": "0.1049427221",
"maxLeverage": "25",
"exchangeTime": null,
"serverTime": 1787638868357
}
]
}
}

Order event (one push immediately after subscribing, then every 60s, only in-flight PENDING/PARTIALLY_FILLED, one frame per vault):

{
"v": "1.0.0",
"type": "event",
"topic": "order.updated",
"subscriptionId": "sub_ac6ab7be-3581-4bb1-83bd-125a9a29ca07",
"seq": 45,
"exchange": "HYPERLIQUID",
"ts": 1787639052680,
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"orders": [
{
"exchange": "HYPERLIQUID",
"symbol": "ETH",
"tokenAddress": null,
"exchangeOrderId": "525796683535",
"side": "BUY",
"orderType": "MARKET",
"quantity": "0.01",
"filledQuantity": null,
"price": "2500.1",
"averagePrice": null,
"status": "PENDING",
"reduceOnly": false,
"createdAt": 1787623689692,
"updatedAt": 1787623689692,
"fee": null,
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc"
}
]
}
}

Position/order event element fields are the same as the corresponding exchange query endpoint: HL as §7.5/§7.4; HT as §8.5/§8.4.

9.3 Unsubscribe​

Request example:

{
"v": "1.0.0",
"type": "unsubscribe",
"id": "unsubscribe-7",
"op": "unsubscribe",
"ts": 1787638961103,
"data": {
"subscriptionIds": ["sub_52a6f1e5-6555-41b2-bc65-19eed11ea9fd"]
}
}

ack:

{
"v": "1.0.0",
"type": "unsubscribe",
"id": "unsubscribe-7",
"op": "unsubscribe",
"ts": 1787638961187,
"code": "SUCCESS",
"message": "unsubscribed",
"data": null
}

10. Market Data Subscription Endpoints (market.orderbook)​

Market data subscriptions run on the same WebSocket endpoint as trading; handshake auth, heartbeat, connection dedup, and the message envelope (§5) are identical — no extra credentials needed. The current market data topic is market.orderbook** (order book)**: data is the upstream order book JSON frame, passed through verbatim with zero processing. Use it for order book / depth scenarios.

  • Market data exchange is HYPERLIQUID (perp/spot) or STOVE (US equities etc.); one exchange per message — cross-source requires separate messages.
  • The two data sources have different upstream order book structures (see §10.2); the subscription request shape is identical, only exchange differs.
  • data allows the single tickers field only — any extra field rejects the whole batch (INVALID_REQUEST).

10.1 Subscribe Request & ack​

Request example:

{
"v": "1.0.0",
"type": "subscribe",
"id": "m-1",
"op": "market.orderbook",
"ts": 1792549000000,
"exchange": "HYPERLIQUID",
"data": {
"tickers": ["BTC@perp", "KNETIQ:kHYPE@perp", "RMD/USDC@spot"]
}
}

Ticker notation:

SourceFormExampleRules
HL perpcoin@perpBTC@perpcoin without : → the main market; HIP-3 form dex:coin@perp (e.g. KNETIQ:kHYPE@perp) routes by the prefix before :
HL spotsymbol@spotRMD/USDC@spotsymbol is the spot pair name (e.g. RMD/USDC); the legacy @N index form is deprecated
STOVEsymbol@marketAAPLs@usexmarket ∈ usex / hkex / krex (case-insensitive)
  • Neither side of @ may be empty; suffixes are case-insensitive (@PERP / @Spot both accepted).
  • An invalid ticker rejects the whole batch (INVALID_REQUEST, message names the offending item). A valid ticker with no data yet (not launched / not covered) is accepted without error and waits silently.
  • Note the ack's op is the topic itself (market.orderbook) and carries a snapshots array:
{
"v": "1.0.0",
"type": "subscribe",
"id": "m-1",
"op": "market.orderbook",
"ts": 1792549000012,
"code": "SUCCESS",
"message": "subscribed",
"data": {
"subscription": {
"subscriptionId": "sub_…",
"exchange": "HYPERLIQUID",
"topic": "market.orderbook",
"tickers": ["BTC@perp", "KNETIQ:kHYPE@perp", "RMD/USDC@spot"]
},
"snapshots": [
{"ticker": "BTC@perp", "state": "pushed"},
{"ticker": "KNETIQ:kHYPE@perp", "state": "pending"},
{"ticker": "RMD/USDC@spot", "state": "pending"}
]
}
}

snapshots[].state:

stateMeaning
pushedData already available at acceptance; the first frame was delivered with the ack
pendingAccepted but no data yet (new symbol / not covered upstream); frames arrive later, no error and no alert
alreadyIdempotent re-subscribe (same exchange+ticker+topic already live on this connection); echoes the existing subscription, no new quota

10.2 Market Data Event Frames​

Same event envelope as §5.3 (seq increases within the connection, resets on reconnect; envelope ts is the server write time). data is the upstream order book JSON frame, verbatim with zero processing; the two data sources have different structures.

HYPERLIQUID (upstream l2Book, full snapshot):

{
"v": "1.0.0",
"type": "event",
"topic": "market.orderbook",
"subscriptionId": "sub_…",
"seq": 9,
"exchange": "HYPERLIQUID",
"ts": 1793334791900,
"data": {
"channel": "l2Book",
"data": {
"coin": "BSV",
"time": 1793334790833,
"levels": [
[
{"px": "17.017", "sz": "0.75", "n": 1},
{"px": "17.008", "sz": "22.63", "n": 2},
{"px": "16.994", "sz": "81.68", "n": 2},
{"px": "16.983", "sz": "5.0", "n": 1}
],
[
{"px": "17.033", "sz": "0.75", "n": 1},
{"px": "17.040", "sz": "594.3", "n": 1},
{"px": "17.048", "sz": "451.11", "n": 2},
{"px": "17.051", "sz": "367.6", "n": 1}
]
]
}
}
}
FieldDescription
data.channelAlways l2Book (upstream channel name)
data.data.coinCoin: for perp, the subscribed coin (HIP-3 with DEX prefix, e.g. xyz:RELIANCE); for spot, the spot pair name (e.g. META/USDC)
data.data.timeUpstream frame time (epoch ms)
data.data.levels[0]Bids, sorted by price descending
data.data.levels[1]Asks, sorted by price ascending
Level element px / szPrice / size, decimal strings
Level element nNumber of aggregated orders at the price level (integer)
  • The HL order book is a full snapshot (not a delta): each push is the entire book. The example shows an excerpt; real frames usually carry up to 20 levels per side. Spot books may be sparse with fewer levels. Frames can be large — mind client processing performance.
  • The nested data.data structure is exactly the upstream l2Book frame; the server does not parse or rewrite it.

STOVE:

{
"v": "1.0.0",
"type": "event",
"topic": "market.orderbook",
"subscriptionId": "sub_…",
"seq": 7,
"exchange": "STOVE",
"ts": 1793330910200,
"data": {
"type": "orderbook",
"data": {
"symbol": "DELTON",
"market": "hkex",
"bid": [{"price": 61.25, "volume": 200}],
"ask": [{"price": 61.4, "volume": 1600}],
"time": "2026-10-30T03:28:30Z"
}
}
}
FieldDescription
data.typeAlways orderbook (upstream message type; distinct from the subscription-side op name)
data.data.symbol / marketSymbol / market code (usex / hkex / krex)
data.data.bidBids
data.data.askAsks
Element price / volumePrice / size as JSON numbers (note HL uses strings — the two sources parse differently)
data.data.timeISO-8601 UTC timestamp string (second granularity)
  • The STOVE book is an object array; the structure is exactly the message from the upstream orderbooks channel, passed through with zero processing.

10.3 Push Semantics & Frame Size​

  • Push on change only: the server compares cached data versions on a ~100ms cadence and pushes only when the data changed; the same version is never pushed twice. Frame cadence follows upstream updates; no fixed frequency is guaranteed.
  • Downlink frame limit defaults to 64 KiB; oversized frames are dropped silently (no disconnect, no alert to the client). If an order book stream stops for a symbol, check single-frame overflow first (a larger per-client limit can be requested from the platform).
  • Slow-consumer protection: when the server write times out (default 10s) or the send buffer overflows (default 512 KiB), slow clients are disconnected proactively.
  • Unsubscribe on disconnect: all market subscriptions are revoked when the connection drops; resubscribe after reconnect (idempotent re-subscription is safe — you may simply replay the full subscription list).

10.4 Unsubscribe​

Same shape as subscribe with type=unsubscribe and the topic as op; revoked per ticker; repeated unsubscribes are idempotent:

{
"v": "1.0.0",
"type": "unsubscribe",
"id": "m-2",
"op": "market.orderbook",
"ts": 1792549001000,
"exchange": "HYPERLIQUID",
"data": {"tickers": ["BTC@perp"]}
}

After unsubscription the ticker no longer produces events. Market data does not use the subscriptionIds form from §9.3.

10.5 Limits & Idempotency​

ItemValue
Max tickers per subscribe50 (over-limit rejects the whole batch, no partial acceptance)
Market subscriptions per connection200 (configurable per client)
Duplicate tickers in an arrayDeduplicated automatically; quota counts post-dedup
Idempotency key(exchange, ticker, topic); repeated subscribes return already and consume no new quota
Relation to trading subscriptionsThe market subscription pool is independent of the trading pool (4) in §6 — they do not share quota

Error codes for market subscriptions reuse Section 12: INVALID_REQUEST (invalid ticker / disallowed data fields / missing ts), INVALID_EXCHANGE (exchange not HYPERLIQUID/STOVE), TOO_MANY_SUBSCRIPTIONS (quota exceeded), REQUEST_TS_STALE (ts not strictly increasing), FEATURE_UNSUPPORTED (market feature disabled, during gray release).

11. marginCall.alert — Server-Initiated Margin Warning Push​

No subscription required. After the connection is established, the server polls in the background (every ~10 seconds) all HYPERLIQUID vaults operable by the current AK and pushes through the priority channel when triggered; it does not count against the §6 subscription quota.

11.1 Tiers & Trigger Conditions​

Tiering is based on the margin level: marginLevel = margin / maintenance margin.

marginLevel rangeTierPushed?
> 1.25SAFENo
(1.15, 1.25]TIER_1Yes
(1.05, 1.15]TIER_2Yes
≤ 1.05TIER_3Yes
  • Cross (CROSS) margin mode is tiered per vault as a whole; isolated (ISOLATED) is tiered independently per vault × symbol × side.
  • When any position reaches its liquidation price, the vault is considered in liquidation: pushes stop and do not auto-resume after liquidation; state resets on reconnect.
  • Push cadence: a tier change (including the first transition from SAFE into a warning tier) → pushed immediately (triggerType=TIER_CHANGED); an unchanged tier → pushed every 30 minutes (triggerType=PERIODIC); returning to SAFE is not pushed.
  • The maintenance margin is derived by the platform from positions; it may differ from upstream values or your own computation.

11.2 Event Frame​

topic is fixed marginCall.alert, subscriptionId is fixed margincall, exchange is always HYPERLIQUID.

Cross example:

{
"v": "1.0.0",
"type": "event",
"topic": "marginCall.alert",
"subscriptionId": "margincall",
"seq": 128,
"exchange": "HYPERLIQUID",
"ts": 1787638868358,
"data": {
"vaultAddress": "0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"marginMode": "CROSS",
"marginLevel": "1.20",
"tier": "TIER_1",
"triggerType": "TIER_CHANGED",
"margin": "0.602",
"maintenanceMargin": "0.50194",
"triggeredAt": 1787638868358,
"positions": [
{
"symbol": "ETH",
"side": "LONG",
"quantity": "0.01",
"entryPrice": "2496.6",
"unrealizedPnl": "0.131",
"marginUsed": "0.602",
"maintenanceMargin": "0.50194"
}
]
}
}

Isolated frames additionally carry symbol / side (the position that triggered the push) and positions contains only that position; cross frames include all cross positions of the vault.

11.3 data Fields​

FieldDescription
vaultAddressVault address
marginModeCROSS / ISOLATED
symbol / sideIsolated only: the triggering position's symbol/side; omitted for cross
marginLevelMargin level (decimal string, e.g. "1.20"; omitted when there is no position)
tierCurrent tier TIER_1 / TIER_2 / TIER_3
triggerTypeTIER_CHANGED (tier change) / PERIODIC (unchanged-tier periodic)
marginMargin: cross = account margin; isolated = the position's isolated margin
maintenanceMarginMaintenance margin (platform-computed): cross = sum over positions; isolated = the single position
triggeredAtTrigger timestamp (epoch ms)
positions[]Related positions (cross = all cross positions of the vault, isolated = the triggering position only). Element fields: symbol / side / quantity / entryPrice / unrealizedPnl / marginUsed / maintenanceMargin, same conventions as the position fields in §7.5

All tier state is cleared when the connection drops and is not inherited on reconnect; after reconnecting, a vault still in a warning tier is pushed again as a tier change.

12. Error Codes​

codemessage
SUCCESSSuccess
INVALID_REQUESTInvalid request: JSON/field/business param missing or invalid
UPLINK_TOO_LARGEUplink frame exceeds the limit; shrink and resend
REQUEST_TS_STALERequest ts is stale or repeated
VAULT_NOT_PERMITTEDvaultAddress is not operable
INVALID_EXCHANGEUnsupported exchange
EXCHANGE_NOT_CONFIGUREDExchange account or required credentials not configured
SYMBOL_NOT_SUPPORTEDTrading pair/topic not enabled
TOO_MANY_INFLIGHT_REQUESTSIn-flight request limit reached for this connection
DUPLICATE_REQUEST_IDRequest ID reused on this connection
TOO_MANY_SUBSCRIPTIONSSubscription limit reached for this connection
RATE_LIMITToo many requests; retry later
DUPLICATE_REQUEST_CONFLICTSame clientOrderId with different request content
ORDER_NOT_FOUNDOrder not found
INVALID_TIME_RANGEHistorical order time range invalid or span exceeds 7 days
INSUFFICIENT_MARGINInsufficient margin or balance
INVALID_PRICE_TICKInvalid price precision
INVALID_SIZE_STEPInvalid quantity precision
REDUCE_ONLY_REJECTEDReduce-only request would increase the position, or the position has changed
EXCHANGE_REJECTEDExchange business rejection
UPSTREAM_TIMEOUTRetryable query request timeout
UPSTREAM_RESULT_UNKNOWNWrite result unknown; you must query first
UPSTREAM_UNAVAILABLEExchange temporarily unavailable
FEATURE_UNSUPPORTEDThe exchange does not support the requested capability
INTERNAL_ERRORUnexpected error

13. Order Status​

Unified statusDescription
PENDINGOrder accepted, not yet filled
PARTIALLY_FILLEDPartially filled (upstream may not have this status; OpenAPI maps it)
FILLEDFully filled
CANCELEDCanceled
EXPIREDExpired or terminated due to TTL (status retained, currently unused)
REJECTEDRejected by the exchange
ERRORGeneric error, used when the precise error cannot be distinguished

The order.updated event pushes only in-flight statuses: PENDING and PARTIALLY_FILLED (in-flight snapshot, re-sent every cycle, see 9.2). Terminal states (FILLED / CANCELED / REJECTED / ERROR) are not actively pushed — an order disappearing from the in-flight list is the terminal signal; the final state value should be obtained via order.query.

14. Java Connection Example​

Demo: connect + handshake signing + limit order + proactive Pong keepalive. AK masked, private key not printed. Pure JDK (java.net.http.WebSocket requires JDK 11+, Ed25519 requires JDK 15+, HexFormat requires JDK 17+).

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class HlWsExample {
private static final String CLIENT_ID = "ak_xxxxxxxxxxxxxxxxxxxxxxxxx";
private static final String PRIV_B64 = "<ed25519 PKCS#8 private key, base64(PEM body, no header/footer)>"; // SK, never log
private static final String BASE = "wss://<host>";
private static final String PATH = "/api/openapi/v1/ws";
private WebSocket ws;
private final Object sendLock = new Object();
private CompletableFuture<WebSocket> lastSend = null;
private final ScheduledExecutorService keepalive = Executors.newSingleThreadScheduledExecutor();

public static void main(String[] args) throws Exception {
PrivateKey sk = loadEd25519(PRIV_B64);
String ts = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString();
String signature = HexFormat.of().formatHex(
sign(sk, (CLIENT_ID + "\n" + ts + "\n" + nonce).getBytes(StandardCharsets.UTF_8)));
String url = BASE + PATH + "?clientId=" + enc(CLIENT_ID) + "&timestamp=" + ts
+ "&nonce=" + enc(nonce) + "&signature=" + signature;
HlWsExample c = new HlWsExample();
WebSocket ws = HttpClient.newHttpClient().newWebSocketBuilder().buildAsync(URI.create(url), c.listener()).join();
c.ws = ws;
Thread.sleep(45_000);
c.keepalive.shutdownNow();
ws.sendClose(WebSocket.NORMAL_CLOSURE, "bye").get();
}

WebSocket.Listener listener() {
return new WebSocket.Listener() {
@Override public void onOpen(WebSocket webSocket) {
System.out.println("[open]"); HlWsExample.this.ws = webSocket; webSocket.request(1);
// limit order example
sendText("""
{"v":"1.0.0","type":"request","id":"order.create-4","op":"order.create",
"ts":1787623689692,"exchange":"HYPERLIQUID","data":{
"vaultAddress":"0x7097732c73b74e94d6e8cc9e77c287757a17afdc",
"symbol":"ETH","side":"BUY","orderType":"LIMIT","quantity":"0.01",
"price":"2500.1","leverage":"20","timeInForce":"GTC","reduceOnly":false}}""");
keepalive.scheduleAtFixedRate(HlWsExample.this::sendPong, 2, 20, TimeUnit.SECONDS);
}
@Override public CompletionStage<?> onText(WebSocket w, CharSequence data, boolean last) {
System.out.println("[rx] " + data); w.request(1); return CompletableFuture.completedStage(null);
}
@Override public CompletionStage<?> onClose(WebSocket w, int code, String reason) {
System.out.println("[close] code=" + code + ", reason=" + reason); return null;
}
@Override public void onError(WebSocket w, Throwable e) { System.err.println("[error] " + e); }
};
}

private void sendText(String text) {
synchronized (sendLock) {
CompletableFuture<WebSocket> base = (lastSend == null) ? CompletableFuture.completedFuture(ws) : lastSend;
lastSend = base.thenCompose(w -> w.sendText(text, true));
}
}
private void sendPong() {
try { synchronized (sendLock) {
CompletableFuture<WebSocket> base = (lastSend == null) ? CompletableFuture.completedFuture(ws) : lastSend;
lastSend = base.thenCompose(w -> w.sendPong(ByteBuffer.wrap("ka".getBytes(StandardCharsets.UTF_8))));
} } catch (Exception e) { System.err.println("pong err: " + e); }
}
private static PrivateKey loadEd25519(String base64) throws Exception {
return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64)));
}
private static byte[] sign(PrivateKey sk, byte[] data) throws Exception {
Signature s = Signature.getInstance("Ed25519"); s.initSign(sk); s.update(data); return s.sign();
}
private static String enc(String v) { return URLEncoder.encode(v, StandardCharsets.UTF_8); }
}

Note: timestamp uses Unix seconds; nonce is recommended to be a UUID; signature is the 128-char lowercase hex of the Ed25519 signature. The server sends Pings periodically; the client is advised to also send Pongs proactively to avoid being closed after 60s with no Pong (4002). Only one active connection is kept per AK; a new connection causes the old one to be closed (4001).