Skip to content

Streaming (SSE)

GET /v1/stream/contests?series={series} is a long-lived Server-Sent Events stream. Authenticate with the same API key as REST.

Terminal window
curl -N -H "Authorization: Bearer $COTES_API_KEY" \
-H "Accept: text/event-stream" \
"https://api.cotes.ai/v1/stream/contests?series=KXBTC15M"

Browsers cannot set Authorization on the built-in EventSource API. Use fetch with a stream reader, or proxy the stream from your backend.

Each frame looks like:

event: <name>
id: <opaque>
data: <json>
Event data payload
contest.opened Contest (live contest when first seen or a new ticker opens)
signal.emitted SignalEmittedPayload (signal + parent contest)
contest.settled Contest (settled snapshot)
unauthorized Problem detail — terminal, see below

On connect, you may receive the current live contest and its existing Cotes AI signals before new events arrive.

When no event has been sent for 15 seconds the server sends an SSE comment line:

: ping

It is not an event: EventSource and most SSE parsers drop it. If your client reads raw bytes, skip lines that start with :. Treat 45 seconds without any bytes, ping included, as a dead connection and reconnect.

Send the last id you received in a Last-Event-ID header when you reconnect. The stream then skips what you already have: for the same contest you get only the signals raised after that id, and a contest.settled you missed while away. Without the header, the replay starts again from contest.opened and repeats the contest’s signals, so de-duplicate on id (each frame’s id is unique and stable).

Back off between reconnects: start at 1 second, double up to 30 seconds, and add jitter.

A minimal client that parses frames, skips keepalive comments and resumes with Last-Event-ID:

import json, os, random, time
import requests
URL = "https://api.cotes.ai/v1/stream/contests"
HEADERS = {"Authorization": f"Bearer {os.environ['COTES_API_KEY']}",
"Accept": "text/event-stream"}
def stream(series="KXBTC15M"):
last_id, delay = None, 1
while True:
headers = dict(HEADERS, **({"Last-Event-ID": last_id} if last_id else {}))
try:
with requests.get(URL, params={"series": series}, headers=headers,
stream=True, timeout=(10, 45)) as r:
r.raise_for_status()
delay = 1
event, data = None, []
for line in r.iter_lines(decode_unicode=True):
if line.startswith(":"):
continue # keepalive ping
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("id:"):
last_id = line[3:].strip()
elif line.startswith("data:"):
data.append(line[5:].strip())
elif line == "" and event:
payload = json.loads("\n".join(data))
if event == "unauthorized":
raise SystemExit(payload["detail"]) # terminal
yield event, payload
event, data = None, []
except requests.RequestException:
time.sleep(delay + random.random())
delay = min(delay * 2, 30)
for event, payload in stream():
if event == "signal.emitted":
s = payload["signal"]
print(s["side"], s["confidence"], s["entry_price_cents"], s["edge_cents"])

The read timeout of 45 seconds matches the keepalive rule above: if no bytes arrive in that time, the request fails and the loop reconnects with the last id it saw.

Each API key may hold 5 open streams at once, and each client IP 10. One more returns 429 with type https://api.cotes.ai/problems/too-many-streams and Retry-After: 5. Close a stream before opening another; one stream per series is enough for every consumer in a process.

Your key and your account’s entitlement are re-checked about every 30 seconds while the stream is open, not just when it opens. Revoke a key or let a subscription lapse while a stream is running and the feed ends with a final unauthorized event:

event: unauthorized
data: {"type":"https://api.cotes.ai/problems/unauthorized","title":"Unauthorized","detail":"API key revoked or account no longer entitled."}

The event exists so the close is distinguishable from a dropped connection. Treat it as terminal — reconnecting fails the same way until the key or the subscription is sorted out, so a client that blindly retries will spin.