Websocket
Subscribe to real-time prices, funding, trades, trigger events, and your own account events over the V3 gateway WebSocket.
The V3 gateway exposes a real-time WebSocket at:
wss://pro-gateway.pearprotocol.io/wsIt is a raw JSON-over-WebSocket protocol (not Socket.IO). Send JSON text frames; binary frames are rejected.
Authentication
Authenticate on the handshake. The connection takes its identity from the upgrade request and keeps it for life. A connection that opens anonymously stays anonymous, and user_events rejects it.
| Client | How to authenticate |
|---|---|
| Headless (server, bot, CLI) | Send Authorization: Bearer <accessToken> on the upgrade request. |
| Browser | Rely on the access_token httpOnly cookie, which the browser attaches for you. |
There is no query-parameter token. ?token= is ignored, and the connection opens anonymously.
The access token is the one POST /auth/login returns. See Authentication.
Two credentials the socket refuses:
- API keys. The socket never reads
x-api-key. Use an access token. - OAuth grant tokens. A token minted for an OAuth grant is rejected, so a third-party agent cannot open a private stream. Use the REST endpoints instead.
There is no in-band login. A { "method": "auth" } frame is accepted by the parser but never authenticates: a valid token answers AUTH_RECONNECT_REQUIRED and closes the socket with 1013, and an invalid one answers UNAUTHENTICATED. Reconnect with the token on the handshake.
The SDK ships no WebSocket client. Use any WebSocket library and the protocol below.
Control messages (client → server)
| Method | Shape | Purpose |
|---|---|---|
subscribe | { "method": "subscribe", "channel": "<channel>", "params": { … } } | Subscribe to a channel. |
unsubscribe | { "method": "unsubscribe", "channel": "<channel>", "params": { … } } | Unsubscribe. Omit params to drop the whole channel; on a public channel, pass connectors, instrumentIds, or accountIds to remove only those. user_events always unsubscribes in full. |
ping | { "method": "ping" } | Optional round-trip; the server replies pong. |
Each message may carry an id, echoed back on the matching ack, error, or pong. A numeric id is echoed as a string, so 42 comes back as "42".
ping is not the heartbeat. The server sends a protocol-level WebSocket ping every heartbeatIntervalMs, and terminates a connection that does not answer it. A JSON ping is a separate round-trip that costs one of your 120 control messages per minute.
Channels
Public (no authentication):
| Channel | Params | Streams |
|---|---|---|
prices | { connectors, instrumentIds? } | Mid / mark price updates. |
funding | { connectors, instrumentIds? } | Funding-rate updates. |
trades | { connectors, instrumentIds? } | Public trade prints. |
trigger_events | { connectors, accountIds? } | TP/SL brackets that fired and filled, across the whole venue. |
connectors is required on every public channel. Pass at least one of hyperliquid, binance, bybit, okx, lighter. An empty or missing list is rejected with INVALID_SUBSCRIPTION.
instrumentIds filters prices and funding only. On trades it is accepted but filters nothing, and still counts against your subscription-key budget.
trigger_events is public and not scoped to you. accountIds is a filter on the venue's own account identifiers, matched case-insensitively; omit it to receive every account. Anyone can subscribe to any account.
Private (requires a handshake-authenticated connection):
| Channel | Params | Streams |
|---|---|---|
user_events | { topics? } (defaults to all) | Your execution, position, schedule, ladder, trigger, rebalance, and notification events. |
user_events is scoped to the user, not to a trade account. A user with several trade accounts receives one merged stream, and the event does not name the account.
Subscribing to user_events on an anonymous connection returns UNAUTHENTICATED.
Server → client frames
Every frame carries a type discriminator. Branch on it first.
type | Shape |
|---|---|
welcome | { type, connectionId, heartbeatIntervalMs, resubscribeRequired: true }, sent once, immediately after the connection is admitted. |
ack | { id?, type, method, channel?, ok: true }, confirms a control message. |
event | { type, channel, topic, data }, the actual payload. |
pong | { id?, type, timestamp }, reply to ping. |
error | { id?, type, code, message }. |
resubscribeRequired is always true. The server keeps no subscription state across connections, so replay every subscription after each welcome.
Event topics
user_events emits these topics (subscribe to a subset with params.topics):
- Executions,
execution.completed,execution.failed - Positions,
position.created,position.updated,position.closed - Schedules (TWAP),
schedule.created,schedule.completed,schedule.failed,schedule.cancelled - Ladders,
ladder.created,ladder.updated,ladder.cancelled - Triggers,
trigger.created,trigger.triggered,trigger.cancelled,trigger.converted - Rebalance,
rebalance.created,rebalance.updated,rebalance.executed,rebalance.paused,rebalance.cancelled - Notifications,
notification.created
There is no fill, balance, or account topic. Fills come from GET /fills.
user_events payloads are pointers
A private event carries only the entity id:
{ "type": "event", "channel": "user_events", "topic": "execution.failed", "data": { "entityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" } }Treat it as a notification, not as data. To learn what happened, GET the entity: an execution id from execution.*, a position id from position.*, and so on. See Error Handling.
Public payloads
Public channels emit four topics, and these carry the data inline.
| Channel | topic | data |
|---|---|---|
prices | price.updated | { connector, source, prices }, where prices maps instrument ID to a price object. |
funding | funding.updated | { connector, funding }, where funding maps instrument ID to a funding object. |
trades | trade.executed | { connector, source, intent, orderType, timestamp, clientId?, legs }. source is DIRECT, TWAP, or TRIGGER; intent is OPEN, ADJUST, CLOSE, or REVERSE; orderType is always MARKET. |
trigger_events | trigger.triggered.public | { connector, accountId, triggerId, bracketType, semanticBracketType?, intent, condition, triggerLevel, timestamp, legs }. bracketType is STOP_LOSS or TAKE_PROFIT; triggerLevel may be null. |
Each entry of legs is { symbol, side, reduceOnly, quantity, price }, with quantity and price as decimal strings and symbol as the instrument ID.
trigger.triggered.public is the only topic trigger_events carries. It is published when a take-profit or stop-loss bracket fires and fills at least once. Trigger creation, cancellation, and conversion are private, and reach you on user_events as trigger.created, trigger.cancelled, and trigger.converted.
Example
Connect with the token on the handshake:
import WebSocket from 'ws';
const socket = new WebSocket('wss://pro-gateway.pearprotocol.io/ws', {
headers: { Authorization: `Bearer ${accessToken}` },
});The server answers with welcome. Then subscribe:
{
"id": 1,
"method": "subscribe",
"channel": "user_events",
"params": {
"topics": ["execution.completed", "execution.failed", "position.created", "position.updated", "position.closed"]
}
}A public subscription must name its connectors:
{
"id": 2,
"method": "subscribe",
"channel": "prices",
"params": { "connectors": ["hyperliquid"], "instrumentIds": ["0", "1"] }
}Error codes
An error frame carries a stable code. Branch on it rather than on message.
code | Meaning |
|---|---|
INVALID_JSON | The frame is not valid JSON. |
INVALID_MESSAGE | The JSON is not a control message. |
INVALID_FRAME | A binary frame arrived. The socket then closes with 1003. |
INVALID_SUBSCRIPTION | The params were rejected, most often an empty connectors. |
TOO_MANY_SUBSCRIPTIONS | The subscription would exceed 100 keys. The channel is left with no subscription at all, so resubscribe with fewer keys. |
UNAUTHENTICATED | user_events on an anonymous connection, or an auth frame with an invalid token. |
AUTH_RECONNECT_REQUIRED | An auth frame with a valid token. Reconnect with it on the handshake. The socket then closes with 1013. |
RATE_LIMITED | More than 120 control messages in a minute. The socket then closes with 1008. |
Close codes
1008 and 1013 are the two that matter. Stop on 1008 and fix the client; reconnecting repeats the fault. Retry 1013 with backoff; it is always temporary or instructional.
| Code | Reason | What to do |
|---|---|---|
1001 | The server is shutting down. | Reconnect. |
1003 | A binary frame arrived. | Stop. Send text frames only. |
1006 | Heartbeat timeout: the client did not answer a protocol-level ping. | Reconnect. |
1008 | Control-message rate limit exceeded. | Stop. Reduce the message rate before reconnecting. |
1013 | capacity:<reason>, authentication requires reconnect, client too slow, or invalid client identity. | Retry with backoff. |
client too slow means your receive buffer passed 512 KB. Consume frames faster, or subscribe to fewer keys.
Polling equivalents
If you cannot hold a socket open, every stream has a REST endpoint you can poll instead. Prefer the socket: a poll returns the whole list every time, and lags the event.
| Topic | Poll instead |
|---|---|
execution.* | GET /executions, GET /executions/{id} |
position.* | GET /positions |
trigger.* | GET /triggers |
schedule.* | GET /schedules |
ladder.* | GET /ladders |
rebalance.* | GET /rebalance/{positionId}/events |
notification.created | GET /notifications |
price.updated | GET /prices |
funding.updated | GET /funding |
Limits
- Max frame 64 KB, max buffered 512 KB (exceeding the buffer closes the socket with
1013) - 120 control messages per minute (exceeding this closes the socket with
1008) - 100 subscription keys per connection
- Connections: 10 anonymous per client IP, 20 per authenticated user, 500 per gateway replica
A connection rejected for capacity closes with 1013 capacity:<reason>, where reason is anonymous_ip_limit, user_limit, or replica_capacity. Authenticating raises your ceiling from 10 to 20.