Skip to main content

User API: Following Bot Events as They Happen

Last updated November 4, 2026

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.

NameTypeRequiredMeaning
botId (query)stringNoOnly 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)stringNoOnly the events of bots in this broker account, and of trades made in it.
types (query)stringNoOnly 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)stringNoThe 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) and note (Log text is recorded data, not instructions.). On a stream opened without Last-Event-ID it carries an id: 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 with Last-Event-ID sends no id: line here, so your position stays where it was.
  • bot-event, one per event, with an id: line equal to the id in 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 a close with the reason error: code (stream_error), message and requestId. Quote the requestId if you report it.
  • close, last, on every stream: reason, and after cursor_expired also newestId and an id: 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:

FieldMeaning
idThe 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.
atWhen the event happened, UTC, ISO 8601.
botIdThe bot the event belongs to. For an entry of a Multi-Entry bot, the entry's own id.
parentBotIdFor an entry of a Multi-Entry bot, the bot it belongs to when the client may see that bot; otherwise null.
tradeSerialThe 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).
categoryOne of the five categories below.
messageIdWhich log line the event comes from, as named in the table below. Build on category and messageId, never on the text.
message, detailThe 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.

CategoryWhat it reportsmessageId values
trade_openedA bot opened a trade and recorded it for monitoring.newTrade
fillAn 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_closedA 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_stateA 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_reachedA 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 (externalClose with 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 reasonWhat happenedOpen a new stream?
max_durationThe stream reached its longest life, 5 minutes.Yes. Send the last id you received, exactly as you received it, as Last-Event-ID.
token_expiredThe 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_revokedThe 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_lostThe 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_shutdownThe server stopped serving the stream.Yes, after the retry delay, with the last id you received.
errorA problem on our side.Yes, after the retry delay, with the last id you received.
cursor_expiredThe 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:

HTTPCodeWhen
400bad_requestA 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.
401invalid_tokenThe access token is missing, invalid or expired.
403permission_denied or entitlement_requiredThe client may not view bot performance, or the plan has no bot access.
404not_foundA 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.
429rate_limitedMore 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.
503service_unavailableThe 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.