Realtime Streaming
You can stream realtime market data via WebSocket if you have subscribed to the enterprise package.
The v2 WebSocket streams realtime market data by exchange: traded prices and bid/ask
quotes, for single instruments or a whole exchange with one subscription.
1. Connect
wscat -c "wss://ws.bavest.co/v2" -H "x-api-key: <API KEY>"Your API key is validated during the handshake. An invalid or missing key rejects the
connection before it opens.
2. Topics
Everything you can subscribe to is a topic:
<channel>.<MIC>.<ISIN> e.g. trades.XMUN.DE0007164600
<channel>.<MIC>.* e.g. quotes.XMUN.* (every instrument on the exchange)
| Part | Meaning |
|---|---|
channel | trades = traded prices. quotes = bid/ask. |
MIC | ISO-10383 operating MIC of the exchange. Currently enabled: XMUN (Börse München / gettex). |
ISIN | 12-character ISIN, or * to receive every instrument on the exchange. |
Subscribing to an exchange that is not enabled returns an error frame per topic, e.g.exchange <exchange> is not enabled.
3. Actions
{"action": "subscribe", "topics": ["trades.XMUN.DE0007164600", "quotes.XMUN.*"]}
{"action": "unsubscribe", "topics": ["quotes.XMUN.*"]}
{"action": "ping"}The server acknowledges with {"ev":"subscribed", ...} / {"ev":"unsubscribed", ...} /
{"ev":"pong"}. Invalid topics in a subscribe request are rejected individually with an
error frame; the valid ones still subscribe.
Both acks report what actually happened, and list only the topics that took effect:
- Every message whose
actionis recognized assubscribeorunsubscribeis acked,
even when thetopicsfield is malformed or every topic was rejected. In those cases
you receive the error frames plus an ack with"topics": []. You may safely block on
the ack for any request you constructed with a valid action. - The only messages that receive no ack are those the server cannot attribute to an
action at all: invalid JSON, a non-object payload, or an unknownaction. These get a
singlebad_requesterror frame. That is a client-side programming bug, not a state
you should wait on. - An unsubscribe acks only the topics this connection actually held. Asking to
unsubscribe from something you never subscribed to leaves it out of thetopicslist,
so the ack can be used to reconcile client state.
4. Messages
Every server frame carries an ev field. Data frames may arrive batched as a JSON
array (one WebSocket message containing many ticks); control frames (acks, pong,
errors, snapshots) are single objects. Your client must handle both shapes.
Trade (ev: "T", channel trades)
ev: "T", channel trades)Every trade is delivered; trades are never dropped or merged.
{"ev": "T", "topic": "trades.XMUN.DE0007164600", "x": "XMUN",
"isin": "DE0007164600", "p": 123.45, "v": 100, "c": "EUR", "t": 1764236768617}| Field | Description |
|---|---|
p | Traded price |
v | Trade volume (optional) |
c | Currency |
t | Event time, epoch milliseconds UTC |
x | Exchange MIC |
Quote (ev: "Q", channel quotes)
ev: "Q", channel quotes)Bid/ask updates, conflated to roughly one message per instrument per second under load
(you always receive the newest state; superseded intermediate quotes are dropped).
{"ev": "Q", "topic": "quotes.XMUN.DE0007164600", "x": "XMUN",
"isin": "DE0007164600", "bp": 123.40, "bs": 200, "ap": 123.45, "as": 100,
"c": "EUR", "t": 1764236768617}| Field | Description |
|---|---|
bp / bs | Bid price / bid size |
ap / as | Ask price / ask size |
Note for typed clients: as is a reserved word in Python (and some other
languages). Read it via frame["as"] rather than attribute access, and give it
an alias in Pydantic or dataclass models (e.g. ask_size with alias "as").
Snapshot
Subscribing to an exact-ISIN topic may deliver a recent cached value as a normal data
frame with "snap": true added. There is no distinct snapshot ev. Wildcard
subscriptions never receive one.
A snapshot is best-effort, not a last-value guarantee. Do not render it as current
state. It is sent only when both hold:
- the topic had an exact-ISIN subscriber at the moment that tick arrived (the cache is
maintained per subscribed topic, not per instrument on the exchange), and - the cached tick is less than 60 seconds old.
Otherwise you receive no snapshot and the first live tick follows. A value older than the
bound is suppressed rather than sent, because a stale price wearing a snap: true label
is worse than no price at all. In practice this means the first subscriber of a quiet
instrument gets nothing, and neither does a client reconnecting after a long gap.
Seed your opening state from the REST endpoint /v2/timeseries/quote and use the
WebSocket for deltas. If you do consume snapshots, check t and apply your own tolerance.
Errors
{"ev": "error", "code": "exchange_not_enabled", "topic": "quotes.XETR.DE0007164600",
"message": "exchange XETR is not enabled"}| Code | Meaning |
|---|---|
bad_request | Message was not valid JSON or had no usable action/topics |
bad_topic | Topic does not match <channel>.<MIC>.<ISIN or *> |
exchange_not_enabled | The MIC is not available on your stream |
too_many_topics | Per-connection topic limit reached |
5. Connection lifecycle, heartbeats and reconnects
- Keep-alive: the connection closes after 10 minutes of inactivity. Send
{"action":"ping"}every 4 minutes; the server replies{"ev":"pong"}. - Maximum session length: 2 hours (platform limit). Your client must expect the
socket to close and reconnect. - Reconnect best practice: reconnect with exponential backoff and jitter (start at
1s, double up to a 30s cap, add random jitter). After reconnecting, resubscribe to your
topics. Snapshots will not close the gap, see section 4: they are age-bounded and
are not written for topics nobody was subscribed to. If gap-free history matters,
backfill the outage window over REST. Do not reconnect in a tight loop: connections are
capped per API key, and a reconnect storm will be rejected at the handshake. - Subscription changes become effective within a few seconds.
6. Limits
| Limit | Value |
|---|---|
| Topics per connection | 200 |
| Concurrent connections per API key | 20 |
| Max message size (server to client) | 128 KB (data is chunked below this) |
7. Best practices for broker integrations
How to run the stream in production when you serve it onwards to your own end users.
Connection topology
- Use one connection per backend service and environment, not one per end user. Your
backend consumes the Bavest stream once and fans out to your users over your own
infrastructure. Connections are capped per API key (20); end-user fan-out through our
socket does not scale and leaks your API key. - Never use the API key in a browser or mobile app. The key authenticates your
organisation; keep it server-side, rotate it like any credential. - Full-market brokers subscribe once to
trades.XMUN.*andquotes.XMUN.*. Watchlist
products subscribe to exact ISINs (limit: 200 topics per connection); shard across
connections if you need more.
Liveness: do not trust the TCP connection
A socket can be dead without closing. Run a watchdog:
- Send
{"action":"ping"}every 30s; if no{"ev":"pong"}within 10s, force-reconnect. - Track the last time ANY frame arrived. During trading hours on a wildcard
subscription, silence longer than your alert threshold (for example 60s) means
reconnect first, page yourself second. - Expect the 2h hard session cap: reconnecting is normal operation, not an incident.
Reconnect proactively at ~110 minutes if you want to control the timing.
Reconnect discipline
- Exponential backoff with full jitter: 1s base, double to a 30s cap, reset only
after the connection has been stable for a minute. Reconnect storms are rejected at
the handshake (connection cap), and hammering the endpoint only delays your own
recovery. - On every (re)connect: resubscribe all topics. Expect no snapshot for most of them, and
never treat a"snap": trueframe as authoritative state; it is a bounded-age hint,
and the rule below (idempotent byt) already handles it correctly. - Apply frames idempotently by event time: keep the last
tper topic and ignore
any frame with an oldert. This makes reconnects, duplicates, and out-of-order
arrival harmless by construction.
Consuming without falling behind
- Never block the read loop. Decode, hand off to an internal queue, return to reading.
A read loop that does database writes will back up the socket. - Fan out to your end users with per-instrument last-value conflation (drop
superseded quotes, keep every trade). That is the same rule the feed itself follows. - Use the
tfield (event time, UTC epoch ms) for display and candles, never your
arrival time.
Trade continuity
Quotes need no gap handling: the next quote supersedes everything before it. Trades are
delivered completely while you are connected, but a disconnect window is a real gap: if
you build candles or tick history from the stream, reconcile the disconnect window via
the REST candles endpoint after reconnecting instead of assuming the stream was
complete.
Degradation and monitoring
- If the WebSocket is unavailable for longer than your tolerance, degrade to polling the
REST quote endpoint for your visible instruments, and switch back when the stream
recovers. - Monitor and alert on: frames per second (per channel), seconds since last frame,
reconnects per hour, handshake rejections (429 means connection cap; 401/403 means key
problem), and watchdog-forced reconnects. - Log WebSocket close codes on every disconnect; include them in support requests.
8. Quickstart (Python)
Implements the broker practices above: jittered backoff, keepalive with a pong
watchdog, resubscribe on reconnect, and idempotent apply by event time.
Requires websockets version 14 or later (the header argument is
additional_headers; the pre-14 name extra_headers no longer exists).
import asyncio, json, random, time
import websockets
from websockets.exceptions import InvalidStatus
URL = "wss://ws.bavest.co/v2"
TOPICS = ["trades.XMUN.*", "quotes.XMUN.DE0007164600"]
last_t = {} # topic -> newest applied event time (idempotent apply)
def apply(frame):
topic = frame.get("topic")
if frame.get("t", 0) < last_t.get(topic, 0):
return # older than what we already have (reconnect/duplicate): ignore
last_t[topic] = frame["t"]
if frame["ev"] == "T":
print("trade", frame["isin"], frame["p"], "snap" if frame.get("snap") else "")
elif frame["ev"] == "Q":
print("quote", frame["isin"], frame["bp"], frame["ap"])
async def stream():
backoff = 1
while True:
try:
async with websockets.connect(
URL, additional_headers={"x-api-key": "<API KEY>"}
) as ws:
state = {"last_pong": time.monotonic()}
async def keepalive(): # ping + watchdog: kill dead-but-open sockets
while True:
await asyncio.sleep(30)
await ws.send(json.dumps({"action": "ping"}))
await asyncio.sleep(10)
if time.monotonic() - state["last_pong"] > 40:
await ws.close()
return
await ws.send(json.dumps({"action": "subscribe", "topics": TOPICS}))
task = asyncio.create_task(keepalive())
try:
async for message in ws:
backoff = 1 # stable connection: reset backoff
frames = json.loads(message)
for frame in frames if isinstance(frames, list) else [frames]:
if frame.get("ev") == "pong":
state["last_pong"] = time.monotonic()
elif frame.get("ev") == "error":
print("error:", frame["code"], frame.get("message"))
elif frame.get("ev") in ("T", "Q"):
apply(frame)
finally:
task.cancel()
except InvalidStatus as e:
status = e.response.status_code
if status in (401, 403):
raise SystemExit(f"auth rejected ({status}): check x-api-key")
print("handshake rejected:", status) # 429 = connection cap: back off
await asyncio.sleep(backoff + random.random())
backoff = min(backoff * 2, 30)
except Exception as e:
print("disconnected:", e)
await asyncio.sleep(backoff + random.random()) # full jitter
backoff = min(backoff * 2, 30)
asyncio.run(stream())