eSoccer API

Introduction

Customer delivery supports SSE and JSON over HTTPS. UDP is postponed until a dedicated UDP edge is available and is not offered by shared delivery. GET /session/grant returns HTTP 200 with transports: ["sse_frames", "sse_json"] and message: "UDP unavailable; use SSE.", including when UDP is requested. No UDP endpoint is returned. The SDK defaults to SSE; an AERON_UDP configuration falls back to SSE after reading this grant.

Connect your server to pre-match and live eSoccer odds. Get fixture metadata, price changes, bet stops, results, settlements, cancellations and rollbacks. Your backoffice controls your margins. The API delivers your prices.

Getting started

Discover fixtures in the catalog, fetch an odds snapshot, then stream changes from its cut. Run a separate accounting reader to recover settlements and corrections. The API reference lists every route, parameter, response and stream event model. Download the OpenAPI YAML or OpenAPI JSON.

Authentication

Send X-API-Key on authenticated requests. Keep your key on your server; send only the data your application needs to browsers and mobile apps. Set BASE to your API origin (without a trailing slash), API_KEY to your secret, and FIXTURE to a fixture urn returned by the catalog.

Quickstart: a minimal app

This is the smallest complete program. It reads your key from one environment variable, loads fixture names from the catalog once, keeps a book of current prices in memory and prints one line for each price change, bet stop and settlement:

Output
16:24:45  Bayern (Shelby) vs Hoffenheim (Professor) · Esoccer GT Leagues - 12 mins play · 20 Oct 16:15  1x2  1.42 / 3.60 / 5.50
16:24:51  Spain (GUARDIAN) vs France (DEZZY) · Esoccer H2H GG League - 8 mins play · 20 Oct 16:23  BET STOP  3 lines
16:26:47  Newcastle (MJ) vs Arsenal (SPARTAN) · Esoccer H2H GG League - 8 mins play · 20 Oct 16:12  SETTLED  Total 2.5  won: over 2.5

How it works:

  1. It sends your key in the X-API-Key header. Run it on your server only.
  2. It reads the tournaments and their fixtures once, to show each fixture by its name, tournament and kickoff. A fixture that appears later is read once, when it is first seen.
  3. It reads the current odds (fixtures and cut), then opens the stream from that cut. It merges odds_change, marks lines suspended on bet_stop and removes settled lines. A snapshot_start stages a new book; snapshot_complete replaces the old one.
  4. When the connection fails, or no data arrives for 30 seconds (alive comes every ten seconds), it waits (1, 2, 4 … 60 seconds, or Retry-After), reads a fresh snapshot and opens a new stream from its cut. It stops on 401, 402 and 403.

The printed settlements are for display. The stream does not promise that you see every event while you are disconnected: to settle tickets, run the accounting reader in Settlements and accounting. For a client that resumes from the last applied id, see Reconnect.

Python (3.11 or later). Save the two files and run:

Shell
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
FEED_API_KEY=your-key python app.py
requirements.txt · Output
httpx==0.28.1
httpx-sse==0.4.3
app.py · Python
"""Minimal odds app: one printed line per price change, bet stop and settlement."""
import json, os, re, time
from datetime import datetime
import httpx
from httpx_sse import connect_sse

key = os.environ['FEED_API_KEY']  # Keep the key on your server.
base = os.environ.get('FEED_API_URL', 'https://dev.feed.sportlive.hr')
headers = {'X-API-Key': key}
client = httpx.Client(timeout=30)  # alive arrives every 10 s; 30 s of silence reconnects.
titles, markets, book, staged = {}, {}, {}, None

def remember(fixture, tournament):  # "Home vs Away · Tournament · kickoff"
    kickoff = datetime.fromisoformat(fixture['scheduledTime']).astimezone()
    titles[fixture['urn']] = f"{fixture['name']} · {tournament} · {kickoff:%d %b %H:%M}"

def show(urn, text):
    if urn not in titles:  # A fixture that is new since start-up: look it up once.
        response = client.get(base + '/tenant-catalog-api/fixtures/' + urn, headers=headers)
        remember(response.json(), 'eSoccer') if response.is_success else None
    print(f"{datetime.now():%H:%M:%S}  {titles.get(urn, 'New fixture')}  {text}", flush=True)

def name(text, market, urn):  # Fill {total}, {hcp}, {$competitor1} from the line and teams.
    home, _, away = titles.get(urn, '').split(' · ')[0].partition(' vs ')
    values = {**market.get('specifiers', {}), '$competitor1': home, '$competitor2': away}
    return re.sub(r'\{[+-]?([$\w]+)\}', lambda m: values.get(m[1], m[0]), text)

def label(market, urn):  # "Total 2.5": the market's name in the descriptions, then its line.
    found = markets.get(market['marketId'], {'name': f"Market {market['marketId']}"})
    return ' '.join([name(found['name'], market, urn), *market.get('specifiers', {}).values()])

def merge(target, urn, changed):  # Book: fixture -> line -> status and outcome prices.
    for market in changed:
        line = target.setdefault(urn, {}).setdefault((market['marketId'], market['line']), {})
        line.update({o['outcomeId']: o['odds'] if o['active'] else None
                     for o in market.get('outcomes', [])}, status=market['status'])
        prices = ' / '.join('%.2f' % v if v else '-' for k, v in line.items() if k != 'status')
        yield label(market, urn) + '  ' + (prices if market['status'] == 'active' else 'suspended')

def apply(event, data):
    global staged
    urn, lines = data.get('fixtureUrn'), book.get(data.get('fixtureUrn'), {})
    if event == 'snapshot_start':  # A full replacement: keep the old book until it completes.
        staged = {}
    elif event == 'fixture_snapshot':
        (book if staged is None else staged).pop(urn, None)
        list(merge(book if staged is None else staged, urn, data['fixture']['markets']))
    elif event == 'snapshot_complete' and staged is not None:
        book.clear(), book.update(staged)
        staged = None
    elif event == 'odds_change':
        for text in merge(book, urn, data['markets']):
            show(urn, text)
    elif event == 'bet_stop':
        stopped = {(m['marketId'], m['line']) for m in data.get('markets', [])}
        for ref in lines if data['scope'] == 'fixture' else stopped & lines.keys():
            lines[ref]['status'] = 'suspended'
        show(urn, 'BET STOP  ' + (f'{len(stopped)} lines' if stopped else 'all markets'))
    elif event == 'bet_settlement':
        for market in data['markets']:
            lines.pop((market['marketId'], market['line']), None)
            outcomes = markets.get(market['marketId'], {}).get('outcomes', [])
            names = {o['id']: o['name'] for o in outcomes}
            won = [name(names.get(o['outcomeId'], o['outcomeId']), market, urn)
                   for o in market['outcomes'] if o['result'] == 'won']
            show(urn, f"SETTLED  {label(market, urn)}  won: {', '.join(won) or '-'}")
    elif event == 'fixture_removed':
        book.pop(urn, None)

response = client.get(base + '/tenant-catalog-api/descriptions/markets')  # No key needed.
markets.update({m['id']: m for m in response.json()} if response.is_success else {})
response = client.get(base + '/tenant-catalog-api/tournaments', headers=headers)
response.raise_for_status()
for tournament in response.json():  # Load fixture names once, tournament by tournament.
    detail = client.get(base + '/tenant-catalog-api/tournaments/' + tournament['urn'],
                        headers=headers)
    for fixture in detail.json()['fixtures'] if detail.is_success else []:
        remember(fixture, tournament['name'])
delay = 1
while True:  # Each connection: a fresh snapshot, then the stream from its cut.
    try:
        response = client.get(base + '/tenant-odds-output/odds/fixtures', headers=headers)
        response.raise_for_status()
        book.clear()
        for fixture in response.json()['fixtures']:
            list(merge(book, fixture['fixtureUrn'], fixture['markets']))
        print('book:', len(book), 'fixtures', flush=True)
        cursor, staged = response.json()['cut'], None
        with connect_sse(client, 'GET', base + '/tenant-odds-output/odds/events',
                         headers={**headers, 'Last-Event-ID': cursor}) as source:
            source.response.raise_for_status()
            for message in source.iter_sse():
                apply(message.event, json.loads(message.data))
                delay = 1
    except httpx.HTTPStatusError as error:
        if error.response.status_code in (401, 402, 403):
            raise  # Restore access first: see Errors and limits.
        delay = int(error.response.headers.get('Retry-After', delay))
    except httpx.TransportError:
        pass  # A network failure, or 30 s without data.
    print('reconnecting in', delay, 's', flush=True)
    time.sleep(delay)
    delay = min(delay * 2, 60)

API reference: GET /tenant-catalog-api/descriptions/markets GET /tenant-catalog-api/tournaments GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

Node.js (22 or later) uses the built-in fetch and the eventsource package:

Shell
npm install
FEED_API_KEY=your-key node app.mjs
package.json · JSON
{
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "dependencies": { "eventsource": "5.1.2" }
}
app.mjs · JavaScript
// Minimal odds app: one printed line per price change, bet stop and settlement.
import { EventSource } from 'eventsource';
const API_KEY = process.env.FEED_API_KEY;  // Keep the key on your server.
const BASE = process.env.FEED_API_URL ?? 'https://dev.feed.sportlive.hr';
const headers = { 'X-API-Key': API_KEY };
const titles = new Map(), markets = new Map();
let book = new Map(), staged = null, delay = 1;

const remember = (fixture, tournament) => titles.set(fixture.urn, [fixture.name, tournament,
  new Date(fixture.scheduledTime).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }),
].join(' · '));  // "Home vs Away · Tournament · kickoff"
async function show(urn, text) {
  if (!titles.has(urn)) {  // A fixture that is new since start-up: look it up once.
    const response = await fetch(BASE + '/tenant-catalog-api/fixtures/' + urn, { headers });
    if (response.ok) remember(await response.json(), 'eSoccer');
  }
  console.log(new Date().toLocaleTimeString(), '', titles.get(urn) ?? 'New fixture', '', text);
}
function fill(text, market, urn) {  // {total}, {hcp}, {$competitor1} from the line and teams.
  const [home, away] = (titles.get(urn) ?? '').split(' · ')[0].split(' vs ');
  const values = { ...market.specifiers, $competitor1: home, $competitor2: away };
  return text.replace(/\{[+-]?([$\w]+)\}/g, (all, name) => values[name] ?? all);
}
const label = (market, urn) => [fill(markets.get(market.marketId)?.name ??
  `Market ${market.marketId}`, market, urn), ...Object.values(market.specifiers ?? {})].join(' ');
const ref = (market) => `${market.marketId}|${market.line}`;

function* merge(target, urn, changed) {  // Book: fixture -> line -> status and outcome prices.
  if (!target.has(urn)) target.set(urn, new Map());
  for (const market of changed) {
    const line = target.get(urn).get(ref(market)) ?? { odds: {} };
    line.status = market.status;
    for (const o of market.outcomes ?? []) line.odds[o.outcomeId] = o.active ? o.odds : null;
    target.get(urn).set(ref(market), line);
    const prices = Object.values(line.odds).map((v) => (v ? v.toFixed(2) : '-')).join(' / ');
    yield `${label(market, urn)}  ${market.status === 'active' ? prices : 'suspended'}`;
  }
}
async function apply(event, data) {
  const urn = data.fixtureUrn, lines = book.get(urn) ?? new Map();
  if (event === 'snapshot_start') staged = new Map();  // Keep the old book until complete.
  if (event === 'fixture_snapshot') {
    (staged ?? book).delete(urn);
    [...merge(staged ?? book, urn, data.fixture.markets)];
  }
  if (event === 'snapshot_complete' && staged) [book, staged] = [staged, null];
  if (event === 'odds_change') for (const text of merge(book, urn, data.markets)) {
    await show(urn, text);
  }
  if (event === 'bet_stop') {
    const stopped = new Set((data.markets ?? []).map(ref));
    for (const [key, line] of lines) {
      if (data.scope === 'fixture' || stopped.has(key)) line.status = 'suspended';
    }
    await show(urn, `BET STOP  ${stopped.size ? `${stopped.size} lines` : 'all markets'}`);
  }
  if (event === 'bet_settlement') for (const market of data.markets) {
    lines.delete(ref(market));
    const outcomes = markets.get(market.marketId)?.outcomes ?? [];
    const names = new Map(outcomes.map((o) => [o.id, o.name]));
    const won = market.outcomes.filter((o) => o.result === 'won')
      .map((o) => fill(names.get(o.outcomeId) ?? o.outcomeId, market, urn));
    await show(urn, `SETTLED  ${label(market, urn)}  won: ${won.join(', ') || '-'}`);
  }
  if (event === 'fixture_removed') book.delete(urn);
}
const names = ['snapshot_start', 'fixture_snapshot', 'snapshot_complete', 'odds_change',
  'bet_stop', 'bet_settlement', 'fixture_removed', 'alive'];

function later(why, seconds = delay) {  // Rebuild from a new snapshot after a backoff.
  console.log(`reconnecting in ${seconds} s (${why})`);
  setTimeout(() => connect().catch(failed), seconds * 1000);
  delay = Math.min(delay * 2, 60);
}
function failed(error) {
  if ([401, 402, 403].includes(error.status)) throw error;  // Restore access first.
  later(error.message, error.retryAfter ?? delay);
}
async function connect() {  // Each connection: a fresh snapshot, then the stream from its cut.
  const response = await fetch(BASE + '/tenant-odds-output/odds/fixtures', { headers });
  if (!response.ok) {
    throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status,
      retryAfter: Number(response.headers.get('Retry-After')) || undefined });
  }
  const snapshot = await response.json();
  book = new Map(), staged = null;
  for (const fixture of snapshot.fixtures) [...merge(book, fixture.fixtureUrn, fixture.markets)];
  console.log('book:', book.size, 'fixtures');
  const lastAppliedId = snapshot.cut;
  const source = new EventSource(BASE + '/tenant-odds-output/odds/events', {
    fetch: (url, init) => fetch(url, {
      ...init,
      headers: { ...init.headers, ...headers, 'Last-Event-ID': lastAppliedId },
    }),
  });
  let queue = Promise.resolve(), watchdog;
  const stop = (why) => {
    if (source.readyState === EventSource.CLOSED) return;
    source.close(), clearTimeout(watchdog), later(why);
  };
  const arm = () => {  // alive arrives every 10 s; 30 s of silence reconnects.
    clearTimeout(watchdog), watchdog = setTimeout(() => stop('no data for 30 s'), 30000);
  };
  for (const name of names) source.addEventListener(name, (message) => {
    arm(), delay = 1;
    queue = queue.then(() => apply(name, JSON.parse(message.data)));
  });
  source.addEventListener('error', (error) => stop(error.message ?? 'network'));
  arm();
}

const descriptions = await fetch(BASE + '/tenant-catalog-api/descriptions/markets');  // No key.
if (descriptions.ok) for (const market of await descriptions.json()) markets.set(market.id, market);
const list = await fetch(BASE + '/tenant-catalog-api/tournaments', { headers });
if (!list.ok) throw new Error(`HTTP ${list.status}`);
for (const tournament of await list.json()) {  // Load fixture names once, per tournament.
  const detail = await fetch(
    BASE + '/tenant-catalog-api/tournaments/' + tournament.urn, { headers });
  if (detail.ok) for (const f of (await detail.json()).fixtures) remember(f, tournament.name);
}
await connect().catch(failed);

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events GET /tenant-catalog-api/descriptions/markets GET /tenant-catalog-api/tournaments

FEED_API_URL changes the API origin; the default is https://dev.feed.sportlive.hr.

Catalog

Read the sports and tournament lists. Fetch a tournament by its urn to read its fixtures, then use a fixture's urn as FIXTURE. Match the tournament's sportUrn to a sport's urn. Keyless reference descriptions explain markets, outcomes, specifiers and reasons.

Shell
curl "$BASE/tenant-catalog-api/sports" \
  -H "X-API-Key: $API_KEY"
curl "$BASE/tenant-catalog-api/tournaments" \
  -H "X-API-Key: $API_KEY"
curl "$BASE/tenant-catalog-api/fixtures/$FIXTURE?locale=en" \
  -H "X-API-Key: $API_KEY"

API reference: GET /tenant-catalog-api/sports GET /tenant-catalog-api/tournaments GET /tenant-catalog-api/fixtures/{urn}

List routes return JSON arrays. Single-entity routes (/sports/{urn}, /categories/{urn}, /tournaments/{urn}, /competitors/{urn} and /fixtures/{urn}) return one JSON object, or 404 when the identifier is unknown or outside your subscription. Read fields directly from that object, for example response.urn, without an array index. /fixtures/resolved returns an array of the available fixtures even when empty.

A single-fixture response looks like this:

JSON
{
  "urn": "sportlive:match:42",
  "sportUrn": "sportlive:sport:2",
  "categoryUrn": "sportlive:category:4",
  "tournamentUrn": "sportlive:tournament:17",
  "name": "Team A v Team B",
  "scheduledTime": "2026-10-14T12:00:00Z",
  "liveOdds": 1
}

Save the ETag and send it in If-None-Match to revalidate. An unchanged answer returns 304 with no body; reuse your cached object. Catalog answers are Cache-Control: private, max-age=0, must-revalidate and Vary: X-API-Key: they belong to your key, so store them only in your own client and revalidate on every use; another key's ETag never matches yours. Reference descriptions are public and cached for 60 seconds.

Odds stream

Snapshot and stream recovery flow

Read current odds, replace your book with fixtures and save cut. Open the stream with that cut as Last-Event-ID. Use identical sport and fixture filters for snapshot and stream, or omit both filters for your whole book.

Shell
curl "$BASE/tenant-odds-output/odds/fixtures" \
  -H "X-API-Key: $API_KEY"

API reference: GET /tenant-odds-output/odds/fixtures

Apply each event successfully before saving its id. Keep resume ids opaque: an id resumes only with your key and the filters you read it with, and any other id starts a new snapshot (resume_gap). A full cancel has no from, to or operation; timed or scoped cancellations wake accounting without removing price lines outside their scope. The clients below decode SSE framing for you. JSON examples show data payloads only.

EventWhen it arrivesWhat to do
snapshot_startReplacement startsClear the cursor; begin a new staged book.
fixture_snapshotDuring replacement or fixture discoveryReplace this fixture; stage it during replacement.
snapshot_completeReplacement finishesPublish the staged book atomically, then save the id.
odds_changePrices or match state changeMerge market lines, outcomes and matchStatus. Only changed lines are listed; an unchanged book makes no event.
bet_stopBetting stopsSuspend the fixture or the listed market lines.
fixture_changeMetadata changesRefresh catalog metadata and apply nextStartTime.
fixture_removedA fixture leaves the bookRemove the fixture.
aliveEvery ten secondsRefresh the watchdog and check each feed status.
bet_settlementA settlement arrivesRemove settled price lines; wake the accounting reader.
bet_cancelA cancellation arrivesWake accounting; remove lines only for an untimed full cancel.
rollback_bet_settlementA settlement is undoneWake accounting to recover the correction.
rollback_bet_cancelA cancellation is undoneWake accounting to recover the correction.

Check feed health with the status route and with alive. Both expose feeds, with feed equal to live or prematch, and status equal to up or down. Both labels share one delivery stream and report the same transport status: a credible subscribed heartbeat within twenty seconds means up; missing, expired or unsubscribed heartbeats mean down. Transport up does not certify upcoming fixtures, active prices, upstream freshness or complete recovery. Check fixture phases and active market statuses in snapshots/events for current coverage, and warm/complete for recovery readiness. A not_started fixture can be listed before any verified prices arrive.

Shell
curl "$BASE/tenant-odds-output/odds/status" \
  -H "X-API-Key: $API_KEY"

API reference: GET /tenant-odds-output/odds/status

Reconnect

Reconnect with the last successfully applied id. Keep the cut if an unlogged heartbeat arrives before the first logged event. snapshot_start clears the cursor; other events advance it only when the library supplies a nonempty id. On process restart, fetch a fresh REST snapshot.

Stream ids are opaque resume cursors. A raw frame has an id like r.<epoch>.<sequence>; a JSON event has one like j.<epoch>.<sequence>.<n>. Each is signed and bound to your tenant and to the filters you read it with, so never build, parse, compare or sort one: store the string you were given and send it back unchanged. A cursor from another key, other filters or a past server epoch is not an error; you get a fresh snapshot (resume_gap in JSON, reset in binary frames).

A reconnect can reach any of our servers: every server keeps the same recent events, so your id resumes wherever it lands. After a server restart your id no longer resumes; you then get a resume_gap snapshot (JSON) or reset (binary frames), as after any gap.

Supply synchronous application hooks replace_book / replaceBook and apply_event / applyEvent. The replacement hook atomically installs REST fixtures. The event hook implements the table above, stages replacement snapshots and swaps books on snapshot_complete. On a second snapshot_start, discard old staging. These hooks must return only after applying the event; propagate failures.

A replacement interrupted by a disconnect can resume from its last applied id; retain its staging until completion or a new snapshot_start. If staging is lost, reconnect with an empty cursor for a new replacement. A partial replacement remains current through events; a warmed replacement follows clean recovery.

For binary SSE recovery, SnapshotComplete with COMPLETE certifies the replayed frames. If the requested accounting window starts before retained coverage, the marker has no recovery cursor and includes oldestAvailable (epoch milliseconds), the start of available coverage; 0 means unknown. Keep older tickets unresolved and repair them through accounting. An unreadable accounting journal answers INCOMPLETE; retry recovery and retain the previous cursor.

Python uses httpx==0.28.1 and httpx-sse==0.4.3.

Python
import json, os, time
import httpx
from httpx_sse import connect_sse
from your_app import replace_book, apply_event
base, key = os.environ['BASE'], os.environ['API_KEY']
headers = {'X-API-Key': key}
names = (
    'snapshot_start', 'fixture_snapshot', 'snapshot_complete', 'odds_change',
    'bet_stop', 'fixture_change', 'fixture_removed', 'alive', 'bet_settlement',
    'bet_cancel', 'rollback_bet_settlement', 'rollback_bet_cancel',
)
handlers = {name: lambda data, name=name: apply_event(name, data) for name in names}
with httpx.Client(timeout=30) as client:
    response = client.get(base + '/tenant-odds-output/odds/fixtures', headers=headers)
    response.raise_for_status()
    snapshot = response.json()
    replace_book(snapshot['fixtures'])
    cursor = snapshot['cut']
    while True:
        try:
            with connect_sse(
                client, 'GET', base + '/tenant-odds-output/odds/events',
                headers={**headers, 'Last-Event-ID': cursor},
            ) as source:
                source.response.raise_for_status()
                for message in source.iter_sse():
                    handlers[message.event](json.loads(message.data))
                    if message.event == 'snapshot_start' or message.id:
                        cursor = message.id
        except httpx.HTTPStatusError:
            raise  # Handle access and Retry-After before retrying.
        except httpx.TransportError:
            pass
        time.sleep(2)

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

Node uses [email protected]. The custom fetch sends the last applied id on every connection, including automatic retries. Event handlers are synchronous to preserve application order; close and recover before continuing after an application error.

JavaScript
import { EventSource } from 'eventsource';
import { replaceBook, applyEvent } from './your-app.js';
const { BASE, API_KEY } = process.env;
const headers = { 'X-API-Key': API_KEY };
const response = await fetch(BASE + '/tenant-odds-output/odds/fixtures', { headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const snapshot = await response.json();
replaceBook(snapshot.fixtures);
let lastAppliedId = snapshot.cut;
const source = new EventSource(BASE + '/tenant-odds-output/odds/events', {
  fetch: (url, init) => fetch(url, {
    ...init,
    headers: { ...init.headers, ...headers, 'Last-Event-ID': lastAppliedId },
  }),
});
const names = [
  'snapshot_start', 'fixture_snapshot', 'snapshot_complete', 'odds_change',
  'bet_stop', 'fixture_change', 'fixture_removed', 'alive', 'bet_settlement',
  'bet_cancel', 'rollback_bet_settlement', 'rollback_bet_cancel',
];
for (const name of names) {
  source.addEventListener(name, message => {
    try {
      applyEvent(name, JSON.parse(message.data));
      if (name === 'snapshot_start' || message.lastEventId) {
        lastAppliedId = message.lastEventId;
      }
    } catch (error) {
      source.close();  // Stop before an unapplied event can be skipped.
      throw error;
    }
  });
}
source.addEventListener('error', error => {
  if (error.code === 401 || error.code === 402 || error.code === 403) {
    source.close();  // Restore access before creating a new connection.
  }
  // Network failures reconnect automatically through the custom fetch above.
});

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

Keep the last applied book during a disconnect. Add an application watchdog: alive arrives every ten seconds; reconnect after thirty seconds without it. Respect Retry-After on HTTP failures; restore access before retrying auth errors.

HTTP/3 (optional)

The API works over HTTP/1.1, HTTP/2 and HTTP/3 (QUIC). Every answer advertises HTTP/3 in its Alt-Svc header, on IPv4 and on IPv6. Use HTTP/2 by default. HTTP/3 is optional: for one long-lived stream it brings little, and some networks block UDP port 443. If you use HTTP/3, keep HTTP/2 as the fallback. Browsers select HTTP/3 automatically.

ClientHTTP/3How
Java java.net.http.HttpClientJDK 26 or laterHttpClient.Version.HTTP_3; falls back to HTTP/2 or HTTP/1.1
.NET HttpClient.NET 7 or laterHttpVersion.Version30 with HttpVersionPolicy.RequestVersionOrLower; Windows 11 or Server 2022, or Linux with libmsquic
Python niquestsYes (QUIC built in)Upgrades after the first answer's Alt-Svc
Python httpx, httpx-sseNoHTTP/1.1 and HTTP/2 only; keep them for HTTP/2
Node.jsNoNo HTTP/3 client in Node.js today; use HTTP/2 or HTTP/1.1
curlOnly builds with HTTP/3--http3 (falls back); curl -V lists HTTP3 when supported

The examples below open the odds stream like the clients in Reconnect: the same snapshot, cut, X-API-Key and Last-Event-ID. They print each event; put your apply_event / applyEvent hook and cursor rules in place of the print.

Python with niquests==3.21.2. The snapshot answer teaches the session that HTTP/3 is available, so the stream connection uses it. stream.http_version is 30 on HTTP/3 and 20 on HTTP/2. niquests.Session(disable_http3=True) turns HTTP/3 off.

stream_h3.py · Python
import os
import niquests
base, key = os.environ['BASE'], os.environ['API_KEY']
sse_base = 'sse://' + base.removeprefix('https://')
headers = {'X-API-Key': key}
with niquests.Session() as session:
    # The first answer carries Alt-Svc; later connections to this origin use HTTP/3.
    response = session.get(base + '/tenant-odds-output/odds/fixtures', headers=headers)
    response.raise_for_status()
    cursor = response.json()['cut']
    stream = session.get(
        sse_base + '/tenant-odds-output/odds/events',
        headers={**headers, 'Last-Event-ID': cursor},
    )
    stream.raise_for_status()
    print('protocol', stream.http_version)  # 30 = HTTP/3, 20 = HTTP/2
    while not stream.extension.closed:
        message = stream.extension.next_payload()
        if message is None:
            continue
        print(message.event, message.id, len(message.data))

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

Java 26 or later. Save the class as StreamH3 with the .java extension and run that one file with java; it needs no build tool and no library. The client tries HTTP/3 and HTTP/2 together and keeps the first that works. stream.version() prints HTTP_3 or HTTP_2.

class StreamH3 · Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Pattern;

public class StreamH3 {
    public static void main(String[] args) throws Exception {
        String base = System.getenv("BASE"), key = System.getenv("API_KEY");
        // JDK 26+: prefer HTTP/3; the client falls back to HTTP/2 or HTTP/1.1.
        HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_3).build();
        HttpRequest snapshot = HttpRequest.newBuilder(
                URI.create(base + "/tenant-odds-output/odds/fixtures"))
            .header("X-API-Key", key).build();
        String body = client.send(snapshot, HttpResponse.BodyHandlers.ofString()).body();
        var cut = Pattern.compile("\"cut\"\\s*:\\s*\"([^\"]*)\"").matcher(body);
        String cursor = cut.find() ? cut.group(1) : "";  // Use your JSON library here.
        HttpRequest events = HttpRequest.newBuilder(
                URI.create(base + "/tenant-odds-output/odds/events"))
            .header("X-API-Key", key).header("Last-Event-ID", cursor).build();
        var stream = client.send(events, HttpResponse.BodyHandlers.ofLines());
        System.out.println("protocol " + stream.version());  // HTTP_3 or HTTP_2
        String event = "", lastId = "";
        StringBuilder data = new StringBuilder();
        for (String line : (Iterable<String>) stream.body()::iterator) {
            if (line.isEmpty()) {  // A blank line ends one event.
                if (!event.isEmpty()) System.out.println(event + " " + lastId + " " + data);
                event = "";
                data.setLength(0);
            } else if (line.startsWith("event:")) event = line.substring(6).strip();
            else if (line.startsWith("id:")) lastId = line.substring(3).strip();
            else if (line.startsWith("data:")) data.append(line.substring(5).strip());
        }
    }
}

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

C# with .NET 10 (or .NET 8 and 9 with the System.Net.ServerSentEvents package, version 10 or later, for EventId). On Linux, install libmsquic for HTTP/3; without it, the client uses HTTP/2. response.Version prints 3.0 on HTTP/3 and 2.0 on HTTP/2.

StreamH3.cs · C#
using System.Net;
using System.Net.ServerSentEvents;
using System.Text.Json;

var baseUrl = Environment.GetEnvironmentVariable("BASE");
var key = Environment.GetEnvironmentVariable("API_KEY");
// .NET 7+: ask for HTTP/3 and accept HTTP/2 or HTTP/1.1 when QUIC is not available.
using var client = new HttpClient
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
    Timeout = Timeout.InfiniteTimeSpan,
};
using var snapshot = new HttpRequestMessage(HttpMethod.Get,
    baseUrl + "/tenant-odds-output/odds/fixtures");
snapshot.Headers.Add("X-API-Key", key);
using var book = await client.SendAsync(snapshot);
book.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await book.Content.ReadAsStringAsync());
var cursor = json.RootElement.GetProperty("cut").GetString();
using var events = new HttpRequestMessage(HttpMethod.Get,
    baseUrl + "/tenant-odds-output/odds/events");
events.Headers.Add("X-API-Key", key);
events.Headers.Add("Last-Event-ID", cursor);
using var response = await client.SendAsync(events, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
Console.WriteLine($"protocol {response.Version}");  // 3.0 = HTTP/3, 2.0 = HTTP/2
await using var body = await response.Content.ReadAsStreamAsync();
await foreach (var item in SseParser.Create(body).EnumerateAsync())
{
    Console.WriteLine($"{item.EventType} {item.EventId} {item.Data.Length}");
}

API reference: GET /tenant-odds-output/odds/fixtures GET /tenant-odds-output/odds/events

Settlements and accounting

Accounting remains available during a payment or staff pause for existing bets. Keep a valid API key; revoked keys, inactive tenants and tenants without an entitlement cannot read accounting.

Run a separate accounting reader. Start with since in epoch milliseconds: the oldest ticket placement you still need to correct, or now if there are no tickets.

Shell
curl "$BASE/tenant-odds-output/odds/accounting?since=$SINCE&limit=500" \
  -H "X-API-Key: $API_KEY"
curl "$BASE/tenant-odds-output/odds/accounting?after=$NEXT&limit=500" \
  -H "X-API-Key: $API_KEY"
curl "$BASE/tenant-odds-output/odds/accounting/fixtures?fixture=$FIXTURE&since=$SINCE" \
  -H "X-API-Key: $API_KEY"

API reference: GET /tenant-odds-output/odds/accounting GET /tenant-odds-output/odds/accounting/fixtures

Apply events in order. Save next in the same transaction as ticket and wallet changes. Continue with after=next, omitting since. Use accountingId to recognise the same effect in the stream and the log. The accounting cursor is independent of SSE.

ResponseAction
more=trueRead the next page immediately
No more eventsPoll every five seconds; stream accounting events and snapshot_start wake the reader
gap is presentRepair all tickets still subject to correction before committing the page
Fixture repairRequest repeated fixture ids, at most 50 per call; include the oldest correction-window since
Missing fixture or complete=falseLeave tickets unresolved and contact support

Repair settled lines with their factors, void cancelled lines and reopen open lines. Include paid, voided and hidden tickets in repair. Commit repair, page effects and next atomically. The correction window is 72 hours. Accounting changes bets and wallets; it does not edit the odds book.

Voids

A void returns the stake: settle the voided part of a ticket at odds 1.0. A void arrives as a bet_cancel for the listed lines, or as a bet_settlement outcome with a voidFactor (1 voids the whole line, 0.5 voids half of it). Accounting carries the same effect: a market with state equal to cancelled, or an outcome with a voidFactor.

We void a line only in these cases:

  1. The match is abandoned or cancelled by the source of the result.
  2. The match ended, but the evidence we hold cannot decide this line.
  3. The line can never be decided, for example because we never saw the end of the match.

Before we void, we settle every line that the match already decided. For example, an over line that the score already passed is settled as won, even if the match is abandoned. A loss of data on our side is never an abandoned match: we stop the markets and wait for evidence of the result. If no evidence arrives, the open lines are voided as unverifiable.

The deadline: lines that are still open are normally voided 30 minutes after the end of the match. If we never see the end, they are voided after 6 hours without new data. A market that we keep for a manual check waits for a manual settlement instead.

voidReason explains a void. The full list is at /tenant-catalog-api/descriptions/void_reasons. The codes you can see on eSoccer:

voidReasonMeaning
3 RESULT_UNVERIFIABLEThe match ended, but the result of this line cannot be confirmed.
5 CANCELLED_EVENTThe match was cancelled before it started.
10 EVENT_ABANDONEDThe source of the result abandoned the match.
14 NO_RESULT_ASSIGNABLENo result can be assigned, for example the end was never seen.

voidReason can be absent. Treat an absent reason as 0 OTHER: the void still applies. A void is corrected only like any other settlement, with a rollback event inside the 72-hour correction window.

Event reference

Each example is the decoded JSON data payload, taken from the published contract. The typed stream models describe the event name, opaque id and payload separately.

snapshot_start

Clear the cursor; begin a new staged book.

JSON
{
  "reason": "connect",
  "sentAt": 1800000000000
}

fixture_snapshot

Replace this fixture; stage it during replacement.

JSON
{
  "fixture": {
    "fixtureUrn": "sportlive:match:9001",
    "markets": [
      {
        "extendedSpecifiers": {
          "score": "0:0"
        },
        "line": "hcp=-0.5|total=2.5",
        "marketId": 1,
        "outcomes": [
          {
            "active": true,
            "odds": 1.85,
            "outcomeId": "1"
          }
        ],
        "specifiers": {
          "hcp": "-0.5",
          "total": "2.5"
        },
        "status": "active"
      }
    ],
    "matchStatus": {
      "awayScore": 0,
      "homeScore": 0,
      "status": "live"
    },
    "sportUrn": "sportlive:sport:2",
    "updatedAt": 1800000000000
  },
  "fixtureUrn": "sportlive:match:9001",
  "reason": "snapshot",
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

snapshot_complete

Publish the staged book atomically, then save the id.

JSON
{
  "fixtures": 1,
  "sentAt": 1800000000000
}

odds_change

Merge market lines, outcomes and matchStatus. A match state change while the fixture's markets are suspended (for example the end of a match) comes with an empty markets list and the new matchStatus.

JSON
{
  "fixtureUrn": "sportlive:match:9001",
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "outcomes": [
        {
          "active": true,
          "odds": 1.85,
          "outcomeId": "1"
        }
      ],
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      },
      "status": "active"
    }
  ],
  "matchStatus": {
    "awayScore": 0,
    "homeScore": 0,
    "status": "live"
  },
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

bet_stop

Suspend the fixture or the listed market lines.

JSON
{
  "fixtureUrn": "sportlive:match:9001",
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      }
    }
  ],
  "producedAt": 1800000000000,
  "reason": 900,
  "scope": "markets",
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2",
  "status": "suspended"
}

fixture_change

Refresh catalog metadata and apply nextStartTime.

JSON
{
  "changeType": "status",
  "fixtureUrn": "sportlive:match:9001",
  "lastModified": 1800000000000,
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

fixture_removed

Remove the fixture.

JSON
{
  "fixtureUrn": "sportlive:match:9001",
  "producedAt": 1800000000000,
  "reason": "ended",
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

alive

Refresh the watchdog and check each feed status.

JSON
{
  "feeds": [
    {
      "feed": "live",
      "status": "up"
    },
    {
      "feed": "prematch",
      "status": "up"
    }
  ],
  "sentAt": 1800000000000
}

bet_settlement

Remove settled price lines; wake the accounting reader.

JSON
{
  "accountingId": "0123456789abcdef0123456789abcdef",
  "certainty": 1,
  "fixtureUrn": "sportlive:match:9001",
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "outcomes": [
        {
          "deadHeatFactor": 0.5,
          "outcomeId": "1",
          "result": "won",
          "voidFactor": 0.5
        }
      ],
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      }
    }
  ],
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

bet_cancel

Wake accounting; remove lines only for an untimed full cancel.

JSON
{
  "accountingId": "0123456789abcdef0123456789abcde1",
  "fixtureUrn": "sportlive:match:9001",
  "from": 1799999995000,
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "outcomeIds": [
        "1"
      ],
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      }
    }
  ],
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2",
  "to": 1800000000000
}

rollback_bet_settlement

Wake accounting to recover the correction.

JSON
{
  "accountingId": "0123456789abcdef0123456789abcde2",
  "fixtureUrn": "sportlive:match:9001",
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      }
    }
  ],
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

rollback_bet_cancel

Wake accounting to recover the correction.

JSON
{
  "accountingId": "0123456789abcdef0123456789abcde3",
  "fixtureUrn": "sportlive:match:9001",
  "markets": [
    {
      "extendedSpecifiers": {
        "score": "0:0"
      },
      "line": "hcp=-0.5|total=2.5",
      "marketId": 1,
      "specifiers": {
        "hcp": "-0.5",
        "total": "2.5"
      }
    }
  ],
  "producedAt": 1800000000000,
  "sentAt": 1800000000000,
  "sportUrn": "sportlive:sport:2"
}

Recovery node_id accepts at most 128 printable ASCII bytes; invalid values return 400.

Errors and limits

Check the HTTP status before applying a response. Keep the last applied state and cursor on failures. Your backoffice shows the limits assigned to each API key.

StatusMeaningNext step
429requests_per_minute or concurrent_streams limit reachedClose redundant streams; wait for Retry-After seconds
401Key missing, unknown or revoked, or its tenant is inactiveDo not retry; fix or replace the key
402Access paused for paymentFollow payUrl; contact support if absent; resume after access is restored
403suspended or not_entitledContact support about access
409A recovery request_id was already accepted in the last 10 minutes (request_id_in_use)Ask again with a new request_id
503API unavailable, snapshot warming, accounting recovering or too many recovery requests waitingWait for Retry-After seconds, then retry (a recovery request with the same request_id)

Each stream consumes one concurrent-stream slot until it closes. Opening a stream also counts toward requests per minute. Catalog, snapshots, status and accounting requests count too. A 401 means the key is missing, unknown or revoked. An open stream is checked again every 30 seconds: when access is paused or the key is revoked, the stream closes, and the next connect answers 402, 403 or 401.

Changes

2026-10-22

Support

Write to [email protected]. Include the route, HTTP status and time of the issue. Keep your API key private.

Free trial and payment

The free trial requires no payment details. We request payment before the trial ends, by invoice or card through your account's pay link. If unpaid at trial end, access is suspended and restored on payment. Starting the trial does not authorize a charge. No automatic charge is made without your action and consent. Prices are shown or sent with your invoice or by the reselling partner.