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
  1. WebSocket authentication with the apiKey parameter
  2. WebSocket subscribe messages and acknowledgements
  3. Snapshot on subscribe: true, false or "always"
  4. Channels: one stock WebSocket API for every asset class
  5. Anatomy of a real frame
  6. Heartbeats, close codes and reconnecting
  7. Error frames leave the socket open
  8. Limits, compression and slow consumers
  9. 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.

  1. Open the socketwss URL with ?apiKey
  2. readyfirst system frame
  3. subscribechannels and symbols
  4. Snapshotlast recent value
  5. Live framesquotes, trades, bars
The whole client lifecycle. Only the first step is HTTP; everything after it is JSON text frames.

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:

first-stream.mjsJavaScript
// 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));
Output (first frames, trimmed)JSON
{"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:

StatusWhat it meansWhat to do
101Upgrade accepted. The ready frame follows.Wait for ready, then subscribe.
401Missing, invalid or misnamed apiKey.Fix the key. Retrying will not help.
403The key is valid but the account has no WebSocket access for this data.Change the plan, not the code.
429Too many open connections for the account: WS_CONNECTION_LIMIT_EXCEEDED.Close a connection you forgot about, or add capacity.
503The auth check timed out, or a release is swapping the serving process. Carries Retry-After.Wait that many seconds, then reconnect.
Upgrade outcomes. Treat every status except 401 and 403 as retryable.

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.

Your clientTickerLayer stream
  1. GET /?apiKey=... (Upgrade: websocket)Your client to TickerLayer stream
  2. 101 Switching ProtocolsTickerLayer stream to Your client
  3. {"type":"system","event":"ready"}TickerLayer stream to Your client
  4. {"action":"subscribe","channels":[...],"symbols":[...]}Your client to TickerLayer stream
  5. {"event":"subscribed",...}normalised and sortedTickerLayer stream to Your client
  6. snapshot framesone per symbol with a recent valueTickerLayer stream to Your client
  7. live quote and trade framesTickerLayer stream to Your client
  8. {"action":"ping"}Your client to TickerLayer stream
  9. {"type":"system","event":"pong","ts":...}TickerLayer stream to Your client
  10. {"action":"unsubscribe",...}Your client to TickerLayer stream
  11. {"event":"unsubscribed",...}TickerLayer stream to Your client
One session from open to unsubscribe. The upgrade is the only HTTP exchange.
subscribe, then the acknowledgementJSON
{"action": "subscribe", "channels": ["crypto.quotes", "crypto.trades"], "symbols": ["BTCUSD"]}

{"type": "system", "event": "subscribed", "channels": ["crypto.quotes", "crypto.trades"], "symbols": ["BTCUSD"]}
  • channels is a non-empty array; symbols is an array or a comma-separated string.
  • One message takes at most 500 symbols. Send several messages for more; subscriptions add up.
  • unsubscribe takes the same shape and answers unsubscribed. A symbol stays live on every channel you did not name.
  • Optional fields: snapshot (next section), interval and session for stocks.agg only, and trade_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 valueWhat arrives after the acknowledgementUse it when
true (default)The last recent quote and/or trade per symbol, marked "snapshot": true.You want a value on screen right away.
falseLive 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.
The three snapshot modes. The field is optional; leaving it out means true.

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.

FamilyChannelsExampleNumbers arrive asNotes
Cryptocrypto.quotes, crypto.tradesBTCUSDstringsStreams around the clock. See the crypto price API guide.
Forexforex.quotes, forex.tradesEURUSDstringsSix-letter pairs, no separator.
Stocksstocks.quotes, stocks.tradesUS:KOstringsMarket prefix required: a bare KO is rejected.
Stock barsstocks.aggUS:KOnumbersSettled US OHLCV bars from 1m to 1d.
Haltsstocks.statusUS:KOno pricesUS halt and resume events.
Indicesindices.quotes, indices.tradesUS500numbers, sometimes stringsCarries ts plus a mirror timestamp.
ETFsetfs.quotes, etfs.tradesUS500ETFnumbers, sometimes stringsUnprefixed codes.
Commoditiescommodities.quotes, commodities.tradesXAUUSDnumbers, sometimes stringsSUGARUSD is quoted in US cents per pound.
Bond yieldsbonds.quotesUS:10YnumbersOne frame per daily observation. Needs the bonds add-on.
Perpetualsperpetuals.quotes, perpetuals.marks, perpetuals.tradesXAUUSDTstringsStreaming comes with the Business perpetuals plan.
Public channel families. The US overnight add-on adds an overnight.quotes channel. A channel outside your plan answers INVALID_CHANNEL.

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
}
  1. typeWhat kind of frame this is: quote, trade, mark, rate, status, agg, system or error. Route on it first.
  2. channelThe channel it belongs to, useful when one symbol is on several channels.
  3. bidA string, not a number. Convert with Number() or float(), or a decimal type for money.
  4. tsEvent time in Unix milliseconds, UTC. On a snapshot frame it can be minutes or hours old.
A live BTCUSD quote frame. The spread here is one cent.

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.

Three more captured framesJSON
{"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 codeWhat happenedWhat to do
1000Normal close, usually one you asked for.Reconnect only if you still want data.
1006The connection died without a close frame: a reset somewhere on the network path, not a server decision.Reconnect with backoff and jitter.
1012A planned release. A SERVER_RESTART system frame arrives first.Reconnect right away with a small random delay, then resubscribe.
4008Your client stopped answering heartbeats (HEARTBEAT_TIMEOUT).Reconnect, then find what blocked your event loop.
Other 4000-range codesAn account-level decision, for example access that ended.Log it loudly and back off to your maximum delay.
The close codes you will meet in production.

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:

  1. 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.
  2. Reconnect to the same URLNothing survives the close: there is no resume token and no replay of what you missed.
  3. Wait for readySend nothing until the new socket delivers its ready frame.
  4. Resubscribe everythingKeep your subscriptions in one list and resend it in full. The server does not remember them.
  5. 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"
    }
  ]
}
  1. eventsubscribe_failed, or subscribe_partial_failed when some symbols were accepted.
  2. codeBranch on this, never on the message text.
  3. rejectedOne entry per refused channel and symbol pair. Everything not listed here is live.
Captured from the live stream. The socket kept streaming the other subscriptions.
CodeCauseFix
INVALID_SYMBOLThe 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_CHANNELThe channel name does not exist.Compare it with the channel table above.
INVALID_CHANNELThe channel exists but is not in your plan.Add the feed, or stop requesting it.
TOO_MANY_SYMBOLSMore than 500 symbols in one message.Split the list across several messages.
SYMBOL_LIMIT_EXCEEDEDThe distinct-symbol cap of your plan is reached.Unsubscribe something, or raise the cap.
MARKET_DATA_CAPACITYA temporary capacity limit.Retry that subscribe later.
PACKAGE_EXPIREDThe entitlement ended while you were connected.Renew; the connection may close.
The error codes that matter most. The docs list every code.

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
Plan figures as the pricing page states them. When an account holds several feeds, their limits combine.

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 ready before 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.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.