The same alerts our subscribers receive, delivered to your bot as JSON — a fixture list over HTTPS and a live signal stream over WebSocket.
Send your key as a bearer token on every request, including the WebSocket upgrade:
Authorization: Bearer sk_live_…
If your WebSocket client cannot set headers — a browser's built-in
WebSocket cannot — pass it as a query parameter instead:
wss://api.sigmapredict.com/v1/stream?key=sk_live_…. Prefer the header
wherever you can; a key in a URL ends up in logs and proxies.
curl -H "Authorization: Bearer $SIGMA_KEY" \
https://api.sigmapredict.com/v1/meta
Then hold the stream open. A complete client:
import asyncio, json, os, websockets
KEY, cursor, seen = os.environ["SIGMA_KEY"], None, set()
async def run():
global cursor
while True: # rule 3: reconnect forever
url = "wss://api.sigmapredict.com/v1/stream"
if cursor: url += f"?since={cursor}"
try:
async with websockets.connect(
url, additional_headers={"Authorization": f"Bearer {KEY}"}
) as ws:
async for raw in ws:
msg = json.loads(raw)
if msg.get("event") == "stream.ready":
cursor = cursor or msg["head_seq"]; continue
if msg.get("seq"): cursor = msg["seq"] # advance on every event, even skipped ones
if msg.get("event") != "signal.opened": continue # signal.settled: see "Settlement"
if msg["seq"] in seen: continue # rule 1: replay overlaps live
seen.add(msg["seq"])
if msg.get("expired"): continue # rule 4: history, not an instruction
print(msg["market"], "min", msg["min_odds"], "ideal", msg["ideal_odds"])
except Exception:
await asyncio.sleep(5)
asyncio.run(run())
The Python websockets package renamed this keyword in v14: it is
additional_headers on 14 and later, and extra_headers
on 13 and earlier. Passing the wrong one raises a TypeError on your
very first connection. If you would rather not care, use the
?key= form instead.
{
"schema": "sigma.alert.v1",
"seq": 48213, // public id, replay cursor and dedupe key
"event": "signal.opened",
"emitted_at": "2026-09-14T20:11:03Z",
"expires_at": "2026-09-14T20:14:03Z",
"ttl_seconds": 180,
"phase": "live", // live | prematch
"match_id": "10432871", // joins to /v1/matches
"league": "Denmark Superligaen",
"home": "FC Copenhagen",
"away": "Sonderjyske",
"kick_off": "2026-09-14T18:00:00Z",
"minute": 84, // null pre-match
"market": {
"category": "goals", // goals | corners | match_result
"label": "Goals",
"line": 4.5, // null for match_result
"side": "under", // over | under | home | draw | away
"selection": "Under 4.5 Goals" // for your logs; switch on the fields above
},
"min_odds": 1.55, // our hard floor. NEVER stake below this.
"ideal_odds": 1.74, // ideal average odds. Read the next section.
"stake_hint": { "units": 2.0, "scheme": "edge_tier",
"note": "Informational. Use your own staking rules." }
}
We do not send you an odds figure to bet at. You price at your own bookmaker, so ours would be at best irrelevant to you and at worst unfillable. You get two reference numbers instead, and the difference between them matters:
| Field | What it is |
|---|---|
min_odds | The floor beneath which our model would not have proposed this bet at all. Do not bet below this odds value. |
ideal_odds | The average price at which comparable past signals stayed stable, computed from realised results and refreshed on a schedule. |
If you want a single rule: place only when your bookmaker offers
ideal_odds or better, and skip otherwise. We publish both
numbers so that call is yours to make rather than ours to hide. If you decide to
place stakes below ideal_odds, you can — but never below
min_odds.
Past performance does not predict future results. Nothing here is a guarantee of profit, and every bet you place is your own decision and your own risk.
Once a match is over and we have graded the signal, the stream sends a second event,
signal.settled, so you can reconcile your own book against ours. It shares
the same seq numbering and the same cursor as signal.opened —
you do not track anything separately.
{
"schema": "sigma.alert.v1",
"seq": 48300, // its own seq: same cursor and dedupe rules as a signal
"event": "signal.settled",
"opened_seq": 48213, // the signal.opened this one settles
"emitted_at": "2026-09-14T22:31:40Z",
"match_id": "10432871", // joins to /v1/matches
"result": "GREEN", // GREEN | RED | VOID
"final_score": { "home": 1, "away": 3 },
"actual_value": 4 // the figure the grade was computed from (total goals, total corners)
}
| Field | What it is |
|---|---|
opened_seq | Join key. Match it to the seq of the signal.opened you stored; no need to match on fixture and market. |
result | GREEN (the selection won), RED (it lost) or VOID (stake returned). |
actual_value | The observed total the line was graded against, so you can check the grade yourself rather than trust it. |
Profit depends on the price you actually filled at and the stake you actually placed,
and we know neither. Work out your own P&L from result together with
your recorded fill price and stake. Do not derive it from min_odds or
ideal_odds: those are reference prices, not prices anyone was paid.
Settlements are not urgent and are not instant. They arrive some minutes to hours after
the final whistle, in no particular order relative to other signals, and each signal is
settled at most once. A signal can also be settled after you have gone offline, so
reconnecting with ?since= delivers anything you missed. A settlement is
informational and can never be placed on, so expired does not apply to it.
seq. Replay overlaps live delivery on
purpose, so you may see the same signal twice. Duplicates are cheap; gaps are not.seq you processed and reconnect with
?since=. On a first connection take head_seq from the
stream.ready frame. Advance your cursor even for signals you skip.expired: true, or after
expires_at. Replayed history is for reconciling your book, not for
betting.| event | Meaning |
|---|---|
stream.ready | Connected. Carries head_seq, the current end of the feed. |
heartbeat | The feed is alive. Every 20s. |
replay.complete | Your backlog is delivered; you are current. |
replay.truncated | You missed more than one batch. Reconnect from resume_from — you are not current. |
signal.opened | A signal. The thing you are here for. |
signal.settled | How an earlier signal turned out. Links back through opened_seq; see Settlement above. |
sandbox.complete | Sandbox replay finished (test keys only). |
An Enterprise plan manages its production key from the Sigma Predict dashboard (Profile → API access), not through us directly. Regenerating gives you a NEW key immediately while your OLD one keeps working for 12 hours, so an unattended bot is never cut off mid-session — update your configuration and redeploy within that window. Revoking (for a leaked key) is immediate, with no grace period, and also invalidates any key still inside a replacement window.
A working production key can be replaced once every 7 days. That limit applies only while you hold one: if you revoke a key — because it leaked, or because you want a clean replacement sooner — you can generate the next one straight away. The dashboard shows the date the limit lifts.
A connection past your plan's simultaneous-connection limit is closed with code
1008, reason max_connections_exceeded — see the table
below. Reconnect after closing your other session, or use a single long-lived
connection with your own internal fan-out.
A sandbox key (sk_test_…) replays real historical signals
at speed the moment you connect, so you can exercise your parser without waiting for a
live alert. Every sandbox frame is flagged sandbox: true and
expired: true — they are real past signals rather than invented fixtures,
and they must never be placed.
Generate one alongside your production key in the dashboard (Profile → API access). A sandbox key is separate from your live key in every way: its own value, its own revocation, and no 7-day limit — you can replace it once an hour while you are iterating. Replacing it takes effect immediately, with no grace period, because nothing unattended should be running against a test credential.
REST requests are limited per key. X-RateLimit-Limit and
X-RateLimit-Remaining come back on every response, and a 429
carries Retry-After. The fixture list changes once a day, so polling it
more than a few times an hour achieves nothing. The stream itself has no limit.
Every error has the same shape:
{ "error": "rate_limited", "message": "Limit is 60 requests per minute. Retry in 24s." }
REST error codes: unauthorised (401), bad_request (400),
not_found (404), rate_limited (429).
WebSocket failures come in two shapes, and they are worth telling apart when something will not connect:
| When | What you see |
|---|---|
| Bad or missing key, at connect | HTTP 403 on the upgrade — the handshake is refused, so there is no close code to read. If your client reports something like InvalidStatusCode: 403, your key is the problem. |
| Key revoked, rotated past grace, or plan downgraded/ended while connected | Close code 1008, reason key_revoked. Checked roughly every 60s, so a socket can outlive the change by up to that long. |
| Past your plan's simultaneous-connection limit | Close code 1008, reason max_connections_exceeded. |
| Your client stopped reading | Close code 1008, reason queue_overflow. Reconnect with your cursor; nothing is lost. |
| We deployed | Close code 1001. Reconnect with your cursor. |