Streaming
One WebSocket connection carries live quotes, candles, account updates and order events, so your program never has to poll.
URL: wss://terminal.jafx.com/trading-api/v1/stream
Connect and authenticate
Send your key in the Authorization header when you connect. If your WebSocket library cannot set headers, send an auth frame within 5 seconds instead:
{"op": "auth", "key": "jafx_demo_…"}The server answers with ready:
{"op": "ready", "key_id": "k7mx2v9qr4tz8w1b6nfa", "account": 123456, "scopes": ["market:read", "account:read", "trade"]}A missing or invalid key closes the connection with code 4401.
Subscribe
{"op": "subscribe", "channels": ["quotes:EURUSD,XAUUSD", "candles:EURUSD:M1", "account", "orders"]}The server confirms what you are now subscribed to:
{"op": "subscribed", "channels": ["quotes:EURUSD,XAUUSD", "candles:EURUSD:M1", "account", "orders@1791879125359"]}orders@1791879125359 means the orders channel is at sequence number 1791879125359: the account's next order event gets a higher one. Unsubscribe the same way with "op": "unsubscribe"; "quotes" on its own removes every quote subscription.
| Channel | Scope | What you receive |
|---|---|---|
quotes:SYM,SYM,… | market:read | The current quote at once, then every change, at most four times a second per instrument. Unknown instruments are skipped. |
candles:SYM:TF | market:read | The forming bar every second it changes. When a new bar starts, the finished one is sent again with "complete": true. Timeframes: M1 M5 M15 M30 H1 H4 D1 W1. |
account | account:read | A snapshot of the account summary at once, then at most one update a second while it changes. |
orders | account:read | Position, pending-order and request events for the key's account, numbered with seq. |
Frames
Quotes
{"ch": "quotes", "data": {"symbol": "EURUSD", "bid": 1.11554, "ask": 1.11555, "time_ms": 1791883801242, "price_basis": "raw"}}Candles
{"ch": "candles", "symbol": "EURUSD", "timeframe": "M1",
"data": {"time": "2026-10-13T09:30:00.000Z", "open": 1.11549, "high": 1.11558, "low": 1.11546, "close": 1.11554, "tick_volume": 41, "complete": false}}Account
{"ch": "account", "data": {"account": 123456, "group": "demo-usd", "mode": "demo", "currency": "USD", "leverage": 100,
"balance": "10000.00", "credit": "0.00", "equity": "10003.20", "margin": "223.11", "free_margin": "9780.09",
"floating_profit": "3.20", "margin_level": 4483.53, "as_of": "2026-10-13T09:30:02.114Z"}}Orders
{"ch": "orders", "seq": 1791879125360, "event": "position.opened", "time": "2026-10-13T09:29:47.690Z",
"data": {"ticket": 5001, "symbol": "EURUSD", "side": "buy", "volume": "0.02", "open_price": 1.11555, "stop_loss": 1.11,
"take_profit": null, "profit": "-0.02", "origin": "api", "client_tag": "breakout-v2", "from_ticket": null}}event | data |
|---|---|
request.decided | {account, request_id, kind, status, ticket} when an order request made through the API is decided. It arrives before the position or order it created. |
position.opened | The new position (same fields as GET …/positions), including positions opened in the web terminal. |
position.modified | The position after a change of stop loss, take profit or volume. |
position.closed | {ticket, symbol}. The close price and result are in GET …/trades. |
order.placed, order.modified | The pending order (same fields as GET …/orders). |
order.triggered | {ticket} when a pending order fills; position.opened follows. |
order.cancelled | {ticket, symbol} when a pending order is cancelled, expires or is removed. |
Control frames
| Frame | Meaning |
|---|---|
{"op": "heartbeat", "time": …} | Sent every 20 seconds. |
{"op": "pong", "time": …} | The answer to your {"op": "ping"}. |
{"op": "error", "code": …, "message": …} | A problem that does not end the connection, for example insufficient_scope when a channel needs a scope the key lacks (that channel is skipped). |
{"op": "resync_required", "seq": …} | The events you asked to replay can't be replayed; reload positions and orders over REST and carry on from this seq. |
Reconnecting without missing events
Remember the seq of the last orders frame you processed. After reconnecting, subscribe with since_seq and the server first replays what you missed, in order, before its subscribed answer:
{"op": "subscribe", "channels": ["orders"], "since_seq": 1791879125360}Events are kept for 5 minutes, including while you are disconnected. You get resync_required instead when you were away longer, when the server restarted in between, or when since_seq is not a sequence number the stream gave you. Then reload positions and pending orders over REST, and carry on from the new seq.
Sequence numbers only go up, but they are not a count: they have 13 digits, and they jump after a resync_required. Store them as 64-bit integers; they don't fit in 32 bits.
- Reconnect with backoff: 1 s, 2 s, 4 s and so on, up to 30 s.
- The server sends a heartbeat every 20 seconds and WebSocket pings, which most libraries answer for you. If you hear nothing for 45 seconds, treat the connection as dead and reconnect.
Close codes
| Code | Meaning | What to do |
|---|---|---|
4401 | The key is missing, invalid, expired, revoked or suspended. A key revoked mid-stream closes its streams within 5 seconds. | Stop. Fix the key before reconnecting. |
4409 | Replaced by a newer connection with the same key. | Check whether another copy of your program is running. |
1001 | The server is restarting. | Reconnect with backoff. |
1006, others | Network trouble. | Reconnect with backoff. |
Limits
- 2 connections per key. A third connection replaces the oldest, which closes with
4409, so a reconnect is never locked out by a connection that died silently. - Up to 100 quote instruments and 20 candle series per connection.
- Frames you send are limited to 16 KB.
- Slow readers: if more than 1 MB is waiting to be sent to you, quote, candle and account frames are skipped until you catch up; order events are never skipped. Above 4 MB the connection is closed.
Example client
A client that reconnects with backoff and replays missed order events:
import asyncio, json, os
import websockets
URL = "wss://terminal.jafx.com/trading-api/v1/stream"
KEY = os.environ["JAFX_API_KEY"]
async def run():
last_seq, delay = None, 1
while True:
try:
async with websockets.connect(URL, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws:
delay = 1
sub = {"op": "subscribe", "channels": ["orders", "quotes:EURUSD"]}
if last_seq is not None:
sub["since_seq"] = last_seq
await ws.send(json.dumps(sub))
async for raw in ws:
frame = json.loads(raw)
if frame.get("ch") == "orders":
last_seq = frame["seq"]
print(frame["event"], frame["data"])
elif frame.get("op") == "resync_required":
last_seq = frame["seq"] # reload positions over REST here
except websockets.ConnectionClosed as e:
if e.rcvd and e.rcvd.code in (4401, 4409):
raise SystemExit(f"stream closed with {e.rcvd.code}")
except OSError as e:
print("connection failed:", e)
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(run())import WebSocket from 'ws'
const URL = 'wss://terminal.jafx.com/trading-api/v1/stream'
let lastSeq: number | null = null
let delay = 1000
function connect(): void {
const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${process.env.JAFX_API_KEY}` } })
ws.on('open', () => {
delay = 1000
const sub: Record<string, unknown> = { op: 'subscribe', channels: ['orders', 'quotes:EURUSD'] }
if (lastSeq !== null) sub.since_seq = lastSeq
ws.send(JSON.stringify(sub))
})
ws.on('message', raw => {
const frame = JSON.parse(raw.toString())
if (frame.ch === 'orders') {
lastSeq = frame.seq
console.log(frame.event, frame.data)
} else if (frame.op === 'resync_required') {
lastSeq = frame.seq // reload positions over REST here
}
})
ws.on('error', e => console.error('stream error:', e.message))
ws.on('close', code => {
if (code === 4401 || code === 4409) return console.error(`stream closed with ${code}`)
setTimeout(connect, delay)
delay = Math.min(delay * 2, 30_000)
})
}
connect()