Realtime
WebSocket stream
Live trading quotes and account events for Trading API keys over one socket at wss://api.your-brand.com/rt/v1/ws. Text frames, JSON, batched quotes, 15-minute sessions.
#Connecting
-
Get a ticket
A ticket is single-use and valid for 60 seconds. A read-only key is enough. Never put the API key itself in the socket URL.
POST /api/open/v1/stream_ticketcurl -X POST "https://api.your-brand.com/api/open/v1/stream_ticket" \ -H "X-API-Key: $OCTOLIT_API_KEY"const res = await fetch("https://api.your-brand.com/api/open/v1/stream_ticket", { method: "POST", headers: { "X-API-Key": process.env.OCTOLIT_API_KEY, }, }); if (!res.ok) throw new Error(`POST failed: ${res.status} ${await res.text()}`); const data = await res.json(); console.log(data);import os, requests r = requests.request( "POST", "https://api.your-brand.com/api/open/v1/stream_ticket", headers={"X-API-Key": os.environ["OCTOLIT_API_KEY"]}, timeout=10, ) r.raise_for_status() print(r.json())<?php $ch = curl_init("https://api.your-brand.com/api/open/v1/stream_ticket"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "X-API-Key: " . getenv("OCTOLIT_API_KEY"), ], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); print_r($data);using var http = new HttpClient(); using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.your-brand.com/api/open/v1/stream_ticket"); req.Headers.Add("X-API-Key", Environment.GetEnvironmentVariable("OCTOLIT_API_KEY")); using var res = await http.SendAsync(req); res.EnsureSuccessStatusCode(); Console.WriteLine(await res.Content.ReadAsStringAsync());var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder(URI.create("https://api.your-brand.com/api/open/v1/stream_ticket")) .header("X-API-Key", System.getenv("OCTOLIT_API_KEY")) .method("POST", HttpRequest.BodyPublishers.noBody()) .build(); var res = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.statusCode() + " " + res.body());var body io.Reader req, err := http.NewRequest("POST", "https://api.your-brand.com/api/open/v1/stream_ticket", body) if err != nil { log.Fatal(err) } req.Header.Set("X-API-Key", os.Getenv("OCTOLIT_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out))var req = URLRequest(url: URL(string: "https://api.your-brand.com/api/open/v1/stream_ticket")!) req.httpMethod = "POST" req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_API_KEY"], forHTTPHeaderField: "X-API-Key") let (data, response) = try await URLSession.shared.data(for: req) print((response as! HTTPURLResponse).statusCode, String(decoding: data, as: UTF8.self))val client = OkHttpClient() val req = Request.Builder() .url("https://api.your-brand.com/api/open/v1/stream_ticket") .header("X-API-Key", System.getenv("OCTOLIT_API_KEY")) .method("POST", null) .build() client.newCall(req).execute().use { res -> println("${res.code} ${res.body?.string()}") }200 OK{ "ticket": "st_...", "exp": 1, "expires_in": 60, "url": "https://trade.your-brand.com/login?token=...", "single_use": true } -
Open the socket
Connect to
wss://api.your-brand.com/rt/v1/wson the same host as the REST API with either?ticket=<ticket>in the URL or the headerAuthorization: Bearer <ticket>. The first frame you receive iswelcome. -
Subscribe
Send
subscribewith up to 100 symbols (symbol codes as inGET /symbols). Account events for all your trading accounts arrive without subscribing.Minimal clientconst res = await fetch('https://api.your-brand.com/api/open/v1/stream_ticket', { method: 'POST', headers: { 'X-API-Key': process.env.OCTOLIT_API_KEY } }); const { ticket } = await res.json(); const ws = new WebSocket('wss://api.your-brand.com/rt/v1/ws?ticket=' + encodeURIComponent(ticket)); ws.onopen = () => ws.send(JSON.stringify({ op: 'subscribe', symbols: ['EUR-USD', 'XAU-USD'] })); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.type === 'quotes') for (const q of m.q) console.log(q.symbol, q.bid, q.ask); if (m.type === 'account') refreshPositions(m.account_id); // fetch over REST if (m.type === 'closing' && m.reason === 'lifetime') reconnect(); // new ticket, new socket };import asyncio, json, os, requests, websockets def ticket(): r = requests.post("https://api.your-brand.com/api/open/v1/stream_ticket", headers={"X-API-Key": os.environ["OCTOLIT_API_KEY"]}) r.raise_for_status() return r.json()["ticket"] async def main(): async with websockets.connect("wss://api.your-brand.com/rt/v1/ws", additional_headers={"Authorization": f"Bearer {ticket()}"}) as ws: await ws.send(json.dumps({"op": "subscribe", "symbols": ["EUR-USD", "XAU-USD"]})) async for raw in ws: m = json.loads(raw) if m["type"] == "quotes": for q in m["q"]: print(q["symbol"], q["bid"], q["ask"]) elif m["type"] == "closing": break # reconnect with a new ticket asyncio.run(main())c, _, err := websocket.DefaultDialer.Dial("wss://api.your-brand.com/rt/v1/ws?ticket="+url.QueryEscape(ticket), nil) if err != nil { log.Fatal(err) } defer c.Close() c.WriteJSON(map[string]any{"op": "subscribe", "symbols": []string{"EUR-USD", "XAU-USD"}}) for { var m map[string]any if err := c.ReadJSON(&m); err != nil { break } switch m["type"] { case "quotes": for _, q := range m["q"].([]any) { fmt.Println(q) } case "closing": return // reconnect with a new ticket } }using var ws = new ClientWebSocket(); ws.Options.SetRequestHeader("Authorization", $"Bearer {ticket}"); await ws.ConnectAsync(new Uri("wss://api.your-brand.com/rt/v1/ws"), CancellationToken.None); await ws.SendAsync(Encoding.UTF8.GetBytes("{\"op\":\"subscribe\",\"symbols\":[\"EUR-USD\",\"XAU-USD\"]}"), WebSocketMessageType.Text, true, CancellationToken.None); var buf = new byte[65536]; while (ws.State == WebSocketState.Open) { var r = await ws.ReceiveAsync(buf, CancellationToken.None); var msg = JsonDocument.Parse(Encoding.UTF8.GetString(buf, 0, r.Count)).RootElement; if (msg.GetProperty("type").GetString() == "quotes") Console.WriteLine(msg); }
#Client → server
| op | Frame | |
|---|---|---|
subscribe | { "op": "subscribe", "symbols": ["EUR-USD", "XAU-USD"] } | Start quotes for the pairs; answered with subscribed. |
unsubscribe | { "op": "unsubscribe", "symbols": ["EUR-USD"] } | Stop quotes for the pairs; answered with unsubscribed. |
ping | { "op": "ping" } | Answered with pong. |
#Server → client
| type | Frame | |
|---|---|---|
welcome | { "type": "welcome", "client_id": 1042, "accounts": [55], "limits": { "max_symbols": 100 } } | First message after the handshake. |
subscribed | { "type": "subscribed", "symbols": ["EUR-USD"] } | The pairs now streaming. |
unsubscribed | { "type": "unsubscribed", "symbols": ["EUR-USD"] } | |
quotes | { "type": "quotes", "q": [{ "symbol": "EUR-USD", "bid": 1.08452, "ask": 1.08461, "ts": 1762862400123 }] } | Trading quotes (the prices orders fill at), batched. |
account | { "type": "account", "event": "positions", "account_id": 55 } | Something changed on one of your accounts (positions | balance): fetch it over REST. |
status | { "type": "status", "quotes": "paused" } | live | paused | unavailable - the quote feed state. |
heartbeat | { "type": "heartbeat", "ts": 1762862400123 } | Every 15 s; no reply needed. |
pong | { "type": "pong" } | |
error | { "type": "error", "code": "bad_symbols", "symbols": ["FOO-BAR"] } | bad_request | bad_symbols | too_many_symbols | unknown_op | rate_limited. More than 20 errors close the connection. |
closing | { "type": "closing", "reason": "lifetime" } | Sent right before the server closes. |
Quotes are batched
A quotes frame carries the latest price of every changed symbol since the previous frame, at most a few frames a second. ts is Unix milliseconds. Use the WebSocket for display and triggers; use GET /quotes for a one-off read. #Limits
| Connections per client | 5 |
| Connections per key | 3 |
| Connections per address | 50 |
| Symbols per connection | 100 |
| Client messages per second | 10 (burst 20) |
| Connection lifetime | 15 min, then close 1000 lifetime - reconnect with a new ticket |
| Heartbeat | every 15 s (server -> client) |
| Client message size | 4 096 bytes |
#Close reasons
Before closing, the server sends { "type": "closing", "reason": "…" } and then closes with the code below.
1008
key_revokedThe key was revoked.1008
key_expiredThe key expired.1008
access_offAPI access or the stream was switched off.1000
lifetimeThe 15-minute lifetime ended - reconnect with a new ticket.1001
shutting_downServer restart - reconnect.1008
slow_consumerYour side did not read fast enough.1008
too_many_errorsMore than 20 error messages.1002
protocol_errorMalformed frame.1009
message_too_bigA client message over 4 096 bytes.1007
invalid_utf8Text frame is not UTF-8.#Patterns
- Reconnect: on
lifetimeorshutting_downfetch a new ticket and reconnect with backoff (1 s, 2 s, 4 s … 30 s). Re-send yoursubscribe. - Account events are signals, not state: on
accountfetchGET /positionsorGET /trading_accounts/{id}. You never miss a change because of a dropped frame. - Status:
quotes: "paused"means the broker paused prices - keep the socket, show stale. - One socket per process: subscribe to all symbols on one connection instead of many sockets; limits are per client and per key.