WebSocket
Real-time feeds for order books, public trades, candles, mark price, session volume, IPO allocation progress, competition leaderboard, and your private portfolio deltas.
URL: wss://api.loafmarkets.com/ws (derive from your REST host by replacing /api with /ws and https with wss).
For a ready-made client, see the Python SDK template .
Ask once vs listen
REST is ask once (a photo of current state or history). WebSocket is listen (what just changed — or, for some channels, a continuous full picture).
- Full picture channels (
orderbook,trades,leaderboard) keep sending the complete state. Subscribe and stop polling REST. - Delta channels (
markprice,volume,chart,portfolio, andipo) only send changes. Call the matching REST endpoint once to load current state, then listen — otherwise you sit empty until something moves.
See API Reference for the shared pattern, and the REST pages for each seed endpoint.
Connection & auth
- Connect to
wss://<host>/ws. - For private channels, send an
authframe with your API key. - Send
subscribewith one or more channel strings. - Handle incoming frames by
type.
Auth frame:
{
"type": "auth",
"token": "<LOAF_API_KEY>",
"timestamp": 1718000000
}Subscribe frame:
{
"type": "subscribe",
"channels": ["orderbook:opera", "portfolio:3"],
"timestamp": 1718000000
}You may also unsubscribe with the same shape. Server replies include auth_result, subscription_confirmed, and error frames when something fails.
Public channels work without auth. The private portfolio:{userId} channel requires a valid API key and a userId that matches the authenticated account (see Get your userId). Market-data channels use lowercase tokenName (e.g. orderbook:opera), not numeric propertyId and not the ticker. Payloads may still include numeric propertyId.
Get your userId
The private portfolio:{userId} channel needs your numeric Loaf user id. That id must belong to the same account as the API key. Call GET /api/auth/profile once and copy userId:
import axios from 'axios';
const response = await axios.get('https://api.loafmarkets.com/api/auth/profile', {
headers: { Authorization: `Bearer ${process.env.LOAF_API_KEY}` },
});
const userId = response.data.userId;Put that number in LOAF_USER_ID and subscribe to portfolio:{userId}.
Channels
| Channel | Auth | Stream | Message type | Payload |
|---|---|---|---|---|
orderbook:{tokenName} | public | Full picture | orderbook_update | Full bid/ask snapshot (~500ms) |
trades:{tokenName} | public | Full picture | trades_batch | Rolling recent-trades batch |
chart:{tokenName} | public | Deltas | candle_update | OHLCV candle updates (channel name is singular chart) |
markprice:{tokenName} | public | Deltas | mark_price | Canonical mark price (about 1s, on change) |
volume:{tokenName} | public | Deltas | volume_update | Session-to-date settled volume (volume24h), on settlement |
ipo:{ipoId} | public | Deltas | ipo_allocation_update | Primary-market allocation progress |
leaderboard | public | Full picture | leaderboard_update | Full competition leaderboard, on change |
portfolio:{userId} | private | Deltas | see below | Your account deltas |
Portfolio message types
The private channel is a delta stream. Seed open orders, fills, balances, and positions from REST (History, Portfolio), then apply these frames — you do not get a full portfolio snapshot on every tick:
type | Meaning |
|---|---|
balances_update | Cash / frozen balances changed |
position_update | Position quantity or entry changed |
order_status | Order reached a terminal or intermediate status |
order_update | Order fields changed (e.g. remaining quantity) |
trade_new | Your fill |
lifetime_volume_update | Your lifetime traded volume changed |
transfer_update | Cash or token transfer into/out of your account |
offering_order_update | IPO subscription status (PENDING / ALLOCATED / REJECTED) |
To value positions live, combine position_update with the markprice channel. The server does not push recomputed portfolio totals on every price tick. Seed mark price and volume24h from Trade (GET /api/trade), then replace them from mark_price / volume_update frames. Seed chart history from candle REST, then apply candle_update frames.
Example: order book
{
"type": "orderbook_update",
"propertyId": 42,
"bids": [{ "price": 124.0, "quantity": 5.0, "orderId": 8801 }],
"asks": [{ "price": 126.0, "quantity": 3.0, "orderId": 8802 }]
}Example: public trades batch
{
"type": "trades_batch",
"propertyId": 42,
"trades": [
{
"tradeId": 9001,
"propertyId": 42,
"aggressorSide": "BUY",
"price": 125.5,
"quantity": 2.0,
"timestamp": 1718000000
}
]
}Example: mark price
{
"type": "mark_price",
"propertyId": 42,
"price": 125.25
}Example: session volume
{
"type": "volume_update",
"propertyId": 42,
"volume24h": 1204.5
}Example: your fill (private)
{
"type": "trade_new",
"trade": {
"tradeId": 9002,
"propertyId": 42,
"side": "BUY",
"quantity": 1.0,
"price": 125.5
}
}Client example
const ws = new WebSocket('wss://api.loafmarkets.com/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'auth',
token: process.env.LOAF_API_KEY,
timestamp: Math.floor(Date.now() / 1000),
}));
ws.send(JSON.stringify({
type: 'subscribe',
channels: ['orderbook:opera', `portfolio:${process.env.LOAF_USER_ID}`],
timestamp: Math.floor(Date.now() / 1000),
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(String(event.data));
if (msg.type === 'orderbook_update') {
console.log('book', msg.bids?.[0], msg.asks?.[0]);
}
if (msg.type === 'trade_new') {
console.log('filled', msg.trade);
}
};A REST 200 on create-order means the exchange accepted the order. It does not mean the order filled. Fills and cancellations arrive asynchronously on portfolio:{userId} (trade_new, order_status, order_update).