Realtime Data
SSE and WebSocket protocols for live market data on Liquidity.io
Liquidity.io provides realtime market data through Server-Sent Events (SSE) using the hanzo/base protocol, with WebSocket support for legacy compatibility.
SSE Connection (Primary)
The primary realtime channel uses SSE via the /api/realtime endpoint. SSE is simpler than WebSocket, works through proxies and load balancers, and automatically reconnects on failure.
Connect
GET /api/realtime
Authorization: Bearer {iam_access_token}
Accept: text/event-streamBase URL: https://api.{env}.satschel.com/api/realtime
Protocol
The SSE stream uses the hanzo/base event protocol. Each event has a type field and a JSON data payload:
event: quote
data: {"assetId":"AAPL","last":249.93,"bid":249.87,"ask":250.01,"volume":979956,"timestamp":"2026-03-19T00:34:44Z"}
event: book
data: {"assetId":"AAPL","bids":[{"price":249.87,"size":100}],"asks":[{"price":250.01,"size":100}],"timestamp":"2026-03-19T00:34:45Z"}
event: trade
data: {"assetId":"AAPL","price":249.93,"size":50,"side":"buy","timestamp":"2026-03-19T00:34:44Z"}Subscribe
After connecting, send subscription messages by appending query parameters:
GET /api/realtime?subscribe=quote:AAPL,book:AAPL,trade:BTCOr subscribe to all updates for an asset:
GET /api/realtime?subscribe=all:AAPLTypeScript Client
const BASE_URL = 'https://api.stage.satschel.com';
function connectRealtime(token: string, assets: string[]) {
const subscriptions = assets.map(a => `all:${a}`).join(',');
const url = `${BASE_URL}/api/realtime?subscribe=${subscriptions}`;
const eventSource = new EventSource(url, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
eventSource.addEventListener('quote', (event) => {
const quote = JSON.parse(event.data);
console.log(`${quote.assetId}: $${quote.last} (${quote.changePercent}%)`);
});
eventSource.addEventListener('book', (event) => {
const book = JSON.parse(event.data);
console.log(`${book.assetId} book: ${book.bids.length} bids, ${book.asks.length} asks`);
});
eventSource.addEventListener('trade', (event) => {
const trade = JSON.parse(event.data);
console.log(`${trade.assetId} trade: ${trade.size} @ $${trade.price}`);
});
eventSource.addEventListener('order', (event) => {
const order = JSON.parse(event.data);
console.log(`Order ${order.orderId} status: ${order.status}`);
});
eventSource.onerror = (error) => {
console.error('SSE connection error, reconnecting...');
// EventSource automatically reconnects
};
return eventSource;
}
// Usage
const es = connectRealtime(accessToken, ['AAPL', 'BTC', 'ETH']);
// Cleanup
es.close();React Hook
import { useEffect, useState, useRef } from 'react';
interface Quote {
assetId: string;
last: number;
bid: number;
ask: number;
volume: number;
change: number;
changePercent: number;
timestamp: string;
}
function useRealtimeQuote(assetId: string, token: string): Quote | null {
const [quote, setQuote] = useState<Quote | null>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
const url = `${BASE_URL}/api/realtime?subscribe=quote:${assetId}`;
const es = new EventSource(url);
es.addEventListener('quote', (event) => {
const data = JSON.parse(event.data);
if (data.assetId === assetId) {
setQuote(data);
}
});
esRef.current = es;
return () => {
es.close();
};
}, [assetId, token]);
return quote;
}
// Usage in component
function PriceTicker({ assetId }: { assetId: string }) {
const quote = useRealtimeQuote(assetId, accessToken);
if (!quote) return <div>Loading...</div>;
return (
<div>
<span>{quote.assetId}</span>
<span>${quote.last.toFixed(2)}</span>
<span className={quote.changePercent >= 0 ? 'green' : 'red'}>
{quote.changePercent.toFixed(2)}%
</span>
</div>
);
}Event Types
quote
Emitted when the price of a subscribed asset changes.
{
"assetId": "AAPL",
"last": 249.93,
"bid": 249.87,
"ask": 250.01,
"open": 252.61,
"high": 254.91,
"low": 249.01,
"prevClose": 254.23,
"change": -4.30,
"changePercent": -1.69,
"volume": 979956,
"timestamp": "2026-03-19T00:34:44Z"
}book
Emitted when the order book changes for a subscribed asset.
{
"assetId": "AAPL",
"bids": [
{ "price": 249.87, "size": 100 },
{ "price": 249.50, "size": 250 },
{ "price": 249.25, "size": 500 }
],
"asks": [
{ "price": 250.01, "size": 100 },
{ "price": 250.25, "size": 150 },
{ "price": 250.50, "size": 300 }
],
"timestamp": "2026-03-19T00:34:45Z"
}trade
Emitted when a trade executes on a subscribed asset.
{
"assetId": "AAPL",
"tradeId": "trd_abc123",
"price": 249.93,
"size": 50,
"side": "buy",
"timestamp": "2026-03-19T00:34:44Z"
}order (authenticated)
Emitted when the status of one of the user's orders changes. Requires authentication.
{
"orderId": "ord_a1b2c3d4e5f6",
"assetId": "AAPL",
"side": "BUY",
"orderType": "limit",
"quantity": 10,
"filledQuantity": 5,
"averagePrice": 249.90,
"status": "PARTIALLY_FILLED",
"timestamp": "2026-03-19T00:35:02Z"
}WebSocket (Legacy)
WebSocket is supported for backwards compatibility with existing integrations. New integrations should use SSE.
Connect
wss://api.{env}.satschel.com/wsMessage Format
All WebSocket messages use JSON:
{
"type": "message_type",
"data": {},
"timestamp": 1710807360
}Subscribe to Order Book
{
"type": "subscribe",
"channel": "orderbook",
"symbol": "BTC-USD",
"depth": 20
}Response (snapshot):
{
"type": "orderbook_snapshot",
"symbol": "BTC-USD",
"bids": [[83400, 1.5], [83399, 2.0]],
"asks": [[83401, 1.2], [83402, 0.8]],
"timestamp": 1710807360
}Incremental updates:
{
"type": "orderbook_update",
"symbol": "BTC-USD",
"bids": [[83400, 1.8]],
"asks": [[83401, 0]],
"timestamp": 1710807361
}A size of 0 means the price level has been removed.
Subscribe to Trades
{
"type": "subscribe",
"channel": "trades",
"symbol": "BTC-USD"
}Response:
{
"type": "trade",
"symbol": "BTC-USD",
"price": 83421.50,
"size": 0.5,
"side": "buy",
"timestamp": 1710807360
}Unsubscribe
{
"type": "unsubscribe",
"channel": "orderbook",
"symbol": "BTC-USD"
}WebSocket TypeScript Client
class RealtimeClient {
private ws: WebSocket;
private handlers: Map<string, ((data: any) => void)[]> = new Map();
constructor(private url: string) {
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const callbacks = this.handlers.get(msg.type) ?? [];
for (const cb of callbacks) {
cb(msg);
}
};
}
subscribe(channel: string, symbol: string, depth?: number): void {
this.ws.send(JSON.stringify({
type: 'subscribe',
channel,
symbol,
...(depth ? { depth } : {}),
}));
}
on(type: string, handler: (data: any) => void): void {
const existing = this.handlers.get(type) ?? [];
existing.push(handler);
this.handlers.set(type, existing);
}
close(): void {
this.ws.close();
}
}
// Usage
const client = new RealtimeClient('wss://api.stage.satschel.com/ws');
client.subscribe('orderbook', 'BTC-USD', 20);
client.subscribe('trades', 'AAPL');
client.on('orderbook_snapshot', (msg) => {
console.log(`${msg.symbol} book: ${msg.bids.length} bids, ${msg.asks.length} asks`);
});
client.on('trade', (msg) => {
console.log(`${msg.symbol}: ${msg.size} @ ${msg.price}`);
});WebSocket Error Handling
{
"type": "error",
"error": {
"code": "SUBSCRIPTION_FAILED",
"message": "Failed to subscribe to channel",
"channel": "orderbook",
"symbol": "INVALID-PAIR"
}
}Polling Fallback
If SSE and WebSocket are unavailable, use REST polling as a fallback:
| Data | Endpoint | Interval |
|---|---|---|
| Quote | GET /v1/assets/{id}/quote | 5 seconds |
| Order book | GET /v1/assets/{id}/book | 3 seconds |
| Order status | GET /v1/orders/{id} | 5 seconds |
The frontend uses polling by default and upgrades to SSE when available.
Connection Limits
| Channel | Limit |
|---|---|
| SSE connections per user | 5 |
| WebSocket connections per user | 5 |
| Subscriptions per connection | 50 |
| Message rate (inbound, WebSocket) | 100 messages/second |