@pandascore/odds-sdk
v2.2.0
Published
SDK to use Pandascore Betting Feed
Readme
PandaSDK (TypeScript)
PandaSDK is the TypeScript SDK for the PandaScore Sportsbook. It connects to the real-time trading feed over AMQPS, delivers structured odds, fixture, scoreboard and settlement messages, and provides REST helpers for matches, markets and settlements.
Features
- RabbitMQ feed integration - connect to the PandaScore AMQPS feed and receive structured JSON events.
- Resilient reconnection - exponential backoff with jitter, and an automatic snapshot recovery when the outage outlasted your queue's message TTL.
- Startup and restart handling - cold-start snapshot, warm-restart replay, and a persistable checkpoint.
- Feed status signal - a single
FeedStatusyou can gate bet acceptance on. - Typed message callbacks - an optional
FeedListenerwithonMarkets/onFixture/onScoreboard/onSettlements. - Void reasons - a structured
void_reasonon voided markets and selections, plus a REST helper listing all possible reasons. - BetBuilder - price combinations of selections from the same match, including across its games, with derived odds and typed selection errors.
- Betting catalog - leagues, series, tournaments, videogames and market templates, plus outright markets on a serie.
- HTTP clients - fetch matches, markets, booked matches, and settlements.
- Videogame on markets responses (2.1.0+) - recovered and fetched markets carry the videogame their match is played on, and
recoverMatchMarketsreturns a recovery window grouped by match. - Team and player matches (2.1.0+) -
competitorId(result)reads a score on both, including the eBattles videogames where the competitor is a player rather than a team. - Snapshot recovery (2.2.0+) -
requestRecoveryasks us to republish your current book on your own vhost, arriving as ordinary feed messages. - Extensive logging - file + console logging with contextual metadata.
Table of Contents
- Installation
- Configuration
- Quick Start
- Service Startup Guide
- Feed Status
- Connection Behavior
- Typed Listener
- Checkpointing
- Graceful Shutdown
- HTTP API
- Betting catalog endpoints
- BetBuilder
- Settlement Feed
- Void reasons
- Data Models
- Testing
Installation
npm install @pandascore/odds-sdkOr via yarn:
yarn add @pandascore/odds-sdkUpgrading to 2.2.0
There is one thing to check, and it is only for code that reads the reconnection
notification. n.recoveryData no longer carries recovered markets and matches on a
reconnection: that data now arrives on the feed instead, as ordinary markets and fixture
messages through the handler you already have. Every method signature is unchanged, and
startWithSnapshot / startWithRecovery still populate recoveryData as before.
// Before
MySDK.events.on('notification', (n) => {
if (n.type === 'reconnection' && n.complete) {
applyRecovered(n.recoveryData.markets, n.recoveryData.modifiedMatches);
}
});
// Now: the same data arrives through your message handler
MySDK.startLive({
onMarkets: (m) => applyIfNewer(m),
onFixture: (f) => applyIfNewer(f),
onRecoveryCompleted: (m) => resumeBetAcceptance(m.recovery_request_id),
});Apply messages idempotently, keeping the newest at per market. If you already do, there is
nothing to add. A snapshot message carries the market's real last-modification time, so one older
than what you hold is correctly ignored and a duplicate is a no-op.
If your queues are created outside the SDK, bind them to v1.recovery.# or you will not
receive the control messages. The SDK adds the binding to queues it declares itself.
Two behaviour changes worth knowing about, neither requiring an edit:
- A reconnection inside your queue's message TTL (15 minutes by default,
queueMessageTtlMs) requests nothing at all, because the queue already held what you missed. - Live messages are no longer buffered during a reconnection. Delivery resumes immediately.
recoverMarkets and recoverMatchMarkets are deprecated but unchanged, and still work.
Upgrading to 2.1.0
Your integration keeps running exactly as it does today. Nothing changes at runtime: every
method returns the same values, the feed delivers the same messages, and no data you already read
changes shape or meaning. recoverMarkets returns the same flat list it returned in 2.0.0.
What can change is your build. The fixture types now describe what the feed actually sends. ?: T
means "may be absent", which does not admit an explicit null - and this API sends the null: a game
has map: null until a map is picked, and a team can arrive with acronym: null while its opponent
in the same message sends a string. If you compile with strict and read one of these, TypeScript
will now ask you to handle that case:
| Field | Was | Is now | What to do |
|---|---|---|---|
| MatchResult.team_id | number | number \| undefined | Use competitorId(result), which returns team_id or player_id, whichever the match carries |
| Game.map | GameMap | GameMap \| null \| undefined | game.map?.name |
| FixtureMessage.match, .serie | non-nullable | \| null added | message.match?.id |
| FixtureTeam / FixtureWinner / GameMap optional strings | string \| undefined | string \| null \| undefined | team.acronym ?? '' |
| Game.number_of_rounds | number | number \| undefined | game.number_of_rounds ?? 0 |
| Game.rounds_score | RoundScore[] \| null | RoundScore[] \| null \| undefined | game.rounds_score ?? [] |
| Tournament.type | string | string \| null | tournament.type ?? '' |
| Streams[language].embed_url, .raw_url | string | string \| null | Check before rendering the link |
If you do not compile with strict, or you do not read these fields, there is nothing to do -
install the new version and carry on.
Run tsc --noEmit after upgrading to see the full list for your codebase. Every error will be one of
the rows above, and each is a missing guard on a value that could already have arrived null at
runtime before this release - the type simply did not say so.
New in this release and optional: recoverMatchMarkets for a recovery
window grouped by match, and competitorId for reading scores on both team
and player matches.
Upgrading from 1.5.0: delete your existing queue first.
Feed queues are now declared as quorum queues, so a queue survives a broker node failure along with the messages waiting in it. A queue's type is fixed when it is created, so a queue declared under an earlier version has to be removed before the new declaration can take effect. Until it is, connecting reports:
PRECONDITION_FAILED - inequivalent arg 'x-queue-type' for queue '<your-queue>': received 'quorum' but current is 'classic'The credentials you already connect with declare the queue, so they can also remove it:
channel.deleteQueue('<your-queue>')over AMQP, or the delete action in the RabbitMQ management view if you have access to it. The SDK recreates it as a quorum queue on your next connect.Nothing changes in your own code: queue names, routing keys and every callback stay as they are. Drain or accept the loss of anything still queued at the moment you delete it, and prefer
startWithRecoveryon the first connect after the switch so you replay from your last checkpoint. Deleting the queue ends any consumer still attached to it, so do this as part of the deploy rather than ahead of it.A first-time integration is unaffected: there is no existing queue to replace.
Configuration
import { PandaSDK } from '@pandascore/odds-sdk';
const MySDK = PandaSDK.initialize({
apiToken: '<your-api-token>', // your API token
apiBaseURL: '<your-api-base-url>', // ask your integration manager for the base URL
feedHost: '<your-feed-host>', // ask your integration manager for the feed host
company_id: 0, // your PandaScore company ID
email: '<your-email>', // your registered email
password: '<your-password>', // your connection password
queues: [
{ queueName: 'my-queue', routingKey: '#' }, // '#' receives all message types
],
oddsFormat: ['american', 'fractional'], // optional; decimal odds are always included
logging: {
directory: './PandaScore_logs', // optional; omit to disable file logging
},
recoverOnReconnect: true, // optional; default true - see Connection Behavior
heartbeatMonitoring: true, // optional; default true - see Connection Behavior
prefetchCount: 1, // optional; default 1 - unacked messages per consumer
recoveryWindowMs: 2 * 60 * 60 * 1000, // optional; default 2h - see Recovery window
});Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiToken | string | - | REST API authentication token (required) |
| company_id | number | - | Your PandaScore account ID (required) |
| email | string | - | Account email (required to connect to the feed) |
| password | string | - | Account password (required to connect to the feed) |
| queues | array | - | At least one { queueName, routingKey } binding (required to connect to the feed, max 10) |
| apiBaseURL | string | https://api.pandascore.co/betting/matches | REST API base URL |
| bettingBaseURL | string | - | Root for the betting catalog and BetBuilder endpoints (leagues/series/tournaments/esports/betbuilder). Optional; derived from apiBaseURL by stripping a trailing /matches when unset |
| feedHost | string | - | AMQPS feed hostname (required to connect to the feed) |
| oddsFormat | ('american'\|'fractional')[] | [] | Extra odds formats to compute; decimal is always present |
| recoverOnReconnect | boolean | true | Auto-recover markets and matches on reconnect |
| heartbeatMonitoring | boolean | true | Detect silent drops via heartbeats; set false for a specific routing key |
| prefetchCount | number | 1 | RabbitMQ QoS prefetch per consumer |
| recoveryWindowMs | number | 7200000 | Max startup-recovery lookback for startWithRecovery |
| logging.directory | string | ./logs | File-log directory; omit to disable |
| customLogger | object | - | Inject your own logger (error/warn/info/debug/logApiResponse) |
Computed odds fields
Decimal odds always come straight from the feed. Listing a format in oddsFormat makes the SDK
add that format to every selection, computed from the decimal values - two fields per format, one
from odds_decimal and one from odds_decimal_with_overround:
| oddsFormat entry | Fields added to each selection |
|--------------------|-------------------------------|
| 'american' | odds_american, odds_american_with_overround |
| 'fractional' | odds_fractional, odds_fractional_with_overround |
// oddsFormat: ['american', 'fractional']
selection.odds_decimal; // 2.5 from the feed
selection.odds_decimal_with_overround; // 2.35 from the feed
selection.odds_american; // 150
selection.odds_american_with_overround; // 135
selection.odds_fractional; // "3/2"
selection.odds_fractional_with_overround; // "27/20"Use the _with_overround pair for what you offer players; the plain pair is the true price before
your overround. A field is null when its source decimal is null, which is what an unpriceable
selection sends - an eliminated outright participant, or the "any-other" catch-all on an outright
market. Leave oddsFormat unset to skip the computation entirely.
The same fields are computed on BetBuilder responses, on both the combination and each selection - see BetBuilder.
Quick Start
const MySDK = PandaSDK.initialize({ /* ...config... */ });
// React to connection lifecycle (disconnection / reconnection / snapshot) and recovery data.
MySDK.events.on('notification', (n) => {
if (n.type === 'disconnection') {
// Feed is down - suspend your markets.
} else if (n.type === 'reconnection') {
if (!n.recoveryRequest) {
// Inside the queue TTL. The queue redelivered everything you missed; resume.
} else if (n.complete) {
// A recovery is in flight. Its messages arrive on your handler, framed by
// onRecoveryStarted / onRecoveryCompleted. Resume on the completed message.
} else {
// The request could not be placed - the window from n.since is still outstanding.
}
}
});
// Gate business actions on feed health. This form takes one change object; the
// FeedListener callback of the same name takes three arguments instead.
MySDK.onFeedStatusChanged(({ previous, current, reason }) => {
console.log(`feed status: ${previous} -> ${current} (${reason})`);
});
// Start consuming. On a fresh process, prefer startWithSnapshot (see Service Startup Guide).
await MySDK.startWithSnapshot((msg) => {
// msg.type is "markets" | "fixture" | "scoreboard" | "settlements" | ...
});Service Startup Guide
The right entry point depends on whether your process restarted or only the feed connection dropped.
| Situation | In-memory state | What to do |
|---|---|---|
| Feed dropped, process still running | Intact | Nothing - the SDK reconnects and recovers automatically |
| First launch | Empty | startWithSnapshot() to load all booked matches, then go live |
| Restart after downtime, with a saved checkpoint | Empty | startWithRecovery(since) to replay the gap, then go live |
| You already hold match state from elsewhere | Provided | startLive() - deltas only, no snapshot or replay |
The three start methods
| Method | Use when |
|--------|----------|
| startWithSnapshot(handler) | Cold start, no checkpoint. Fetches all booked matches before going live. |
| startWithRecovery(since, handler) | Warm restart with a persisted checkpoint. Replays the gap, then goes live. |
| startLive(handler) | Advanced. You already have match state. No snapshot, no recovery. (getRMQFeed is an alias.) |
All three accept either a raw (msg) => void callback or a typed FeedListener.
Live messages that arrive while the snapshot or replay is being fetched are buffered and
delivered in order afterwards, so the bootstrap data is never overwritten by newer updates.
This applies to these three start methods only. A mid-run reconnection buffers nothing: see
Reconnection and recovery.
Cold start
// startWithSnapshot delivers a one-off `snapshot` notification, then streams live.
MySDK.events.on('notification', (n) => {
if (n.type === 'snapshot') {
if (n.complete) {
const booked = n.recoveryData.bookedMatches; // rebuild your match state from these
}
}
});
await MySDK.startWithSnapshot((msg) => {
// live updates, applied on top of the snapshot
});You can also call fetchBookedMatches() directly if you want to build state before connecting:
const booked = await MySDK.fetchBookedMatches();Warm restart
const since = loadCheckpoint(); // the ISO timestamp you persisted via onCheckpoint
try {
await MySDK.startWithRecovery(since, (msg) => {
// live updates
});
} catch (err) {
// The checkpoint is older than recoveryWindowMs (STRICT mode, the default).
// Replay what we can and accept a partial picture:
const { RecoveryWindowExceededError } = await import('@pandascore/odds-sdk');
if (err instanceof RecoveryWindowExceededError) {
await MySDK.startWithRecovery(since, (msg) => { /* ... */ }, undefined, 'ALLOW_PARTIAL');
} else {
throw err;
}
}Feed Status
FeedStatus is a single signal you can gate downstream actions (such as accepting bets)
on. Subscribe with onFeedStatusChanged, or read the current value with getFeedStatus().
| Status | Accept bets? | Meaning |
|--------|--------------|---------|
| HEALTHY | yes | Live, ordered, up to date. |
| DELAYED | yes (usually) | A beat is more than 25s overdue. Usually network jitter or a skipped publish; data is still valid. Treat as a warning, not a stop. |
| RECOVERING | no | A startup snapshot or replay is in progress and live messages are buffered, or a feed recovery is being published. A feed recovery does not buffer: see Reconnection and recovery. |
| PARTIAL_RECOVERY | no | Known-incomplete state. Sticky until you reconcile and call clearPartialRecovery(reason). |
| STALE | no | Heartbeats gone; the connection may still be up. Treat data as untrusted. |
| DISCONNECTED | no | AMQP is down; the SDK is reconnecting. |
let bettingEnabled = false;
MySDK.onFeedStatusChanged(({ current }) => {
bettingEnabled = current === 'HEALTHY' || current === 'DELAYED';
});Two shapes, one name.
MySDK.onFeedStatusChangedreceives a single{ previous, current, reason }object.FeedListener.onFeedStatusChangedreceives three arguments,(previous, current, reason). Taking the wrong one leaves the status undefined, which silently pins a bet-acceptance flag tofalserather than throwing.
PARTIAL_RECOVERY is reached when a startup replay was clamped to the recovery window, or
when automatic recovery failed after all retries. It does not clear on its own:
// After you have reconciled the missing window out of band:
MySDK.clearPartialRecovery('reconciled via fetchBookedMatches');Connection Behavior
Heartbeat monitoring
PandaScore sends a heartbeat roughly every 10 seconds on the feed exchange. The watch checks every
10 seconds and counts a miss when the last beat is more than 25 seconds old (the 10s interval
plus a 15s grace). One miss flags DELAYED; three consecutive misses mark the feed STALE and
emit a disconnection notification, roughly 50 seconds after the last beat.
The grace is deliberately wider than one interval so a single skipped beat does not flag a healthy feed. Publishers skip the occasional beat, and a feed that misses one while still delivering messages is not degraded. Two consecutive skips still register.
Heartbeats are a liveness signal, not just a keepalive. If beats stop while messages are still arriving, do not read the continuing traffic as evidence the feed is fine.
STALEmeans the feed's own health signal has gone, whatever else is on the wire, and it is the state to gate bet acceptance on.
This only works if heartbeats reach your queue. Heartbeats only match the catch-all
binding #. If you bind to a specific routing key (for example, settlements only),
heartbeats never arrive and the SDK would emit false disconnection warnings. Set
heartbeatMonitoring: false in that case:
PandaSDK.initialize({
// ...
heartbeatMonitoring: false,
queues: [{ queueName: 'settlements', routingKey: 'v1.*.match.*.settlements.updated' }],
});AMQP-level drops (socket error, channel close) are detected immediately and emit a
disconnection regardless of heartbeatMonitoring - the flag only controls the
heartbeat-based timeout.
Reconnection and recovery
When a disconnection is detected the SDK reconnects automatically using exponential
backoff with jitter (min(attempt * 5s, 60s) plus random jitter), so a fleet of clients
does not reconnect in lockstep after an outage.
Once the feed is flowing again, and if recoverOnReconnect is true, the SDK compares the
outage against queueMessageTtlMs (default 15 minutes) and does one of three things:
| Outage | What happens |
|---|---|
| Within the TTL | Nothing. Your queue held the messages and delivers them on reconnect |
| Past the TTL, within 24 hours | A windowed recovery from the last message timestamp the SDK saw |
| Past 24 hours | A bootstrap recovery with include_recently_settled |
The window starts at the last message timestamp the SDK saw, not at the moment the outage was noticed. Heartbeats carry a timestamp and arrive every 10 seconds, so that value tracks the feed closely and no safety margin is needed.
Whatever you asked for arrives on the feed, as ordinary fixture and markets messages on
their usual routing keys, framed by two control messages. Live delivery is not held back while it
runs: every snapshot message carries its market's real last-modification time, so keeping the
newest at per market applies exactly what you missed.
MySDK.events.on('notification', async (n) => {
if (n.type !== 'reconnection') return;
if (!n.recoveryRequest) {
// Inside the queue TTL. Nothing was lost.
resumeMarketOperations();
} else if (n.complete) {
// A recovery is on its way; onRecoveryCompleted tells you when it has all arrived.
console.log(`recovery ${n.recoveryRequestId} requested`);
} else {
// The request itself could not be placed. The window from n.since is still outstanding.
await MySDK.requestRecovery({ mode: 'windowed', since: n.since });
}
});Apply the snapshot with your normal handler, keeping the newest at per market:
MySDK.startLive({
onMarkets: (m) => applyIfNewer(m), // live and snapshot both land here
onFixture: (f) => applyIfNewer(f),
onRecoveryStarted: (m) => suspendBetAcceptance(m.recovery_request_id),
onRecoveryCompleted: (m) => resumeBetAcceptance(m.recovery_request_id),
});Your queue must receive
v1.recovery.#. The SDK adds that binding to queues it declares. If your queues are created outside the SDK and bound to specific routing keys, add it yourself, with the trailing#: a topic exchange matches word by word, so a barev1.recoverymatches nothing.
Set recoverOnReconnect: false to skip all of this (the reconnect itself still happens).
Running more than one consumer
A recovery covers your company, not a queue, and we publish the snapshot to your whole vhost.
Every feed bound to receive it gets the started messages, the snapshot and the completed,
whichever of your consumers asked.
So enable the request on exactly one of them. recoverOnReconnect is resolved per feed, falling
back to the global value when you leave it unset:
// The consumer that asks
const primary = PandaSDK.initialize({ ...config, recoverOnReconnect: true });
// The others: they receive the same snapshot without asking
const secondary = PandaSDK.initialize({ ...config, recoverOnReconnect: false, queues: otherQueues });A second request while one is running is refused with 429. That is handled rather than fatal,
since the winner's snapshot republishes the same book, but it is noise worth avoiding.
What a rate-limited request means
RecoveryRequestError.isRateLimited covers two cases, and the SDK tells them apart:
- Another of your consumers is already recovering. Its snapshot republishes this feed's book
too, so nothing is owed here. The feed stays on course for
HEALTHYand waits for thecompletedcontrol message. - You asked less than five minutes ago (fifteen for a bootstrap) and nothing is running.
The window really is outstanding, so the feed goes
PARTIAL_RECOVERYand the reconnection notification reportscomplete: false.
If you call requestRecovery yourself and catch a rate-limited error, wait for the running
recovery's completed message rather than retrying.
Settlement Feed data
A recovery republishes fixture and markets messages, so a market's settled state comes back
with its results. The Settlement Feed is a separate feed and is not republished.
If you consume it and need to catch up after a recovery, call sdk.fetchSettlements(matchId) for
the matches you care about. A recovery names every match it touches with a fixture message before
its markets, so by the time the completed control message arrives you already have the list. Each
response is the full current state for that match, so one call per match is enough.
recoverSettlements still applies to startWithSnapshot and startWithRecovery, which have the
match list in hand. It is not used on a reconnection.
Requesting a recovery yourself
// Everything that changed since a timestamp, up to 24 hours back
const { request_id } = await MySDK.requestRecovery({ mode: 'windowed', since: lastSeenAt });
// Your whole current book, for a first start or after a long outage
await MySDK.requestRecovery({ mode: 'bootstrap', include_recently_settled: true });One recovery runs at a time per company, with five minutes between requests and fifteen between
bootstraps. A request refused for that reason throws RecoveryRequestError with
isRateLimited === true; wait for the running recovery's completed message rather than retrying.
recoverMarkets and recoverMatchMarkets (deprecated)
Both still work and return what they always returned. They cover markets only, reach back at most
five hours, and hand you a shape that is not a feed message, so requestRecovery is the better
choice for new integrations. The automatic reconnect path no longer calls them.
Example log output
11:20:07 [WARN] Disconnection detected at 2026-01-20T11:20:07Z.
(notification: disconnection - suspend markets)
11:20:12 [WARN] Reconnecting in 7s (attempt #1)
11:20:58 [INFO] Reconnection successful at 2026-01-20T11:20:58Z.
11:20:58 [INFO] Requested a windowed recovery (request d82018a1-...).
11:20:58 [INFO] Feed status: STALE -> RECOVERING (recovery d82018a1-... requested)
11:21:02 [INFO] Recovery d82018a1-... started
11:21:09 [INFO] Feed status: RECOVERING -> HEALTHY (recovery d82018a1-... completed)Recovery window
recoveryWindowMs (default 2 hours) bounds how far back startWithRecovery will replay.
The recover_markets endpoint returns the whole window in a single response, so the
payload grows with your booked-match count.
| Account profile | Recommended recoveryWindowMs |
|-----------------|--------------------------------|
| Does not book eBattles | 7200000 (2 hours, default) |
| Books eBattles | a few minutes, e.g. 5 * 60 * 1000 |
A since older than the window either throws RecoveryWindowExceededError (default,
'STRICT') or clamps to the window edge and marks the feed PARTIAL_RECOVERY
('ALLOW_PARTIAL'). See Warm restart.
Configuration matrix
| Routing key | heartbeatMonitoring | recoverOnReconnect | Behavior |
|---|---|---|---|
| # | true (default) | true (default) | Full disconnection detection + automatic recovery. Recommended for most integrations. |
| # | true (default) | false | Disconnection detection and automatic reconnect, but no recovery request and no v1.recovery.# binding. Use if you handle recovery yourself. |
| Specific key | false | false | No heartbeat detection, no recovery request. AMQP-level drops still reconnect. Use when handling everything in your app. |
| Specific key | true (default) | any | Avoid - heartbeats never arrive and the SDK emits false disconnection warnings. |
Typed Listener
Instead of a raw callback you can pass a FeedListener and implement only the methods you
care about. Pass it to any start method.
import { PandaSDK, FeedListener } from '@pandascore/odds-sdk';
const listener: FeedListener = {
onMarkets(msg) { /* msg: MarketsMessage */ },
onFixture(msg) { /* msg: FixtureMessage */ },
onScoreboard(msg) { /* msg: ScoreboardMessage - see Scoreboards below */ },
onSettlements(msg) { /* msg: SettlementMessage */ },
onUnknown(raw) { /* unrecognized type */ },
// Wired automatically when the listener is passed to a start method:
onCheckpoint(ts) { saveCheckpoint(ts); },
onFeedStatusChanged(prev, next, reason) { /* gate bets */ },
};
await MySDK.startWithSnapshot(listener);Messages arrive already parsed, and markets are already enriched with the configured odds formats, so handlers receive ready-to-use objects.
Identifying what a message is about: read event_type and event_id together. That pair is on
every message and is what the routing key carries. event_type is match, game or serie, and
event_id is the id of that thing.
match_id is a convenience for pointing back at the parent match and is only present where it adds
something, which is why it is typed match_id?: number | null. On a game-scoped markets message it
gives you the parent match; on a match-scoped one it is usually absent, because event_id is already
the match id. Keying purely on match_id therefore drops match-scoped updates, which are a
substantial share of the markets traffic. Key on event_type plus event_id, and read match_id
when you specifically want the parent of a game.
A recovery arrives on the listener. Since 2.2.0 the snapshot published after a disconnection comes through
onMarketsandonFixturelike any live message, so aFeedListeneron its own is enough to stay in sync.onRecoveryStartedandonRecoveryCompletedframe it, and a snapshot message carriesrecovery_request_idif you want to tell it apart. Thereconnectionnotification still reports what the SDK decided the outage cost -recoveryRequestisnullwhen your queue held everything and nothing was requested - so wireevents.on('notification')as well if you act on that decision. Seeexamples/feed_listener_with_recovery.tsfor the full pattern.
Scoreboards
onScoreboard gives you a ScoreboardMessage. A scoreboard's shape depends on the videogame, so switch on videogame_slug and narrow with the matching helper:
import { asCs, asDota2, asLol, scoreboardType } from '@pandascore/odds-sdk';
const listener: FeedListener = {
onScoreboard(message) {
switch (message.videogame_slug) {
case 'cs-go': {
const sb = asCs(message);
sb?.games.forEach(g => g.teams.forEach(t => render(t.id, t.round_score)));
break;
}
case 'league-of-legends': {
const sb = asLol(message);
sb?.games.forEach(g => render(g.id, g.timer));
break;
}
default:
// Nothing is unreachable: the payload is on message.scoreboard
console.debug('No typed shape for', message.videogame_slug);
}
},
};| videogame_slug | Helper |
|---|---|
| cs-go | asCs → ScoreboardCs |
| league-of-legends | asLol → ScoreboardLol |
| dota-2 | asDota2 → ScoreboardDota2 |
| valorant | asValorant → ScoreboardValorant |
| e-soccer | asEsoccer → ScoreboardEsoccer |
| e-basketball | asEbasketball → ScoreboardEbasketball |
| e-hockey | asEhockey → ScoreboardEhockey |
| e-tennis | asEtennis → ScoreboardEtennis |
Each helper returns null if the message is for a different videogame or carries no scoreboard, so a mismatched call gives you null rather than a wrongly-typed object.
scoreboard_type is not the videogame. It reports how the data is produced, and you read it with scoreboardType(message):
| Value | Meaning |
|---|---|
| realtime | Continuously updated as the game progresses. Every videogame except Dota 2. |
| snapshot | Values are captured periodically and hold until the next capture. Dota 2 only. |
Counters are nullable. A score, kill count, tower count or gold lead the API has not resolved yet is null, which is not the same as 0. This matters most for Dota2Game.radiant_gold_lead, where 0 means the sides are level. On a game that has not started the API may send null for one counter and 0 for another in the same object.
Two per-videogame notes: eTennis groups by sets, not games, and carries current_game per set. Dota 2 is snapshot-based, so age its timer against snapshotted_at - the Dota 2 timer carries no timestamp of its own:
const game = asDota2(message)?.games[0];
if (game?.snapshotted_at != null && game.timer != null) {
const staleSeconds = (Date.now() - Date.parse(game.snapshotted_at)) / 1000;
render(game.timer + staleSeconds);
}Checkpointing
To support warm restarts, persist the latest server timestamp and pass it to
startWithRecovery next time.
// Option A: callback on every message
MySDK.onCheckpoint((ts) => saveCheckpoint(ts)); // ts is an ISO-8601 string
// Option B: read on demand
const latest = MySDK.getLastMessageTimestamp(); // string | nullGraceful Shutdown
await MySDK.close(); // stops heartbeat monitoring, cancels reconnects, closes the connectionAfter close() the feed will not reconnect or emit further disconnection events.
HTTP API
const match = await MySDK.fetchMatch('979621');
const markets = await MySDK.fetchMarkets('979621'); // flat list of markets
const matchMarkets = await MySDK.fetchMatchMarkets('979621'); // videogame + match-level markets + games with position
const range = await MySDK.fetchMatchesRange(fromISO, toISO);
const booked = await MySDK.fetchBookedMatches(); // active statuses, paginated
const recovered = await MySDK.recoverMarkets(sinceISO); // match-level + game-level markets, flat
const byMatch = await MySDK.recoverMatchMarkets(sinceISO); // the same window, grouped by match
const settle = await MySDK.fetchSettlements(979621);
const voidReasons = await MySDK.fetchVoidReasons(); // dictionary of code + label
const outrights = await MySDK.getSerieMarkets('10846'); // outrights on a serie (see catalog)
// BetBuilder: price a combination of selections from the same match (see BetBuilder)
const betBuilder = await MySDK.updateBetWithSelections('979621', [
{ market_id: '4-140633-1-bb837bc8-4c0a-bfc1-c9945e3ea545', selection_position: 0 },
]);Publishing RTBL bets
Optional - only needed if you are subscribed to the Real-Time Bet Log package.
await MySDK.connectToRabbitMQ();
await MySDK.createChannel();
const betData = {
event_type: 'bet_placed',
bet: {
id: 'id-of-the-bet',
type: 'single',
user_id: 'user-id',
cash_amount: 100,
currency: 'USD',
placed_at: new Date().toISOString(),
selections: [
{ provider: 'PandaScore', provider_market_id: 'market-id', provider_selection_position: 1, decimal_odds: 1.5 },
],
},
};
MySDK.publishBet(
betData,
(error) => console.error('Error:', error.message),
(data) => console.log('Success:', data),
);Betting catalog endpoints
Read-only REST access to the betting catalog: leagues, series, tournaments, videogames (esports)
and market templates. These sit alongside /betting/matches on the API. The SDK resolves the catalog
root automatically by stripping a trailing /matches from apiBaseURL, so an existing
config keeps working with no change. To point the catalog somewhere else, set the optional
bettingBaseURL.
// Leagues
const leagues = await MySDK.listLeagues({ filter: { videogame: 'cs-go' }, sort: 'name', page: { size: 20, number: 1 } });
const league = await MySDK.getLeague('lec'); // by numeric id or slug
// Series
const series = await MySDK.listSeries({ filter: { videogame: 'league-of-legends' } });
const serie = await MySDK.getSerie('lec-2025-summer'); // by numeric id or slug
const found = await MySDK.retrieveSerie('LEC', '2025-06-01T12:00:00Z', 'league-of-legends');
const outrights = await MySDK.getSerieMarkets('10846'); // outright markets on the serie
// Tournaments
const tours = await MySDK.listTournaments({ sort: 'begin_at' });
const tour = await MySDK.getTournament('lec-2025-summer-playoffs'); // by numeric id or slug
const foundT = await MySDK.retrieveTournament('Playoffs', '2025-06-01T12:00:00Z', 'league-of-legends');
// Videogames (esports)
const csgo = await MySDK.getVideogame('cs-go'); // by numeric id or slug
// Market templates
const templates = await MySDK.listMarketTemplates();
const csTemplates = await MySDK.listMarketTemplates({ filter: { videogame_slug: 'cs-go' } });Market templates
listMarketTemplates() returns every market template with its display name and where it is
offered. template is the same value a market carries on the feed, so this is how you resolve a
template slug to something you can show, rather than hard-coding a table:
const nameByTemplate = new Map((await MySDK.listMarketTemplates()).map((t) => [t.template, t.name]));
// later, on a market off the feed
const label = nameByTemplate.get(market.template) ?? market.template;Each availabilities entry pairs an event_type with a videogame_slug, so a template offered on
several videogames has one entry per videogame. The response is a flat list with no paging.
Filters: template, videogame_slug.
event_type is kept open, so a value added server-side arrives as a plain string rather than
breaking your build. Enumerate what this version knows with MARKET_TEMPLATE_EVENT_TYPES, and
narrow with isKnownMarketTemplateEventType when you want an exhaustive switch:
import { isKnownMarketTemplateEventType } from '@pandascore/odds-sdk';
for (const a of template.availabilities) {
if (!isKnownMarketTemplateEventType(a.event_type)) {
logger.info(`New event type: ${a.event_type}`);
continue;
}
switch (a.event_type) {
case 'match': offerOnMatch(template, a.videogame_slug); break;
case 'game': offerOnGame(template, a.videogame_slug); break;
case 'serie': offerOnSerie(template, a.videogame_slug); break;
}
}| Method | Endpoint | Returns |
|--------|----------|---------|
| listLeagues(query?) | GET {root}/leagues | League[] |
| getLeague(idOrSlug) | GET {root}/leagues/{id_or_slug} | League |
| listSeries(query?) | GET {root}/series | FixtureSerie[] |
| getSerie(idOrSlug) | GET {root}/series/{id_or_slug} | FixtureSerie |
| retrieveSerie(name, scheduledAt, videogameSlug) | GET {root}/series/retrieve | FixtureSerie |
| getSerieMarkets(idOrSlug) | GET {root}/series/{id_or_slug}/markets | EnrichedMarket[] |
| listMarketTemplates(query?) | GET {root}/market_templates | MarketTemplate[] |
| listTournaments(query?) | GET {root}/tournaments | Tournament[] |
| getTournament(idOrSlug) | GET {root}/tournaments/{id_or_slug} | Tournament |
| retrieveTournament(name, scheduledAt, videogameSlug) | GET {root}/tournaments/retrieve | Tournament |
| getVideogame(idOrSlug) | GET {root}/esports/{id_or_slug} | Videogame |
{root} is the derived /betting root. The list methods take an optional CatalogQuery
(filter, range, sort, page) and return a single page - pass page to walk through
results. Omit the query for the first page at the server default page size.
Serie shapes
A serie arrives in two shapes and the difference matters:
| Shape | Where it comes from | Extra fields |
|-------|--------------------|--------------|
| Full | FixtureMessage.serie on event_type: "serie" events, and every /betting/series method above | opponents, betting_metadata, videogame, league_name, league_image_url, widget_supported |
| Reduced | FixtureMessage.match.serie, fetchMatch, fetchMatchesRange | none of them |
So if you need a serie's participants or its trading metadata, read a serie fixture event or call
getSerie - they are not on the serie nested under a match. videogame is not the same field as
videogame_title: a serie can run on Counter-Strike (cs-go) while its title is Counter-Strike 2
(cs-2).
Outright markets
getSerieMarkets returns the outright markets on a serie - priced on the serie as a whole rather
than on a single match, which is why they are not reachable through fetchMarkets. A serie carries
one market with template: "outright-winner", whose selections are one template: "participant"
entry per competitor, lining up with the serie's own opponents, plus an optional "any-other"
catch-all. Selections are enriched with the odds formats in oddsFormat, same as match markets.
A serie has no per-game grouping, so the result is a flat market list - there is no grouped
counterpart to fetchMatchMarkets. Pass the event_id of a serie fixture event straight through.
EnrichedMarket is the market as the feed sends it with the computed odds fields added, so
name, template, participant_id, position and the rest are fully typed and need no cast:
MySDK.startLive({
onFixture: async (msg) => {
if (msg.event_type !== 'serie') return;
const outrights = await MySDK.getSerieMarkets(msg.event_id);
// A selection that cannot be priced - an eliminated participant, or "any-other" - carries
// null odds rather than a fabricated price.
for (const market of outrights) {
for (const sel of market.selections) {
if (sel.odds_decimal_with_overround !== null) render(sel.name, sel.odds_decimal_with_overround);
}
}
},
});BetBuilder
BetBuilder prices a combination of market selections from the same match. The API is stateless, so every recalculation sends the whole current combination and returns both the combined price and the recomputed probability of every other selection.
BetBuilder is REST only. It needs no feed credentials, so an apiToken and a company_id are
enough to configure the SDK for it.
import { PandaSDK, isValid, isCombinable, marketsByGame, matchMarkets } from '@pandascore/odds-sdk';
const MySDK = PandaSDK.initialize({ apiToken: 'YOUR_TOKEN', company_id: 123 });
// 1. Bet slip opens: everything available on this match, at normal prices
const all = await MySDK.fetchAllMarkets('999600');
// 2. User picks two selections: price the combination
const priced = await MySDK.updateBetWithSelections('999600', [
{ market_id: '4-140633-1-bb837bc8-4c0a-bfc1-c9945e3ea545', selection_position: 0 },
{ market_id: '4-140633-1-80692552-4e63-b30e-6782b13785e8', selection_position: 0 },
]);
if (isValid(priced)) {
const odds = priced.odds_decimal_with_overround; // the combined price
} else {
// The combination was rejected. The markets are still populated and the probabilities
// are zeroed, so the bet slip can stay on screen.
for (const error of priced.market_selections_errors ?? []) {
error.error; // e.g. 'market_selection_suspended'
error.market_id;
error.selection_position;
}
}| Method | Endpoint | Returns |
|--------|----------|---------|
| fetchAllMarkets(matchId, filterMarkets?) | POST {root}/betbuilder/matches/{matchId} | BetBuilderResponse |
| updateBetWithSelections(matchId, selections, filterMarkets?) | same | BetBuilderResponse |
{root} is the derived /betting root, the same one the catalog endpoints use.
fetchAllMarkets is updateBetWithSelections with an empty combination. filterMarkets is
described under Rendering a bet slip below.
Reading the response
market_selections is the combination the server actually priced, which can differ from what you
sent when a leg had to be dropped. Use it rather than your own local state.
A selection with probability of 0 cannot be added to the current combination. Test it with
isCombinable(selection).
isValid(response) tells you whether the combination was accepted. When it is false,
market_selections_errors names the legs to remove. This does not reject, because the response
still contains everything needed to keep a bet slip rendered.
Rendering a bet slip
The one rule: never render a selection as selectable when isCombinable is false. Follow it and
the whole class of invalid-combination errors becomes unreachable, because the user can never click
something the server would reject.
Each call tells you what is still available. As the user adds legs, selections that can no longer be
combined come back with a probability of 0, and their derived odds are null. Show those greyed out
and unclickable.
import { isCombinable } from '@pandascore/odds-sdk';
for (const market of response.markets) {
for (const selection of market.selections) {
if (isCombinable(selection)) {
render(selection.name, selection.odds_decimal_with_overround);
} else {
renderDisabled(selection.name); // no price: there isn't one
}
}
}Odds are null exactly when a selection is not combinable, so a disabled selection never needs a
price. Do not substitute 0 or 1.0 for the null, or it will end up multiplied into a payout.
Do not use filterMarkets: true for a bet slip. It removes uncombinable selections from the
payload entirely, so once the user has picked a leg whole markets and games silently vanish from your
UI and they cannot see they existed. Keep them and grey them out instead.
filterMarkets: true is for callers that only want the combinable set and have no UI to grey out,
such as a backend generating suggested combinations.
Recovering from an invalid combination
If an invalid combination does reach the API, every selection in the response comes back with a probability of 0, so the market list offers no way out. Make sure users can remove a leg from the bet slip itself rather than relying on clicking markets.
Combinations across games
Markets from several games of the same match arrive in one flat list, each tagged with the event it belongs to. Two helpers split it:
// One group per game, for rendering per-game tabs
const byGame: Map<number, BetBuilderMarket[]> = marketsByGame(priced);
// Markets attached to the match as a whole
const matchLevel = matchMarkets(priced);Derived odds
The API returns probabilities only. The SDK derives decimal odds as 1 / probability and exposes
them on both the response and each selection. Add 'american' or 'fractional' to oddsFormat to
get those forms too, exactly as for feed markets.
Errors
A rejected request throws BetBuilderApiError, carrying status, type, errorCode and
rawBody, so you can tell a match that is not booked from one whose markets have not been created.
import { BetBuilderApiError } from '@pandascore/odds-sdk';
try {
await MySDK.fetchAllMarkets(matchId);
} catch (err) {
if (err instanceof BetBuilderApiError && err.errorCode === 'not_booked') {
// book the match first
}
}Which videogames and markets are supported, how combinations across games are limited, and what each error code means are all covered in the BetBuilder integration documentation.
Keeping a combination up to date
Prices move. When your feed listener reports a change on a market in the user's combination, call
updateBetWithSelections again with the same combination to get the current price.
Settlement Feed
Note: This feed is not a replacement for market settlements. Use it when PandaScore market settlements are not in use, or when you manage your own settlements and need the raw outcome data. Coverage: CS2, Dota 2, League of Legends.
REST snapshot
import { SettlementMessage } from '@pandascore/odds-sdk';
const settlements: SettlementMessage = await MySDK.fetchSettlements(979621);
console.log(settlements.videogame_slug); // "cs-go" | "dota-2" | "league-of-legends"
console.log(settlements.games); // per-game settlement dataThe endpoint returns 404 if the match is not in an emitting state (not_started,
postponed, canceled).
Streaming
Settlement updates carry type: "settlements" and arrive through the same handler as
other feed messages (or onSettlements on a typed listener).
await MySDK.startLive((msg) => {
if (msg.type === 'settlements') {
for (const game of msg.games) {
console.log(`Game ${game.position}: ${game.status}`);
}
}
});To receive only settlements, bind your queue with v1.*.match.*.settlements.updated
(or v1.cs-go.match.*.settlements.updated for a specific videogame) and set
heartbeatMonitoring: false - see the Configuration matrix.
Settlement status values
| Status | Meaning |
|--------|---------|
| pending | Not yet resolved. Value is null. Expect a future update. |
| settled | Resolved. Value is populated. |
| voided | Resolved as void (the underlying event did not happen). Value is null. |
| unavailable | The game is finished and this outcome will never resolve. Settle per your void policy. |
Per-videogame settlement types
import type { Cs2Settlements, Dota2Settlements, LolSettlements } from '@pandascore/odds-sdk';- CS2 (
Cs2Settlements):winner,winner_first_half,rounds_won,first_to_n_rounds,round_winners,participants_winning_n_rounds,player_kills,player_headshots - Dota 2 (
Dota2Settlements):winner,duration_seconds,first_kill,first_tower_destroyed,first_roshan_killed,team_kills,team_towers_destroyed,team_barracks_destroyed,team_roshans_killed,player_kills,kill_snapshots_at_ingame_timer_seconds - League of Legends (
LolSettlements):winner,first_kill,first_tower_destroyed,first_inhibitor_destroyed,team_kills,team_towers_destroyed,team_inhibitors_destroyed,team_nashors_killed,team_drakes_killed,player_kills,player_assists
Recommended integration pattern
Subscribe to the streaming feed for live updates and call fetchSettlements on reconnect
to backfill missed messages. The REST snapshot always reflects the full current state, so
a missed streaming message never causes permanent inconsistency.
Void reasons
When a market is voided, it carries a structured void_reason explaining why. It appears on
the markets feed messages and on the Odds API market responses (so fetchMarkets returns it
too). Reasons are on markets only - the settlement feed never carries them.
import type { MarketsMessage } from '@pandascore/odds-sdk';
const listener = {
onMarkets(msg: MarketsMessage) {
for (const market of msg.markets) {
if (market.void_reason) {
// whole market voided - key logic on the code, not the label
market.void_reason.code; // e.g. "game-forfeited"
market.void_reason.label; // "Game forfeited"
market.void_reason.incident; // { category, description } | null
}
// markets with independent outcomes (e.g. player-kill-to-get-n) can void a
// single selection while the others resolve:
for (const sel of market.selections) {
if (sel.void_reason) {
// this selection was voided on its own
}
}
}
},
};void_reason is null when a market/selection is not voided, was voided before this release
(no backfill), or voided without a known reason. When only some selections are voided the
market-level void_reason stays null and each voided selection carries its own.
Void reason codes
VoidReasonCode is an open union - unknown codes added server-side still type-check, so key
your logic on the code and fall back to the label for anything unrecognised. Fetch the live
dictionary with MySDK.fetchVoidReasons().
| Code | Label |
|------|-------|
| game-not-played | Game not played |
| game-forfeited | Game forfeited |
| match-forfeited | Match forfeited |
| match-canceled | Match canceled |
| change-of-opponents | Change of opponents |
| match-format-changed | Match format changed |
| team-withdrew-from-tournament | Team withdrew from tournament |
| tournament-canceled | Tournament canceled |
| wrong-odds | Wrong odds |
| objective-not-taken | Objective not taken |
| player-did-not-play | Player did not play |
| two-way-tie | Two-way tie |
| early-gg | Early GG |
| interval-not-started-or-finished | Interval not started or finished |
| other | Other |
Data Models
All message and entity types are exported from the package and available as TypeScript types:
- Markets:
MarketsMessage,MarketsMessageMarket,MarketsMessageSelection - Fixtures:
FixtureMessage, and the nested league, tournament, videogame, game, player and team types - Scoreboards:
ScoreboardCs,ScoreboardDota2,ScoreboardLol,ScoreboardValorant,ScoreboardEsoccer,ScoreboardEbasketball,ScoreboardEhockey,ScoreboardEtennis - Settlements:
SettlementMessage,SettlementGame,Cs2Settlements,Dota2Settlements,LolSettlements, and the per-entry types - Void reasons:
VoidReason,VoidReasonIncident,VoidReasonCode,VoidReasonDefinition - BetBuilder:
BetBuilderResponse,BetBuilderMarket,BetBuilderSelection,MarketSelection,MarketSelectionError,MarketSelectionErrorCode,BetBuilderEventType,BetBuilderMarketStatus - SDK:
FeedStatus,StatusChange,FeedListener,RecoveryMode,RecoveryWindowExceededError,BetBuilderApiError
Fixture actions
FixtureMessage.action says what happened to the fixture:
| Action | Meaning |
|--------|---------|
| created | Fixture appeared in the schedule |
| booked / unbooked | Added to or removed from your booked offer |
| reviewed | Checked by a PandaScore trader |
| updated | Any other change to the fixture |
| rescheduled / postponed | Start time moved, or pushed with no new time |
| started / finished | Play began, or the last game ended |
| settled | All markets resolved |
| coverage_changed | Coverage level changed |
| canceled / deleted | Called off, or removed from the schedule |
| opponents_swapped / opponents_updated | Home/away flipped, or participants changed |
deleted carries no match
deleted is the one action that arrives without a body. The match has been removed from the offer,
so there is no record left to send and message.match is null - the envelope is the whole
message. Identify the match by event_id, which is the match id on an event_type: "match" event:
onFixture(message) {
if (message.action === 'deleted' && message.event_type === 'match') {
removeFromOffer(message.event_id); // message.match is null here
return;
}
applyUpdate(message.match);
}Reading message.match.id on a deleted event throws, and the throw reaches your consumer
callback, so the delivery is requeued rather than processed. Guard with message.match?.id, or
branch on the action first as above. A deleted event is only sent before markets are created, so
if you are already holding markets for a match this is not the message that removes it.
Also make sure your queue binding actually routes it. The routing key is
{version}.{videogame_slug}.{event_type}.{event_id}.{type}.{action}, so this message ships as
v1.cs-go.match.1632625.fixture.deleted. A binding that enumerates actions rather than ending in
.* or .# will silently exclude it.
live_available and live_not_available are legacy actions, still sent for backward compatibility.
Read betting_metadata.live_available on the match instead.
The type stays open, so an action added server-side arrives as a plain string rather than breaking
your build. Enumerate the ones this version knows with FIXTURE_ACTIONS, and narrow with
isKnownFixtureAction when you want an exhaustive switch:
import { isKnownFixtureAction } from '@pandascore/odds-sdk';
if (!isKnownFixtureAction(msg.action)) {
logger.warn(`Unrecognised fixture action: ${msg.action}`); // treat as a plain update
return;
}
switch (msg.action) {
case 'settled': /* ... */ break;
// ...
}Market actions
MarketsMessage.action is typed the same open way as fixture actions, with MARKET_ACTIONS and
isKnownMarketAction alongside it:
| Action | Meaning |
|--------|---------|
| created | Market appeared |
| odds_changed | Prices moved |
| margin_changed | Your margin was reapplied |
| suspended / deactivated | Temporarily closed, or withdrawn |
| settled / partially_settled | Resolved, fully or in part |
| rollback_settlement | A settlement was reversed |
| opponent_updated | A participant on the market changed |
Match and game status are different sets
MatchStatus and GameStatus do not share values beyond finished, and mixing them up is easy:
| | Values |
|---|---|
| MatchStatus | not_booked, pending, pre_match, live, postponed, finished, settled, canceled |
| GameStatus | not_started, running, finished, not_played |
A match is never not_started or running; a game is never live or pending. The two sets
overlap only on finished. Both are also exported as runtime arrays (MATCH_STATUSES,
GAME_STATUSES, MATCH_TYPES) for validation.
Upgrading from 1.5.0: the status types are kept open so a value added server-side does not break your build, which also means
match.status === 'running'still compiles - it simply never matches. Search your code fornot_startedandrunningon a match and move them topending,pre_matchorlive.
Videogame-specific match fields
A few fields on FixtureMatch only populate for one videogame:
| Field | Videogame | Notes |
|-------|-----------|-------|
| map_type_order | Overwatch | Map types played, in order: assault, control, escort, hybrid. Empty array on every other videogame |
| games[].game_round_teams | Counter-Strike | Round-by-round side and outcome |
| games[].rounds_score | Counter-Strike | Per-team round score |
| games[].map, games[].number_of_rounds | Team videogames | Absent on eBattles, where a game is not played on a map and has no rounds |
Reading match scores
FixtureMatch.results is one entry per competitor. Which identifier the entry carries follows
opponents[i].type:
| Opponents | Identifier | Videogames |
|-----------|------------|------------|
| Team | team_id, no player_id | Counter-Strike, Dota 2, League of Legends, Valorant, Overwatch, Rocket League, ... |
| Player | player_id, no team_id | eBattles: eBasketball, eSoccer, eHockey, eTennis |
Use competitorId(result) when you only need the identifier and not which kind it is - it returns
whichever of the two the match carries, so the same code covers both:
import { competitorId } from '@pandascore/odds-sdk';
const scoreByCompetitor = new Map(match.results.map((r) => [competitorId(r), r.score]));
for (const { opponent, playing_as } of match.opponents) {
// On eBattles, `name` is the player and `playing_as` the team they are playing as
console.log(opponent.name, playing_as, scoreByCompetitor.get(opponent.id));
}Testing
npm test # run the unit suite once (vitest)
npm run test:watchThe suite covers the feed-status state machine, recovery retry and failure signaling, in-order buffer draining, the heartbeat state machine, and typed-listener dispatch - all offline, with no network or broker required.
