Guide
WebSocket market data: stream stocks, forex and crypto in real time
One socket, one JSON subscribe message, every asset class. The bugs live in the parts most docs skim: the ready frame, the snapshot, prices sent as strings and the close code.
On this page
- WebSocket authentication with the apiKey parameter
- WebSocket subscribe messages and acknowledgements
- Snapshot on subscribe: true, false or "always"
- Channels: one stock WebSocket API for every asset class
- Anatomy of a real frame
- Heartbeats, close codes and reconnecting
- Error frames leave the socket open
- Limits, compression and slow consumers
- Questions
Key takeaways
- Connect to `wss://stream.tickerlayer.com/?apiKey=...`, wait for the `ready` frame, then send a JSON `subscribe` message naming channels and symbols.
- Subscribing replays the last recent value per symbol, marked `"snapshot": true`; send `snapshot: false` for live ticks only, or `"always"` to get an old value on a closed market.
- Crypto, forex, stocks and perpetuals frames carry prices as strings, so convert every numeric field explicitly and read time from `ts` in Unix milliseconds.
- Close 1012 is a planned release and 1006 a network reset: reconnect with jittered backoff, resubscribe everything and let the snapshot restore the latest value.
- A rejected subscribe returns an error frame and leaves the socket open, so fix the request instead of reconnecting.
A stock WebSocket API keeps one connection open and pushes each quote and trade as it happens, instead of waiting for you to ask. With TickerLayer the protocol fits in four steps: open wss://stream.tickerlayer.com/?apiKey=YOUR_API_KEY, wait for the ready frame, send a JSON subscribe message that names channels and symbols, then read frames. The same socket streams stocks, forex, crypto, indices, ETFs, commodities, government bond yields and perpetual futures.
This guide is the reference for that protocol, built from frames captured on the live stream. If you are still deciding whether you need a stream at all, WebSocket vs REST covers the trade-off, and the market data API explainer shows how symbols, units and timestamps fit together. If you want working code first, the Python WebSocket client builds a complete streaming client on top of everything below.
- Open the socketwss URL with ?apiKey
- readyfirst system frame
- subscribechannels and symbols
- Snapshotlast recent value
- Live framesquotes, trades, bars
WebSocket authentication with the apiKey parameter
The stream authenticates once, during the HTTP upgrade, through the apiKey query parameter. The name is case-sensitive (api_key gets a 401), the value should be URL-encoded, and there is no login message inside the stream: by the time the first frame arrives you are already authenticated. REST uses an x-api-key header instead, because a browser cannot attach custom headers to a WebSocket upgrade.
The endpoint speaks plain WebSocket, not Socket.IO, so any standard client works: the browser WebSocket, ws in Node.js, websockets in Python. A minimal Node.js session:
// npm install ws
import WebSocket from "ws";
const ws = new WebSocket(
"wss://stream.tickerlayer.com/?apiKey=" + encodeURIComponent(process.env.TICKERLAYER_API_KEY),
{ perMessageDeflate: false }
);
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "system" && msg.event === "ready") {
ws.send(JSON.stringify({ action: "subscribe", channels: ["crypto.quotes", "crypto.trades"], symbols: ["BTCUSD"] }));
return;
}
console.log(JSON.stringify(msg));
});
ws.on("close", (code) => console.log("closed with", code));{"type":"system","event":"subscribed","channels":["crypto.quotes","crypto.trades"],"symbols":["BTCUSD"]}
{"type":"trade","channel":"crypto.trades","asset":"crypto","symbol":"BTCUSD","price":"82850","size":"0.01105","ts":1790591203601}
{"type":"trade","channel":"crypto.trades","asset":"crypto","symbol":"BTCUSD","price":"82819.16","size":"3e-8","ts":1790591203755}
{"type":"quote","channel":"crypto.quotes","asset":"crypto","symbol":"BTCUSD","bid":"82849.99","ask":"82850","bid_size":"15.50507","ask_size":"1.87429","ts":1790591203836}If the upgrade fails, you get a plain HTTP status and no frames at all. That is the first branch your client needs, because two of these statuses will never fix themselves:
| Status | What it means | What to do |
|---|---|---|
| 101 | Upgrade accepted. The ready frame follows. | Wait for ready, then subscribe. |
| 401 | Missing, invalid or misnamed apiKey. | Fix the key. Retrying will not help. |
| 403 | The key is valid but the account has no WebSocket access for this data. | Change the plan, not the code. |
| 429 | Too many open connections for the account: WS_CONNECTION_LIMIT_EXCEEDED. | Close a connection you forgot about, or add capacity. |
| 503 | The auth check timed out, or a release is swapping the serving process. Carries Retry-After. | Wait that many seconds, then reconnect. |
WebSocket subscribe messages and acknowledgements
Send nothing before ready. After it, every request is a JSON text message with an action. A subscribe names one or more channels and one or more symbols, and the server answers with a subscribed acknowledgement that echoes what it accepted. The echo is normalised (trimmed, uppercased, de-duplicated and sorted), so compare it with your request as a set, not as an array.
- GET /?apiKey=... (Upgrade: websocket)Your client to TickerLayer stream
- 101 Switching ProtocolsTickerLayer stream to Your client
- {"type":"system","event":"ready"}TickerLayer stream to Your client
- {"action":"subscribe","channels":[...],"symbols":[...]}Your client to TickerLayer stream
- {"event":"subscribed",...}normalised and sortedTickerLayer stream to Your client
- snapshot framesone per symbol with a recent valueTickerLayer stream to Your client
- live quote and trade framesTickerLayer stream to Your client
- {"action":"ping"}Your client to TickerLayer stream
- {"type":"system","event":"pong","ts":...}TickerLayer stream to Your client
- {"action":"unsubscribe",...}Your client to TickerLayer stream
- {"event":"unsubscribed",...}TickerLayer stream to Your client
{"action": "subscribe", "channels": ["crypto.quotes", "crypto.trades"], "symbols": ["BTCUSD"]}
{"type": "system", "event": "subscribed", "channels": ["crypto.quotes", "crypto.trades"], "symbols": ["BTCUSD"]}channelsis a non-empty array;symbolsis an array or a comma-separated string.- One message takes at most 500 symbols. Send several messages for more; subscriptions add up.
unsubscribetakes the same shape and answersunsubscribed. A symbol stays live on every channel you did not name.- Optional fields:
snapshot(next section),intervalandsessionforstocks.aggonly, andtrade_mode: "full_volume"as an opt-in for US stocks. {"action":"ping"}answers{"type":"system","event":"pong","ts":...}: a cheap way to prove the server is still reading you.
Snapshot on subscribe: true, false or "always"
By default, right after the acknowledgement the stream sends one last-known quote and/or trade for every symbol that has a recent value. Snapshot frames have the same shape as live ones plus "snapshot": true, and their ts is the original event time, not the send time. The point is a number on screen immediately instead of an empty cell until the next tick.
| snapshot value | What arrives after the acknowledgement | Use it when |
|---|---|---|
true (default) | The last recent quote and/or trade per symbol, marked "snapshot": true. | You want a value on screen right away. |
false | Live ticks only. The first frame is a genuinely new event. | An old value would be wrong: an alert, or an oracle that must not act on a stale price. |
"always" | The cached value even when it is old, still marked as a snapshot. | You develop against a closed market, or a dashboard should show last night's price. |
Three rules decide whether a snapshot exists at all. US and Asian equities and ETFs replay only a value from roughly the last two minutes, so subscribing to US:KO before the open delivers nothing until the first live tick: exactly the case "always" is for. stocks.status and stocks.agg never replay, because a halt or a settled bar is a statement about something that happens after you subscribe. bonds.quotes always replays, since a daily yield observation stays current until the next one.
Crypto and forex trade continuously, so their first frames are usually ordinary live ticks. When you need a last price at any hour with certainty, ask REST: GET /stocks/snapshot/US:KO answers with the last known value whether the market is open or not.
Channels: one stock WebSocket API for every asset class
A channel is an asset family plus a message kind: forex.quotes, stocks.trades, indices.quotes. Symbols follow the same conventions as REST, so a symbol that works on GET /forex/quote/EURUSD works on forex.quotes. Everything below runs over the same connection. A stock WebSocket API that covered only equities would force a second socket for currencies and a third for coins; here the same connection doubles as a forex WebSocket API and a crypto WebSocket API, and the channel name does the routing.
| Family | Channels | Example | Numbers arrive as | Notes |
|---|---|---|---|---|
| Crypto | crypto.quotes, crypto.trades | BTCUSD | strings | Streams around the clock. See the crypto price API guide. |
| Forex | forex.quotes, forex.trades | EURUSD | strings | Six-letter pairs, no separator. |
| Stocks | stocks.quotes, stocks.trades | US:KO | strings | Market prefix required: a bare KO is rejected. |
| Stock bars | stocks.agg | US:KO | numbers | Settled US OHLCV bars from 1m to 1d. |
| Halts | stocks.status | US:KO | no prices | US halt and resume events. |
| Indices | indices.quotes, indices.trades | US500 | numbers, sometimes strings | Carries ts plus a mirror timestamp. |
| ETFs | etfs.quotes, etfs.trades | US500ETF | numbers, sometimes strings | Unprefixed codes. |
| Commodities | commodities.quotes, commodities.trades | XAUUSD | numbers, sometimes strings | SUGARUSD is quoted in US cents per pound. |
| Bond yields | bonds.quotes | US:10Y | numbers | One frame per daily observation. Needs the bonds add-on. |
| Perpetuals | perpetuals.quotes, perpetuals.marks, perpetuals.trades | XAUUSDT | strings | Streaming comes with the Business perpetuals plan. |
The stock-bar channel is the one that behaves differently. stocks.agg pushes closed OHLCV bars only, takes an interval (1m, 5m, 15m, 1h, 4h or 1d) and a session (regular or extended), and never replays: bars that settled while you were disconnected come from GET /stocks/agg. The candlestick chart tutorial combines REST history with a live last candle.
Anatomy of a real frame
A crypto quote, as captured from the stream
{
"type": "quote",1
"channel": "crypto.quotes",2
"asset": "crypto",
"symbol": "BTCUSD",
"bid": "82849.99",3
"ask": "82850",
"bid_size": "15.50507",
"ask_size": "1.87429",
"ts": 17905912038364
}
typeWhat kind of frame this is:quote,trade,mark,rate,status,agg,systemorerror. Route on it first.channelThe channel it belongs to, useful when one symbol is on several channels.bidA string, not a number. Convert withNumber()orfloat(), or a decimal type for money.tsEvent time in Unix milliseconds, UTC. On a snapshot frame it can be minutes or hours old.
The string encoding is deliberate: "82850" survives any JSON parser without float surprises, and crypto sizes can arrive in exponent form ("3e-8"). Indices, ETFs and commodities mostly send numbers, but some of their frames arrive as strings too, so the only safe rule is to convert every numeric field whatever the channel. REST, by contrast, always sends JSON numbers.
{"type":"quote","channel":"stocks.quotes","asset":"stocks","symbol":"US:KO","bid":"88.11","ask":"88.3","bid_size":"400","ask_size":"200","ts":1790590887491,"snapshot":true}
{"type":"quote","channel":"indices.quotes","asset":"indices","symbol":"US500","bid":7702.17,"ask":7706.03,"bid_size":98,"ask_size":88,"ts":1790591201606,"timestamp":1790591201606,"snapshot":true}
{"type":"rate","channel":"bonds.quotes","asset":"bonds","symbol":"US:10Y","rate":5.17,"unit":"percent","date":"2026-09-25","prev_rate":5.18,"prev_date":"2026-09-24","change":-0.01,"change_bps":-1,"change_percent":-0.1931,"ts":1790294400000,"timestamp":1790294400000,"snapshot":true}Read time from ts on every family; the extra timestamp on indices, ETFs, commodities and bonds is a mirror. The bond frame shows why the time field matters: its ts is the observation date at UTC midnight, days older than the moment it arrived. If milliseconds since 1970 still trip you up, the Unix timestamp guide converts them in every language.
Heartbeats, close codes and reconnecting
The server sends native WebSocket ping frames, and every mainstream client library answers them with pongs on its own. If your process stops answering, usually because synchronous work is blocking its event loop, the server sends {"type":"system","event":"disconnect","code":"HEARTBEAT_TIMEOUT"} and closes with 4008. A quiet symbol is not a problem; missed heartbeats are.
| Close code | What happened | What to do |
|---|---|---|
| 1000 | Normal close, usually one you asked for. | Reconnect only if you still want data. |
| 1006 | The connection died without a close frame: a reset somewhere on the network path, not a server decision. | Reconnect with backoff and jitter. |
| 1012 | A planned release. A SERVER_RESTART system frame arrives first. | Reconnect right away with a small random delay, then resubscribe. |
| 4008 | Your client stopped answering heartbeats (HEARTBEAT_TIMEOUT). | Reconnect, then find what blocked your event loop. |
| Other 4000-range codes | An account-level decision, for example access that ended. | Log it loudly and back off to your maximum delay. |
Access is also rechecked on open connections, so a downgraded plan or an ended trial closes the socket instead of letting it run on. Whatever the cause, recovery is the same five steps:
- Wait, with jitterStart near one second, double on each failure, cap around 30 seconds, and randomise part of every delay so a crowd of clients does not return in lockstep.
- Reconnect to the same URLNothing survives the close: there is no resume token and no replay of what you missed.
- Wait for readySend nothing until the new socket delivers its
readyframe. - Resubscribe everythingKeep your subscriptions in one list and resend it in full. The server does not remember them.
- Fill the gapThe snapshot restores the latest value. Bars that closed while you were away come from
GET /{asset}/agg/....
The WebSocket reconnect tutorial turns these steps into a Node.js and browser client, including detection of half-open sockets that never deliver a close code at all.
Error frames leave the socket open
A request the server cannot honour produces an error frame, and the connection stays up. A rejected subscribe is a problem in the request, so reconnecting only repeats it. Every error frame has type: "error", a machine-readable code and a message; a failed subscribe adds event: "subscribe_failed" and lists the refused pairs in rejected. This one was captured by subscribing to a symbol that does not exist:
An error frame for an unknown symbol
{
"type": "error",
"event": "subscribe_failed",1
"code": "INVALID_SYMBOL",2
"message": "invalid symbol",
"channel": "stocks.quotes",
"symbol": "NOTREAL",
"rejected": [3
{
"channel": "stocks.quotes",
"symbol": "NOTREAL",
"code": "INVALID_SYMBOL",
"message": "invalid symbol"
}
]
}
eventsubscribe_failed, orsubscribe_partial_failedwhen some symbols were accepted.codeBranch on this, never on the message text.rejectedOne entry per refused channel and symbol pair. Everything not listed here is live.
| Code | Cause | Fix |
|---|---|---|
INVALID_SYMBOL | The symbol is not enabled for that channel, or has a typo. | Check it with GET /{asset}/symbols; stocks need a market prefix such as US:. |
UNSUPPORTED_CHANNEL | The channel name does not exist. | Compare it with the channel table above. |
INVALID_CHANNEL | The channel exists but is not in your plan. | Add the feed, or stop requesting it. |
TOO_MANY_SYMBOLS | More than 500 symbols in one message. | Split the list across several messages. |
SYMBOL_LIMIT_EXCEEDED | The distinct-symbol cap of your plan is reached. | Unsubscribe something, or raise the cap. |
MARKET_DATA_CAPACITY | A temporary capacity limit. | Retry that subscribe later. |
PACKAGE_EXPIRED | The entitlement ended while you were connected. | Renew; the connection may close. |
The full list, including the rarer codes, is in the WebSocket troubleshooting reference.
Limits, compression and slow consumers
- 500symbols per subscribe message
- 1 / 10connections per feed, Individual / Business
- 10 / unlimitedstreamed symbols, Individual / Business
The distinct-symbol cap is account-wide, counted across every connection and channel, and the connection cap is enforced at the upgrade with a 429. That makes it the stream's version of a rate limit, and the backoff mechanics are the same as for REST, covered in 429 Too Many Requests. The free tier is REST only, with 3,000 requests a month; WebSocket comes with paid plans, and a free account can request a WebSocket trial from its dashboard. Plan details are on the pricing page.
The stream sends uncompressed frames. Turn per-message compression off in your client (perMessageDeflate: false in Node's ws, compression=None in Python websockets) so your process never spends time inflating frames on a busy stream, and keep the message handler fast: hand heavy work to a queue or a worker instead of doing it inside the socket callback. A client that cannot keep up falls further behind with every second, and eventually misses the heartbeats that keep it connected.
Do
- Wait for
readybefore the first subscribe. - Keep one list of subscriptions and resend it after every reconnect.
- Convert every numeric field; read time from
ts. - Share one connection per process across all your symbols.
- Backfill missed bars over REST after a gap.
Avoid
- Opening one socket per symbol: you will hit the connection cap.
- Reconnecting after an error frame; the request itself is wrong.
- Treating a snapshot frame as a new trade.
- Parsing, storing and charting inside the message callback.
- Logging the connection URL, which contains the key.
Signed-in accounts can also open a real stream from the WebSocket docs page and watch ticks update in place, which is the quickest way to confirm that a symbol and channel pair works before you write any code.
Questions
What is a stock WebSocket API?
It is a single long-lived connection over which the server pushes quotes and trades as they happen, so you do not poll. You subscribe to channels such as stocks.quotes for symbols such as US:KO, and each update arrives as a JSON frame.
How do I authenticate a WebSocket connection?
Pass the key in the apiKey query parameter of the connection URL, URL-encoded: wss://stream.tickerlayer.com/?apiKey=YOUR_API_KEY. There is no login message after connecting; a bad key fails the upgrade with HTTP 401.
Is there a free WebSocket API for real-time stock data?
The TickerLayer free tier covers REST, with 3,000 requests a month. WebSocket streaming comes with paid plans, and a free account can request a WebSocket trial from its dashboard.
Why do WebSocket prices arrive as strings?
Crypto, forex, stocks and perpetuals frames send numeric fields as strings so no JSON parser rounds them. Convert them explicitly with Number(), float() or a decimal type.
How many symbols can I subscribe to on one connection?
One subscribe message takes up to 500 symbols, and you can send several. The total is capped by your plan: 10 streamed symbols on Individual and unlimited on Business, counted across all your connections.
Can I use a WebSocket and a REST API together?
Yes, and most production clients do: REST for history, backfill and an on-demand last price, the WebSocket for everything that changes while you watch.