npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 FeedStatus you can gate bet acceptance on.
  • Typed message callbacks - an optional FeedListener with onMarkets / onFixture / onScoreboard / onSettlements.
  • Void reasons - a structured void_reason on 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 recoverMatchMarkets returns 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+) - requestRecovery asks 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

npm install @pandascore/odds-sdk

Or via yarn:

yarn add @pandascore/odds-sdk

Upgrading 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 startWithRecovery on 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.onFeedStatusChanged receives a single { previous, current, reason } object. FeedListener.onFeedStatusChanged receives three arguments, (previous, current, reason). Taking the wrong one leaves the status undefined, which silently pins a bet-acceptance flag to false rather 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. STALE means 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 bare v1.recovery matches 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 HEALTHY and waits for the completed control 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_RECOVERY and the reconnection notification reports complete: 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 onMarkets and onFixture like any live message, so a FeedListener on its own is enough to stay in sync. onRecoveryStarted and onRecoveryCompleted frame it, and a snapshot message carries recovery_request_id if you want to tell it apart. The reconnection notification still reports what the SDK decided the outage cost - recoveryRequest is null when your queue held everything and nothing was requested - so wire events.on('notification') as well if you act on that decision. See examples/feed_listener_with_recovery.ts for 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 | null

Graceful Shutdown

await MySDK.close(); // stops heartbeat monitoring, cancels reconnects, closes the connection

After 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 data

The 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 for not_started and running on a match and move them to pending, pre_match or live.

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:watch

The 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.