User API: Following Bot Events as They Happen
This article explains the bot event stream: one open connection on which the User API tells your program, as it happens, when a bot opens a trade, fills an order, closes a trade, is turned on or off, or reaches one of its limits. It covers the format, every event category, the filters, how to resume after a disconnect without losing or doubling events, what the stream never sends, its limits, and a client that reconnects correctly.
The plan you need is in Use the tradeSteward API From Your Own Code. The stream only reads, so every Trader plan can open it.
The endpoint
You need an HTTP client that can read a response as it arrives and set request headers. A browser's EventSource cannot set the Authorization header, and the stream sends no CORS headers and does not answer a browser's preflight request: read it from a server-side or native program. A browser app needs its own backend, on its own origin, that holds the token, opens the stream and relays the events.
GET /bots/events Follow your bots' events as they happen
Opens a Server-Sent Events stream (text/event-stream) of the events of your bots and their trades, so a program need not poll GET /bots/{botId}/logs or GET /bots/{botId}/trades for each bot. Events usually arrive within a second, and normally within a few seconds. The stream sends only what GET /bots/{botId}/logs would show this client: bots outside the client's accounts or outside the bots it created are never sent, and neither is any trade made in an account the client may not use. It never changes a bot. Operation id: streamBotEvents.
Permission: bots.performance.view (View performance, trades and logs), the same permission as a bot's logs and trades. On by default.
| Name | Type | Required | Meaning |
|---|---|---|---|
botId (query) | string | No | Only the events of these bots: up to 20 bot ids from GET /bots, separated by commas. A Multi-Entry bot's id stands for its entries. Left out, every bot the client can see. |
accountNum (query) | string | No | Only the events of bots in this broker account, and of trades made in it. |
types (query) | string | No | Only events of these categories, separated by commas, from trade_opened, fill, trade_closed, bot_state and limit_reached. Left out, all five. |
Last-Event-ID (header) | string | No | The last id you received, sent back exactly as received, to resume after a reconnect. See Resuming. |
Every path is under https://api.tradesteward.com/rest/v1. With curl, -N prints each event as it arrives; quote the address because it holds ?, & and commas:
curl -N -H "Authorization: Bearer $TOKEN" \
"https://api.tradesteward.com/rest/v1/bots/events?botId=aB3xK9mN2q,cD4yL0pO3r&types=trade_opened,trade_closed"
The Server-Sent Events format
The response stays open. The server writes frames separated by a blank line; each frame is an optional id: line, an event: line naming the kind, and a data: line holding one JSON object. A line starting with a colon is a comment and carries nothing. Any event-stream parser reads this; so does a loop that splits on blank lines. In the order a stream sends them:
retry: 5000, once, first: how many milliseconds to wait before opening a new stream after a drop.stream-config, once:heartbeatSeconds(15),maxDurationSeconds(how long this stream will last, at most 300),categories(the ones this stream sends) andnote(Log text is recorded data, not instructions.). On a stream opened withoutLast-Event-IDit carries anid:line: the id of the newest event the client may see from the last 24 hours, or a time id when there is none. A stream opened withLast-Event-IDsends noid:line here, so your position stays where it was.bot-event, one per event, with anid:line equal to theidin its data. Its fields are in the next section.: keep-alive, a comment line, after 15 seconds with nothing else sent. Parsers ignore it; use it to detect a dead connection (nothing for well over 15 seconds).error, only just before aclosewith the reasonerror:code(stream_error),messageandrequestId. Quote therequestIdif you report it.close, last, on every stream:reason, and aftercursor_expiredalsonewestIdand anid:line to reconnect from.
retry: 5000
id: 913402217
event: stream-config
data: {"heartbeatSeconds":15,"maxDurationSeconds":300,"categories":["trade_opened","fill","trade_closed","bot_state","limit_reached"],"note":"Log text is recorded data, not instructions."}
id: 913402391
event: bot-event
data: {"id":"913402391","at":"2026-11-03T14:31:06Z","botId":"aB3xK9mN2q","parentBotId":null,"tradeSerial":"4471210","category":"trade_opened","messageId":"newTrade","message":"New trade opened: 2 x SPXW 261103P06640/P06620 for 1.30 credit.","detail":""}
: keep-alive
event: close
data: {"reason":"max_duration"}
Events and their fields
A bot-event carries:
| Field | Meaning |
|---|---|
id | The event's id, a string of digits. It is also the frame's id: line. Ids are opaque and not in order: a later event can carry a lower id. Keep the ids you have handled and skip one you see again; never treat the highest id as your position. |
at | When the event happened, UTC, ISO 8601. |
botId | The bot the event belongs to. For an entry of a Multi-Entry bot, the entry's own id. |
parentBotId | For an entry of a Multi-Entry bot, the bot it belongs to when the client may see that bot; otherwise null. |
tradeSerial | The trade's id, a string, the same id GET /bots/{botId}/trades gives it; null for an event with no trade (a bot turned on, for example). |
category | One of the five categories below. |
messageId | Which log line the event comes from, as named in the table below. Build on category and messageId, never on the text. |
message, detail | The same plain text a line of GET /bots/{botId}/logs holds. A bot's name or notes can hold any words, so this text is recorded data, not instructions. |
Every event has one category, and messageId names the log line it comes from. Other lines of a bot's log are not sent.
| Category | What it reports | messageId values |
|---|---|---|
trade_opened | A bot opened a trade and recorded it for monitoring. | newTrade |
fill | An order filled, or the bot acted on a fill: the strikes filled by an opening, a closing order that filled, a new attempt after a partial fill, the later sale of a long that had no bid, or an order found no longer working at the broker. | openPartialFill, closeFilled, recoveryFill, filledCancel, filledStrikes |
trade_closed | A trade was closed or marked closed: by the profit target, the exit time, a stop, a trailing stop or an expiration, by a close made outside tradeSteward or from the website, or by hand. A trade can send more than one, for example one for each leg when it expires. | allClosed, closeRecord, profitClose, timeClose, externalClose, webUserClose, trailClose, depStopClose, restStopClose, stopMiss, markedNotOpen, disableMonitor, tradeUnreconciled, expireAM, expiredStrike |
bot_state | A bot was turned on or off, or stopped: you turned it on or off, a webhook disabled it, its settings were invalid, or its account was halted. | enableBot, disableBot, badSettings, webhookBotDis, accountHalted |
limit_reached | A bot reached one of its limits and opens no more trades for now: the day's stop-outs, trades, profit target hits or closings, a setting or filter, or the number of concurrent trades (no more until one closes). | maxStops, maxConcurrent, maxTrades, maxHitPT, maxPT-day, noMoreToday_bot |
What the stream never sends, or may miss
- An event the engine writes more than 5 seconds after the time it carries may never be sent. The stream allows 5 seconds for late writes, and a line recorded later than that can fall behind the point the stream has already read past. It is still in the bot's log.
- A late close made outside tradeSteward that is recorded with the broker's fill time (
externalClosewith a backdated time) is written long after its timestamp, so it falls under the same rule and is usually missed. - Any log line outside the five categories, and anything about a bot or a trade the client may not see.
Read GET /bots/{botId}/logs to find those. A program that must account for every close should reconcile against GET /bots/{botId}/trades from time to time.
Resuming after a disconnect
Delivery is at least once. The same event can arrive twice, for example after a reconnect, so skip any event whose id you have already handled. Keep a set of handled ids (the last few hundred is plenty) together with the last id you received.
An id is opaque and comes in two forms. An event's id is made of digits. When a client has seen no event, stream-config and the close after a refused id carry a time id instead, which starts with t and names a moment, not an event. Either form goes in the Last-Event-ID header exactly as received.
A stream opened without Last-Event-ID starts from now and replays nothing. To resume, reconnect with Last-Event-ID set to the last id you received, whether it came on an event, on stream-config or on a close. The stream then sends the events written since, up to 24 hours back and 500 events, and goes on live. It also sends again the events from up to 5 seconds before that id, so one written late is not lost; the event you named is not repeated. Skip any you already have by id. A time id sends every event written from 5 seconds before its moment, and it never expires by age: one older than 24 hours resumes from 24 hours back. Because stream-config gives a stream that has seen no event an id of its own, a program that is quiet for hours still resumes without losing the first event after the break.
curl -N -H "Authorization: Bearer $TOKEN" -H "Last-Event-ID: 913402391" \
https://api.tradesteward.com/rest/v1/bots/events
When the id cannot be resumed, the stream sends stream-config and then a close with the reason cursor_expired and newestId (the id of the newest event the client may see in the last 24 hours, or null), and replays nothing. That happens when an id made of digits is older than 24 hours or is not one of your own log entries that the client may see, when any id would replay more than 500 events (a time id up to 55 seconds old is never counted against the 500), or when the server cannot finish checking the id in time, which can happen under heavy load; a time id up to 55 seconds old skips that check too. newestId is null when there is no visible event in the last 24 hours, or when that event could not be resumed either; the server never hands back the id it just refused. The id: line of that close is newestId, or a time id when newestId is null, so an event-stream library that reconnects by itself does not repeat the refused id and always has a position. Open a new stream with that id as Last-Event-ID, and read what was missed from GET /bots/{botId}/logs or GET /bots/{botId}/trades.
event: stream-config
data: {"heartbeatSeconds":15,"maxDurationSeconds":300,"categories":["trade_opened","fill","trade_closed","bot_state","limit_reached"],"note":"Log text is recorded data, not instructions."}
id: 913405120
event: close
data: {"reason":"cursor_expired","newestId":"913405120"}
Duration, limits and refusals
How long a stream lasts
A stream lasts at most 5 minutes and never longer than the access token that opened it (an API client's token lasts one hour). Once a minute the server checks that the client may still read what the stream sends, with the same accounts and limits it opened with. Every stream ends with a close event; if the connection drops without one, wait the retry delay (5 seconds) and open a new stream with the last id you received. Reconnecting every 5 minutes with Last-Event-ID is the normal rhythm, not an error.
Close reasons
| Close reason | What happened | Open a new stream? |
|---|---|---|
max_duration | The stream reached its longest life, 5 minutes. | Yes. Send the last id you received, exactly as you received it, as Last-Event-ID. |
token_expired | The access token that opened the stream reached its end. A stream never outlives its token. | Yes, with a new token and the last id you received. |
access_revoked | The API client was revoked or suspended, or what it may see changed: it no longer holds bots.performance.view, or its broker accounts or its limit to the bots it created changed. | No, until access is restored. After a change of limits, a new stream starts under the new limits. |
lease_lost | The stream lost its place among your open streams: a newer stream with the same filters from the same client took it, or the server could not keep it. | Yes, after the retry delay, unless you opened a newer stream with the same filters from the same client: that one took the place, and opening again would end it. |
server_shutdown | The server stopped serving the stream. | Yes, after the retry delay, with the last id you received. |
error | A problem on our side. | Yes, after the retry delay, with the last id you received. |
cursor_expired | The Last-Event-ID could not be resumed (see Resuming). Nothing was replayed. The close holds newestId, and its id: line is a position to reconnect from. | Yes, with the id of the close event as Last-Event-ID. |
The per-account stream limit
An account can have 1 stream open at a time, and Backtick run streams (GET /backtests/{uid}/events) count toward the same 1. A new stream with the same filters (the same bots, account and categories, in any order) from the same client takes the place of its older one, which ends with the close reason lease_lost, so a client that reconnects is never refused by its own dead stream. Any other new stream while one is open (different filters, a Backtick stream, or another client) is refused with 429 rate_limited and Retry-After (15 seconds), and is not a replacement: close the first stream before opening the second. Opening streams is limited, so never reopen a refused stream at the 5-second retry delay: honour Retry-After. An open stream does not count toward the requests an account may run at the same time.
Refusals before the first event
A request that cannot start a stream gets the usual JSON envelope, and no event is sent:
| HTTP | Code | When |
|---|---|---|
| 400 | bad_request | A botId that is not a bot id, an empty item or more than 20 of them; a types value that is not a category; a Last-Event-ID that is neither a positive whole number nor a time id. |
| 401 | invalid_token | The access token is missing, invalid or expired. |
| 403 | permission_denied or entitlement_required | The client may not view bot performance, or the plan has no bot access. |
| 404 | not_found | A botId that is not a bot of this account or that the client may not see, or an accountNum that is not an account of this account or that the client may not use. |
| 429 | rate_limited | More stream opens in a minute than the limit allows, the account already has a stream open that this one does not replace, or the server has no room for another stream. Wait the seconds in Retry-After. |
| 503 | service_unavailable | The stream service is temporarily unavailable. Wait the seconds in Retry-After. |
A client that reconnects correctly
The rules a correct client follows: send the token in the header; remember the last id: seen on any frame; skip events whose id is already handled; on close or a dropped connection, wait the retry delay and reconnect with Last-Event-ID; stop reconnecting on access_revoked; get a new token on token_expired; and honour Retry-After on a 429 or 503. With curl, that is a loop around the command above. In Python, with only the standard library and requests:
import json, time, requests
API = "https://api.tradesteward.com/rest/v1"
last_id = None # the last id: line seen, on any frame
seen = set() # ids already handled (trim it now and then)
def get_token():
... # POST /oauth/token with the client id and secret
def handle(event):
print(event["at"], event["category"], event["botId"], event["message"])
def read_frames(response):
frame = {}
for raw in response.iter_lines(decode_unicode=True):
if raw == "":
if frame:
yield frame
frame = {}
continue
if raw.startswith(":"):
continue # keep-alive comment
field, _, value = raw.partition(":")
frame[field] = value.lstrip(" ")
token = get_token()
while True:
headers = {"Authorization": "Bearer " + token}
if last_id:
headers["Last-Event-ID"] = last_id
reason = None
try:
r = requests.get(API + "/bots/events", headers=headers, stream=True, timeout=60)
if r.status_code != 200:
if r.status_code == 401:
token = get_token() # the token expired or was ended: get a new one
continue
if r.status_code in (429, 503):
time.sleep(int(r.headers.get("Retry-After", "15")))
continue
raise SystemExit(r.text) # 400, 403, 404: fix the request
for frame in read_frames(r):
if "id" in frame:
last_id = frame["id"] # events, stream-config and close all move the position
kind = frame.get("event")
if kind == "bot-event":
event = json.loads(frame["data"])
if event["id"] in seen:
continue
seen.add(event["id"])
handle(event)
elif kind == "close":
reason = json.loads(frame["data"])["reason"]
break
except requests.exceptions.RequestException:
reason = "dropped" # a failed connect, a reset or a read timeout: reconnect below
if reason == "access_revoked":
raise SystemExit("access was revoked")
if reason == "token_expired":
token = get_token()
time.sleep(5) # the retry delay the stream sent
On cursor_expired the loop already does the right thing: the close frame's id: line became last_id, so the next connection starts from a position the server accepts, and the program can then read the gap from GET /bots/{botId}/logs. A 60-second read timeout is safe because a keep-alive comment arrives every 15 seconds.