Sigma Predict · Signals API · v1

Football betting signals, for machines

The same alerts our subscribers receive, delivered to your bot as JSON — a fixture list over HTTPS and a live signal stream over WebSocket.

Endpoints

GEThttps://api.sigmapredict.com/v1/matches Fixtures we plan to cover, with the match IDs every signal references. Refreshed daily.
GEThttps://api.sigmapredict.com/v1/matches/{match_id} One fixture, for resolving an ID that arrives mid-session.
GEThttps://api.sigmapredict.com/v1/meta Confirms your key works and reports your rate limit. Start here.
WSSwss://api.sigmapredict.com/v1/stream The live signal stream. Same host and port as the REST calls — only the scheme changes.

Authentication

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.

Quick start

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())
One library gotcha

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.

The signal payload

{
  "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." }
}

Prices: read this before you place anything

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:

FieldWhat it is
min_oddsThe floor beneath which our model would not have proposed this bet at all. Do not bet below this odds value.
ideal_oddsThe average price at which comparable past signals stayed stable, computed from realised results and refreshed on a schedule.
What this means in practice

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.

Settlement: how a signal turned out

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)
}
FieldWhat it is
opened_seqJoin key. Match it to the seq of the signal.opened you stored; no need to match on fixture and market.
resultGREEN (the selection won), RED (it lost) or VOID (stake returned).
actual_valueThe observed total the line was graded against, so you can check the grade yourself rather than trust it.
There is no profit figure, on purpose

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.

Four rules for a correct client

  1. Dedupe on seq. Replay overlaps live delivery on purpose, so you may see the same signal twice. Duplicates are cheap; gaps are not.
  2. Persist the last 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.
  3. Reconnect after two missed heartbeats. They arrive every 20 seconds. This feed is quiet most of the time, so silence on its own tells you nothing.
  4. Never place on expired: true, or after expires_at. Replayed history is for reconciling your book, not for betting.

Control frames

eventMeaning
stream.readyConnected. Carries head_seq, the current end of the feed.
heartbeatThe feed is alive. Every 20s.
replay.completeYour backlog is delivered; you are current.
replay.truncatedYou missed more than one batch. Reconnect from resume_from — you are not current.
signal.openedA signal. The thing you are here for.
signal.settledHow an earlier signal turned out. Links back through opened_seq; see Settlement above.
sandbox.completeSandbox replay finished (test keys only).

Rotating your key

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.

Testing without waiting

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.

Rate limits and errors

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:

WhenWhat 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 connectedClose 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 limitClose code 1008, reason max_connections_exceeded.
Your client stopped readingClose code 1008, reason queue_overflow. Reconnect with your cursor; nothing is lost.
We deployedClose code 1001. Reconnect with your cursor.