@pandascore/odds-sdk
v1.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, automatic recovery of missed data, and in-order message buffering during recovery.
- 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. - HTTP clients - fetch matches, markets, booked matches, and settlements.
- 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
- Settlement Feed
- Data Models
- Testing
Installation
npm install @pandascore/odds-sdkOr via yarn:
yarn add @pandascore/odds-sdkConfiguration
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) |
| password | string | - | Account password (required) |
| queues | array | - | At least one { queueName, routingKey } binding (required, max 10) |
| apiBaseURL | string | - | REST API base URL (required) |
| feedHost | string | - | AMQPS feed hostname (required) |
| 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) |
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.complete) {
// Apply n.recoveryData.markets and n.recoveryData.modifiedMatches, then resume.
} else {
// Recovery was incomplete - recover the window manually using n.since (see below).
}
}
});
// Gate business actions on feed health.
MySDK.onFeedStatusChanged((prev, next, reason) => {
console.log(`feed status: ${prev} -> ${next} (${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.
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) | One missed heartbeat (~15s). Usually network jitter; data is still valid. Treat as a warning, not a stop. |
| RECOVERING | no | Snapshot or replay in progress; live messages are buffered. |
| 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((prev, next, reason) => {
bettingEnabled = next === 'HEALTHY' || next === 'DELAYED';
});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. If no heartbeat
arrives within 15 seconds the missed-beat counter increments; one miss flags DELAYED, and
three consecutive misses mark the feed STALE and emit a disconnection notification.
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:
- Buffers incoming live messages (so older recovered data is applied first).
- Calls
recover_marketsand the matches range endpoint to backfill the gap, retrying up to 4 times (immediately, then after 5s, 15s, 30s). - Emits a
reconnectionnotification carrying the recovered data and acompleteflag. - Drains the buffered live messages in order and returns the feed to
HEALTHY.
MySDK.events.on('notification', async (n) => {
if (n.type !== 'reconnection') return;
if (n.complete) {
applyRecovered(n.recoveryData.markets, n.recoveryData.modifiedMatches);
resumeMarketOperations();
} else {
// Automatic recovery failed after all retries. The feed is PARTIAL_RECOVERY.
// Recover the window manually using n.since, then clearPartialRecovery().
const markets = await MySDK.recoverMarkets(n.since);
const matches = await MySDK.fetchMatchesRange(n.since, new Date().toISOString());
applyRecovered(markets, matches);
MySDK.clearPartialRecovery('manual recovery complete');
resumeMarketOperations();
}
});Set recoverOnReconnect: false to skip the backfill (the reconnect itself still happens).
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:50 [INFO] Recovery mode started - buffering live messages
11:20:58 [INFO] Reconnection successful at 2026-01-20T11:20:58Z.
11:20:58 [INFO] Recovery mode ended - drained 3 buffered messages
(notification: reconnection, complete=true - apply data, resume)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 backfill. Recommended for most integrations. |
| # | true (default) | false | Disconnection detection and automatic reconnect, but no backfill. Use if you handle recovery yourself. |
| Specific key | false | false | No heartbeat detection, no backfill. 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(raw, scoreboardType) { /* e.g. "cs", "lol", "dota2" */ },
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.
The backfill after a disconnection does not arrive on the listener. A
FeedListenerhandles live messages (including the buffered ones drained after recovery), theonCheckpointsignal, andonFeedStatusChanged. The recovered markets/matches from a reconnection are delivered separately on thereconnectionnotification - see Reconnection and recovery. A service that needs a complete picture after a reconnect must wire both the listener andevents.on('notification'). Seeexamples/feed_listener_with_recovery.tsfor the full pattern.
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');
const range = await MySDK.fetchMatchesRange(fromISO, toISO);
const booked = await MySDK.fetchBookedMatches(); // active statuses, paginated
const recovered = await MySDK.recoverMarkets(sinceISO);
const settle = await MySDK.fetchSettlements(979621);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),
);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.
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 - SDK:
FeedStatus,StatusChange,FeedListener,RecoveryMode,RecoveryWindowExceededError
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.
