Live stream
Get your own fills, orders, trades and account changes pushed to your bot the moment they happen, instead of polling.
Your bot doesn't have to keep asking "did it fill yet?" anymore. Open one stream and Nexus pushes your own changes the moment they happen: a fill, an order resting or cancelled, a trade opened or closed, and your wallet or futures account right after. It's Server-Sent Events: one long HTTP response that sends an event per change.
curl -N "https://app.skylit.ai/api/nexus/v1/trading/stream" \
-H "Authorization: Bearer $SKYLIT_API_KEY"- Same key, same checks. It checks your key, a suspended account and Nexus access exactly like every other route. Opening it counts as one read. Events and heartbeats don't count.
- Only yours. Nobody else's trades ever show up, and nobody else ever sees yours. Your agent account's events come to you and no one else. Test orders never show up: they aren't trades.
- One book or both. Add
?account=mainfor your own trades and accounts, or?account=agentfor your bot's. Leave it out to get both. Anything else answers400 bad_request. Every event says whichbookit's on. - Futures events only come if your account has futures. The
connectedevent tells you with"futures": trueorfalse. - It never streams market data. Quotes aren't in it.
What comes down the stream
| Event | When | data |
|---|---|---|
connected | Once, first | {"account":"all","resumed":false,"futures":true,"heartbeatSeconds":15} |
resync | First thing on a new connection, and any time the server lost track | {"reason":"connected"}. Re-read your state with the REST routes |
trade | An options or stock trade was opened, added to, trimmed, closed or expired | change, book, at, fill, trade |
order | A resting order changed | change, assetClass, book, order |
wallet | Your options and stocks cash or buying power moved | book, wallet |
account | A futures account changed (balance, P/L, positions, working orders) | book, account |
heartbeat | Every 15 seconds | {"at":"2026-10-12T14:31:00Z"} |
closed | We ended the stream. Fix the reason before you reconnect | {"reason":"invalid_api_key"} |
reconnect | The server is restarting | {"reason":"server_restart"}. Reconnect right away |
resync's reason is for your logs (connected, reconnected, lagged, bus or error). Your bot does the same thing for all of them. closed says one of invalid_api_key (the key was revoked or is no longer valid), account_suspended, nexus_access_denied or trades_api_disabled (the API is switched off for now, so wait a while before you try again).
The objects inside are the same ones the REST routes return, field for field:
tradeis whatGET /api/nexus/v1/trades/{id}returns, as the trade stands when the event goes out.fillis this one fill:{"quantity", "price"}, plusidwhen the server knows the fill's own id (it's left out today, so don't rely on it).changeisopened,added,trimmed,closedorexpired.orderis what the order read returns:GET .../wallets/{book}/orders/{orderId}for options and stocks,GET .../accounts/{account}/orders/{orderId}for futures. Orders you placed in Nexus show up too, without"source": "api".walletisGET /api/nexus/v1/trading/wallets/{book}.accountisGET /api/nexus/v1/trading/accounts/{account}.
change on an order tells you what just happened to it:
change | Means |
|---|---|
working | It's resting and can fill |
held | A futures stop or target waiting for its entry to fill |
modified | You (or Nexus) moved its price or size |
triggered | A stop went off. It now works as an order and can't be cancelled |
partial_fill | Part of it filled. The rest still works |
filled | Done. fillPrice is what you got |
cancelled, rejected, expired | Done, no fill. statusReason says why |
Here's a fill as it comes over the wire:
id: 5c1f0e9a2b7d-1042
event: order
data: {"change":"filled","assetClass":"options","book":"main","order":{"id":"8f0c...","status":"filled","fillPrice":1.98,"filledQuantity":2,"tradeId":"41aa...", ...}}
id: 5c1f0e9a2b7d-1043
event: trade
data: {"change":"opened","book":"main","at":"2026-10-12T14:31:07Z","fill":{"quantity":2,"price":1.98},"trade":{"id":"41aa...","ticker":"SPY", ...}}Dropped connections
Every data event has an id. When you reconnect, send the last one you got as the Last-Event-ID header (or ?lastEventId=). If the server you land on still has what you missed (it keeps your last 128 events for 2 minutes after you drop), you get them and carry on, and connected says "resumed": true. If it doesn't, the first event after connected is resync.
So write your bot one way: on resync, read your open trades, working orders, wallet and futures accounts with the REST routes, then keep going. That covers both cases.
Limits and timing
- Streams. A key can hold 3 streams at once and your account 6 across all your keys. Past that you get
429 stream_limit_reachedwithRetry-After. One stream is all a bot needs. - If it goes quiet. A heartbeat comes every 15 seconds. If you hear nothing for 45, the connection's dead: drop it and reconnect with your
Last-Event-ID. - A revoked key's stream ends within about 2 minutes, with
closed. - A browser's
EventSourcecan't send your key in a header. That's fine: your key shouldn't be in a browser anyway. Run the stream from your bot. capabilitieshas astreamblock:available,route,events,accounts,resume,heartbeatSeconds,maxStreamsPerKey,replayWindowSeconds(120) andreplayEvents(128). Ifavailableisfalse, poll the reads instead.
| Status | code | What it means | What your bot should do |
|---|---|---|---|
| 429 | stream_limit_reached | Too many live streams open for this key (3) or your account (6) | Close one, or wait Retry-After seconds |
| 503 | stream_unavailable | The live stream isn't on, or the server is restarting | Try again shortly, or poll the reads |
Examples
import json
import os
import time
import requests
NEXUS = "https://app.skylit.ai"
KEY = os.environ["SKYLIT_API_KEY"]
def events(last_id=None):
"""Yield (id, event, data) from the stream until it ends."""
headers = {"Authorization": f"Bearer {KEY}", "Accept": "text/event-stream"}
if last_id:
headers["Last-Event-ID"] = last_id
# The read timeout is 45 s: three missed heartbeats means a dead stream.
with requests.get(f"{NEXUS}/api/nexus/v1/trading/stream",
headers=headers, stream=True, timeout=(10, 45)) as r:
if r.status_code != 200:
raise RuntimeError(r.json()["error"])
eid, event, data = None, None, []
for line in r.iter_lines(decode_unicode=True):
if line == "":
if event:
yield eid, event, json.loads("".join(data)) if data else None
eid, event, data = None, None, []
elif line.startswith("id: "):
eid = line[4:]
elif line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data.append(line[6:])
def resync():
# Re-read what you hold: GET /api/nexus/v1/trades?status=open,
# GET /api/nexus/v1/trading/wallets/main/orders/working, and so on.
pass
last_id = None
while True:
try:
for eid, event, data in events(last_id):
if eid:
last_id = eid
if event == "resync":
resync()
elif event == "trade":
print(data["change"], data["trade"]["ticker"], data["fill"])
elif event == "order":
print(data["assetClass"], data["change"], data["order"]["id"])
elif event == "closed":
raise SystemExit(f"stream closed: {data['reason']}")
except (requests.ConnectionError, requests.Timeout):
pass # dropped or went quiet: reconnect below
time.sleep(2)const NEXUS = "https://app.skylit.ai";
const KEY = process.env.SKYLIT_API_KEY;
let lastId = null;
async function resync() {
// Re-read what you hold with the REST routes.
}
async function stream() {
const headers = { Authorization: `Bearer ${KEY}`, Accept: "text/event-stream" };
if (lastId) headers["Last-Event-ID"] = lastId;
// Three missed heartbeats (45 s of nothing) means a dead stream.
const quiet = new AbortController();
let timer = setTimeout(() => quiet.abort(), 45_000);
const res = await fetch(`${NEXUS}/api/nexus/v1/trading/stream`, { headers, signal: quiet.signal });
if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));
const decoder = new TextDecoder();
let buf = "";
try {
for await (const chunk of res.body) {
clearTimeout(timer);
timer = setTimeout(() => quiet.abort(), 45_000);
buf += decoder.decode(chunk, { stream: true });
let end;
while ((end = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, end);
buf = buf.slice(end + 2);
let id = "", event = "", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("id: ")) id = line.slice(4);
else if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (id) lastId = id;
const msg = data ? JSON.parse(data) : null;
if (event === "resync") await resync();
else if (event === "trade") console.log(msg.change, msg.trade.ticker, msg.fill);
else if (event === "order") console.log(msg.assetClass, msg.change, msg.order.id);
else if (event === "closed") {
console.error(`stream closed: ${msg.reason}`);
process.exit(1);
}
}
}
} finally {
clearTimeout(timer);
}
}
(async () => {
for (;;) {
try {
await stream();
} catch (err) {
console.error(err.message); // dropped or went quiet: reconnect below
}
await new Promise((r) => setTimeout(r, 2000));
}
})();Last updated