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:
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.5How it works:
- It sends your key in the
X-API-Keyheader. Run it on your server only. - 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.
- It reads the current odds (
fixturesandcut), then opens the stream from thatcut. It mergesodds_change, marks lines suspended onbet_stopand removes settled lines. Asnapshot_startstages a new book;snapshot_completereplaces the old one. - When the connection fails, or no data arrives for 30 seconds (
alivecomes every ten seconds), it waits (1, 2, 4 … 60 seconds, orRetry-After), reads a fresh snapshot and opens a new stream from itscut. It stops on401,402and403.
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:
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
FEED_API_KEY=your-key python app.pyhttpx==0.28.1
httpx-sse==0.4.3"""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:
npm install
FEED_API_KEY=your-key node app.mjs{
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"dependencies": { "eventsource": "5.1.2" }
}// 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.
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:
{
"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
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.
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.
| Event | When it arrives | What to do |
|---|---|---|
snapshot_start | Replacement starts | Clear the cursor; begin a new staged book. |
fixture_snapshot | During replacement or fixture discovery | Replace this fixture; stage it during replacement. |
snapshot_complete | Replacement finishes | Publish the staged book atomically, then save the id. |
odds_change | Prices or match state change | Merge market lines, outcomes and matchStatus. Only changed lines are listed; an unchanged book makes no event. |
bet_stop | Betting stops | Suspend the fixture or the listed market lines. |
fixture_change | Metadata changes | Refresh catalog metadata and apply nextStartTime. |
fixture_removed | A fixture leaves the book | Remove the fixture. |
alive | Every ten seconds | Refresh the watchdog and check each feed status. |
bet_settlement | A settlement arrives | Remove settled price lines; wake the accounting reader. |
bet_cancel | A cancellation arrives | Wake accounting; remove lines only for an untimed full cancel. |
rollback_bet_settlement | A settlement is undone | Wake accounting to recover the correction. |
rollback_bet_cancel | A cancellation is undone | Wake 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.
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.
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.
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.
| Client | HTTP/3 | How |
|---|---|---|
Java java.net.http.HttpClient | JDK 26 or later | HttpClient.Version.HTTP_3; falls back to HTTP/2 or HTTP/1.1 |
.NET HttpClient | .NET 7 or later | HttpVersion.Version30 with HttpVersionPolicy.RequestVersionOrLower; Windows 11 or Server 2022, or Linux with libmsquic |
Python niquests | Yes (QUIC built in) | Upgrades after the first answer's Alt-Svc |
Python httpx, httpx-sse | No | HTTP/1.1 and HTTP/2 only; keep them for HTTP/2 |
| Node.js | No | No HTTP/3 client in Node.js today; use HTTP/2 or HTTP/1.1 |
| curl | Only 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.
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.
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.
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.
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.
| Response | Action |
|---|---|
more=true | Read the next page immediately |
| No more events | Poll every five seconds; stream accounting events and snapshot_start wake the reader |
gap is present | Repair all tickets still subject to correction before committing the page |
| Fixture repair | Request repeated fixture ids, at most 50 per call; include the oldest correction-window since |
Missing fixture or complete=false | Leave 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:
- The match is abandoned or cancelled by the source of the result.
- The match ended, but the evidence we hold cannot decide this line.
- 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:
voidReason | Meaning |
|---|---|
3 RESULT_UNVERIFIABLE | The match ended, but the result of this line cannot be confirmed. |
5 CANCELLED_EVENT | The match was cancelled before it started. |
10 EVENT_ABANDONED | The source of the result abandoned the match. |
14 NO_RESULT_ASSIGNABLE | No 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.
{
"reason": "connect",
"sentAt": 1800000000000
}fixture_snapshot
Replace this fixture; stage it during replacement.
{
"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.
{
"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.
{
"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.
{
"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.
{
"changeType": "status",
"fixtureUrn": "sportlive:match:9001",
"lastModified": 1800000000000,
"producedAt": 1800000000000,
"sentAt": 1800000000000,
"sportUrn": "sportlive:sport:2"
}fixture_removed
Remove the fixture.
{
"fixtureUrn": "sportlive:match:9001",
"producedAt": 1800000000000,
"reason": "ended",
"sentAt": 1800000000000,
"sportUrn": "sportlive:sport:2"
}alive
Refresh the watchdog and check each feed status.
{
"feeds": [
{
"feed": "live",
"status": "up"
},
{
"feed": "prematch",
"status": "up"
}
],
"sentAt": 1800000000000
}bet_settlement
Remove settled price lines; wake the accounting reader.
{
"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.
{
"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.
{
"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.
{
"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.
| Status | Meaning | Next step |
|---|---|---|
429 | requests_per_minute or concurrent_streams limit reached | Close redundant streams; wait for Retry-After seconds |
401 | Key missing, unknown or revoked, or its tenant is inactive | Do not retry; fix or replace the key |
402 | Access paused for payment | Follow payUrl; contact support if absent; resume after access is restored |
403 | suspended or not_entitled | Contact support about access |
409 | A recovery request_id was already accepted in the last 10 minutes (request_id_in_use) | Ask again with a new request_id |
503 | API unavailable, snapshot warming, accounting recovering or too many recovery requests waiting | Wait 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
- Changed: stream ids are opaque resume cursors (
r.<epoch>.<sequence>for binary frames,j.<epoch>.<sequence>.<n>for JSON). Store and return them unchanged. A cursor that no longer fits ends inresetor aresume_gapsnapshot. The client SDK (platform client 0.2) keeps the id as a string. - Removed: customer UDP. Delivery is SSE only, as binary frames or JSON;
GET /session/grantreturnstransports: ["sse_frames", "sse_json"]. - Changed: catalog answers are
private, max-age=0, must-revalidatewithVary: X-API-Keyand oneETagper key; revalidate withIf-None-Match. - Added:
409 request_id_in_useand503withRetry-Afterin the error table; recoverynode_idis at most 128 printable ASCII bytes.
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.
