Pear Exchange

WebSocket

Topic-based pub/sub stream for live trades, prices, orderbooks, orders, portfolio, and notifications.

WebSocket

Realtime data is delivered through a single topic-based WebSocket endpoint on the same host as the REST API.

EnvironmentURL
Productionwss://api.pear.trade/ws
Stagingwss://api-staging.pear.trade/ws
Local devws://localhost:3000/ws

The connection is multiplexed — one socket can subscribe to many topics. Routing is in-process (no Redis hop), so server-side fan-out is the same memory budget as a typical pub/sub bus.

Connecting

const ws = new WebSocket("wss://api.pear.trade/ws?token=<privy-access-token>");

The token query param is optional. When supplied it must be a valid Privy access token; the server resolves it to a userId so private topics (portfolio:{userId}, orders:{userId}) and per-user notify delivery work.

Invalid tokens are silently treated as anonymous — the socket still opens, you just can't subscribe to private topics.

Connection limits

  • Authenticated: 5 simultaneous connections per userId.
  • Anonymous: 100 globally shared connections.

When a cap is hit the server frames the rejection before closing:

{ "type": "error", "code": "connection_limit", "reason": "per_user_connection_limit" }

…and closes with WebSocket code 1013 ("try again later").

Welcome frame

Immediately after a successful upgrade the server sends:

{ "type": "welcome", "userId": "did:privy:abc..." }

userId is null for anonymous sockets.

Protocol

All client→server frames are JSON with an action field. All server→client frames are JSON with a type field (control) or a topic field (data push).

Subscribe

{ "action": "subscribe", "topics": ["trades:polymarket:0x...", "event:trump-2028"] }

topic (singular string) is also accepted.

Server ack:

{
  "type": "subscribed",
  "topics": ["trades:polymarket:0x...", "event:trump-2028"],
  "rejected": [{ "topic": "portfolio:other-user", "reason": "forbidden" }]
}

rejected is omitted when empty. Reasons:

ReasonMeaning
invalid_topicTopic doesn't match any known prefix.
forbiddenPrivate topic (portfolio:, orders:) belonging to another user.

Unsubscribe

{ "action": "unsubscribe", "topics": ["trades:polymarket:0x..."] }

Server ack:

{ "type": "unsubscribed", "topics": ["event:trump-2028"] }

topics here is the remaining subscription set, not the topics you just dropped.

Data frames

Every push is shaped:

{ "topic": "<topic-string>", "data": <payload> }

The shape of data depends on the topic — see the table below.

Topics

Topic patternAuthPayloadNotes
trades:{venue}:{marketId}publicVenue trade tickOne per fill. venuepolymarket | kalshi | dflow.
prices:{venue}:{marketId}public{ price, bid?, ask?, last?, ts }Mid when available, else best last/bid/ask.
orderbook:{venue}:{marketId}publicSnapshot or deltaCaller controls shape; clients should handle both.
event:{eventSlug}publicTrade or priceAggregated stream — fans in every market under the event.
game_state:{pearEventId}publicScore / period / status deltaSports markets only. Initial snapshot fires on subscribe.
social:activitypublic{ kind, userId?, postId?, ... }FYP firehose: post created, auto-trade post, copytrade.
notifyper-userGeneric notificationOnly delivered to clients authenticated as the target user.
orders:{userId}owner onlyOrder lifecycle (placed → routing → filled / canceled)Foreign subscribes rejected with forbidden.
portfolio:{userId}owner onlyMark-to-market equity (~5s)Foreign subscribes rejected with forbidden.

Snapshot-on-subscribe

Some domains (notably game_state:) push an immediate snapshot when you subscribe so late joiners don't wait for the next delta. The snapshot arrives as a normal { topic, data } frame after the subscribed ack — no extra opt-in needed.

Examples

Follow a single market's trade tape

const ws = new WebSocket("wss://api.pear.trade/ws");

ws.onopen = () => {
  ws.send(JSON.stringify({
    action: "subscribe",
    topics: ["trades:polymarket:0xabc...", "prices:polymarket:0xabc..."],
  }));
};

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "welcome" || msg.type === "subscribed") return;
  console.log(msg.topic, msg.data);
};

Live portfolio + private order stream

const userId = "did:privy:abc123";
const ws = new WebSocket(`wss://api.pear.trade/ws?token=${accessToken}`);

ws.onopen = () => {
  ws.send(JSON.stringify({
    action: "subscribe",
    topics: [`portfolio:${userId}`, `orders:${userId}`, "notify"],
  }));
};

If accessToken doesn't resolve to userId, both portfolio: and orders: subscribes will come back in the rejected array with reason: "forbidden", and notify will silently never deliver.

Operational notes

  • No reconnect logic on the server side. If the socket drops the client must reconnect and re-subscribe; the server keeps no per-user subscription persistence.
  • Backpressure. Sends use the underlying ws.send directly. Slow consumers may drop frames at the OS buffer level — the server does not queue.
  • Heartbeat. None at the protocol level. Browser WebSocket keep-alives and intermediary proxies handle liveness; if you're behind a proxy with an aggressive idle timeout, send your own application ping (any valid frame counts) every ~30s.
  • Malformed frames are silently dropped — invalid JSON or unknown actions don't error back.

On this page