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.
| Environment | URL |
|---|---|
| Production | wss://api.pear.trade/ws |
| Staging | wss://api-staging.pear.trade/ws |
| Local dev | ws://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:
| Reason | Meaning |
|---|---|
invalid_topic | Topic doesn't match any known prefix. |
forbidden | Private 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 pattern | Auth | Payload | Notes |
|---|---|---|---|
trades:{venue}:{marketId} | public | Venue trade tick | One per fill. venue ∈ polymarket | kalshi | dflow. |
prices:{venue}:{marketId} | public | { price, bid?, ask?, last?, ts } | Mid when available, else best last/bid/ask. |
orderbook:{venue}:{marketId} | public | Snapshot or delta | Caller controls shape; clients should handle both. |
event:{eventSlug} | public | Trade or price | Aggregated stream — fans in every market under the event. |
game_state:{pearEventId} | public | Score / period / status delta | Sports markets only. Initial snapshot fires on subscribe. |
social:activity | public | { kind, userId?, postId?, ... } | FYP firehose: post created, auto-trade post, copytrade. |
notify | per-user | Generic notification | Only delivered to clients authenticated as the target user. |
orders:{userId} | owner only | Order lifecycle (placed → routing → filled / canceled) | Foreign subscribes rejected with forbidden. |
portfolio:{userId} | owner only | Mark-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.senddirectly. 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.