PearPear
API Integration

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/ws

It 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.

ClientHow to authenticate
Headless (server, bot, CLI)Send Authorization: Bearer <accessToken> on the upgrade request.
BrowserRely 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)

MethodShapePurpose
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):

ChannelParamsStreams
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):

ChannelParamsStreams
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.

typeShape
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.

Channeltopicdata
pricesprice.updated{ connector, source, prices }, where prices maps instrument ID to a price object.
fundingfunding.updated{ connector, funding }, where funding maps instrument ID to a funding object.
tradestrade.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_eventstrigger.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.

codeMeaning
INVALID_JSONThe frame is not valid JSON.
INVALID_MESSAGEThe JSON is not a control message.
INVALID_FRAMEA binary frame arrived. The socket then closes with 1003.
INVALID_SUBSCRIPTIONThe params were rejected, most often an empty connectors.
TOO_MANY_SUBSCRIPTIONSThe subscription would exceed 100 keys. The channel is left with no subscription at all, so resubscribe with fewer keys.
UNAUTHENTICATEDuser_events on an anonymous connection, or an auth frame with an invalid token.
AUTH_RECONNECT_REQUIREDAn auth frame with a valid token. Reconnect with it on the handshake. The socket then closes with 1013.
RATE_LIMITEDMore 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.

CodeReasonWhat to do
1001The server is shutting down.Reconnect.
1003A binary frame arrived.Stop. Send text frames only.
1006Heartbeat timeout: the client did not answer a protocol-level ping.Reconnect.
1008Control-message rate limit exceeded.Stop. Reduce the message rate before reconnecting.
1013capacity:<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.

TopicPoll 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.createdGET /notifications
price.updatedGET /prices
funding.updatedGET /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.

On this page