Skip to main content

Pinnacle + Betfair + PAL Architecture — Complete Research

All research compiled from Pinnacle API docs (api/linesapi.yaml, api/betsapi.yaml), Betfair adapter source code, and PAL architecture docs. This document is the single source of truth for understanding how both providers work and how PAL abstracts them.


Table of Contents

  1. PAL Architecture Overview
  2. PAL Core Interfaces
  3. Canonical Models Deep-Dive
  4. Capability System
  5. Sport ID Mapping
  6. Market Types — Structural Differences
  7. Periods
  8. lineId — Pinnacle's Optimistic Concurrency
  9. Live Betting & PENDING_ACCEPTANCE
  10. Incremental Updates — since vs Streaming
  11. ExternalIds
  12. Order Placement Flow
  13. Settlement & Bet Status Mapping
  14. Bet Types — Straight, Parlay, Teaser, Special
  15. Pinnacle API — Complete Endpoint Inventory
  16. Pinnacle Data Models
  17. Pinnacle Error Codes
  18. Betfair Adapter Reference

1. PAL Architecture Overview

PAL (Provider Abstraction Layer) is a plugin-based architecture with four pillars:

PillarWhat It Does
Canonical ModelsUniversal data format (CanonicalFixture, CanonicalMarket, CanonicalOrder, etc.) — all adapters map to/from these
Plug-in AdaptersEach provider (Betfair, Pinnacle) implements the same interfaces; adapters handle API-specific logic + mapping
Capability-Based RoutingRoutingStrategy picks the right provider per sport/feature; SportProviderConfig declares primary + fallbacks
Graceful DegradationFailoverManager implements circuit breaker (closed → open after 5 failures → half-open after 60s)

Request Flow

Route handler
→ ExchangeCoordinator.getOdds(sportId, ...)
→ RoutingStrategy.getRouteForOdds(sportId) → { primary: 'pinnacle', fallbacks: ['betfair'] }
→ executeWithFailover(providers, fn)
→ try primary (PinnacleAdapter)
→ PinnacleClient.getOdds(...)
→ PinnacleMapper.mapToCanonical(...)
→ return CanonicalMarket[]
→ on failure: FailoverManager.recordFailure('pinnacle')
→ try fallback (BetfairAdapter)

Key Files (all paths relative to worktree root)

FilePurpose
backend/src/exchanges/core/interfaces/IExchangeAdapter.tsMain adapter interface — extends IOddsProvider + IOrderProvider
backend/src/exchanges/core/interfaces/IOddsProvider.tsgetFixtures(), getMarkets(), getOdds(), getFixture(), supportsSport()
backend/src/exchanges/core/interfaces/IOrderProvider.tsplaceOrder(), cancelOrder(), getOrder(), getOrders(), getBetslip()
backend/src/exchanges/core/interfaces/IStreamProvider.tssubscribeMarket(), subscribeOrders(), connect(), disconnect()
backend/src/exchanges/core/models/canonical.tsAll canonical types
backend/src/exchanges/core/models/capability.tsProviderCapability, ProviderFeatures, RoutingConfig
backend/src/exchanges/core/models/events.tsStream event types
backend/src/exchanges/core/coordinator/ExchangeCoordinator.tsMain orchestrator — singleton exchangeCoordinator
backend/src/exchanges/core/coordinator/RoutingStrategy.tsSport → provider routing — singleton routingStrategy
backend/src/exchanges/core/coordinator/FailoverManager.tsCircuit breaker — singleton failoverManager
backend/src/exchanges/core/registry/ProviderRegistry.tsAdapter registry — register(), get(), getForSport()
backend/src/exchanges/index.tsPAL bootstrap — initializePAL(), registers adapters, configures routing
backend/src/exchanges/adapters/betfair/BetfairAdapter.tsReference adapter implementation
backend/src/exchanges/adapters/betfair/mappers/BetfairMapper.tsReference mapper implementation

2. PAL Core Interfaces

IExchangeAdapter (combines IOddsProvider + IOrderProvider)

initialize(): Promise<void>
shutdown(): Promise<void>
isReady(): boolean
healthCheck(): Promise<HealthCheckResult>
supportsStreaming(): boolean
getStreamProvider(): IStreamProvider | null

Type variants: NonStreamingAdapter (streaming = false), StreamingAdapter (streaming = true).

IOddsProvider

MethodParamsReturnsNotes
getFixtures(params){ sportId, competitionIds?, dateFrom?, dateTo?, inPlay? }CanonicalFixture[]List fixtures for a sport
getMarkets(params){ fixtureId, sportId, marketTypes? }CanonicalMarket[]Markets for a fixture
getOdds(params){ fixtureId, sportId, marketIds? }CanonicalMarket[]Markets with live prices
getFixture(id, sportId)fixture ID + sportCanonicalFixture | nullSingle fixture
supportsSport(sportId)Hannibal sportIdboolean

IOrderProvider

MethodParamsReturnsNotes
placeOrder(params){ userId, fixtureId, marketId, outcomeId, side, stake, odds, sportId }CanonicalOrder
cancelOrder(params){ orderId, userId }CanonicalOrderPinnacle: not supported
getOrder(params){ orderId, userId }CanonicalOrder | null
getOrders(params){ userId, status?, dateFrom?, dateTo? }CanonicalOrder[]
getBetslip(params){ fixtureId, marketId, outcomeId, sportId }betslip infoPre-bet validation
supportsOrdersForSport(sportId)boolean

3. Canonical Models Deep-Dive

CanonicalFixture

FieldTypeDescription
idstringHannibal internal ID
externalIdsExternalIdsProvider-specific IDs (see ExternalIds section)
sportIdnumberHannibal sport ID
homeTeamstring
awayTeamstring
startTimeDate
status'not_started' | 'live' | 'finished' | 'suspended' | 'cancelled'
marketsCanonicalMarket[]Optional — populated when fetching with markets
competitionIdstringOptional
competitionNamestringOptional

CanonicalMarket

FieldTypeDescription
idstringHannibal internal market ID
fixtureIdstringParent fixture
externalIdsExternalIds
typeMarketTypeSee below
namestringHuman-readable name
handicapnumberFor spread/total markets
periodstringOptional — "full_time", "first_half", etc.
status'open' | 'closed' | 'suspended'
outcomesCanonicalOutcome[]

MarketType enum

'match_odds' | 'over_under' | 'asian_handicap' | 'spread' |
'moneyline' | 'both_teams_score' | 'correct_score' |
'player_prop' | 'outright' | 'other'

CanonicalOutcome

FieldTypeDescription
idstring
externalIdsExternalIds
namestringe.g., "Home", "Away", "Over 2.5"
priceCanonicalPrice
activeboolean

CanonicalPrice

FieldTypeBetfair Maps ToPinnacle Maps To
backnumber?availableToBack[0].pricemoneyline/spread/total price (decimal)
laynumber?availableToLay[0].priceundefined (no lay)
backDepthPriceSize[]?Up to 3 levels of back depthundefined (single price)
layDepthPriceSize[]?Up to 3 levels of lay depthundefined
limitnumber?Not setmaxMoneyline / maxSpread / maxTotal
limitMinnumber?Not setminRiskStake from /v2/line
changedAtDate

CanonicalOrder

FieldTypeDescription
idstringHannibal order ID
userIdstring
statusOrderStatusSee status mapping table
side'back' | 'lay'Pinnacle: always 'back'
requestedStakenumber
requestedOddsnumber
matchedStakenumber?
matchedOddsnumber?
providerstring'betfair' or 'pinnacle'
providerOrderIdstringBetfair betId or Pinnacle betId
settlementResultSettlementResult?

OrderStatus

'pending' | 'submitted' | 'accepted' | 'partially_accepted' |
'declined' | 'cancelled' | 'lapsed' | 'settled'

SettlementResult

'win' | 'lose' | 'void' | 'half_win' | 'half_lose' | 'push'

ExternalIds

interface ExternalIds {
oddspapi?: string;
betfair?: BetfairExternalId;
matchbook?: string;
pinnacle?: string;
betradarId?: string;
}

interface BetfairExternalId {
eventId: string;
marketIds: Record<string, string>;
}

4. Capability System

ProviderFeatures

FeatureTypeBetfairPinnacle
layBettingbooleantruefalse
inPlaybooleantruetrue (with PENDING_ACCEPTANCE)
streamingbooleantruefalse
cashOutbooleantruefalse
partialMatchingbooleantruefalse
betEditingbooleanfalsefalse
cancellationbooleantruefalse
accountBalancebooleantruetrue
orderStatusbooleantruetrue

ProviderCapability

{
providerId: 'pinnacle',
displayName: 'Pinnacle (PS3838)',
supportedSports: [10, 1, 6], // Soccer, Basketball, Tennis
supportedMarkets: ['match_odds', 'spread', 'over_under', 'moneyline'],
features: { /* see above */ },
priority: 50, // lower than Betfair's 100
rateLimits: { requestsPerSecond: 5, requestsPerMinute: 60 },
health: { status: 'healthy', lastCheck: Date, latencyMs: number }
}

RoutingConfig & SportProviderConfig

interface RoutingConfig {
defaultProvider: string;
sportConfigs: SportProviderConfig[];
autoFailover: boolean;
maxFailoverAttempts: number;
circuitBreakerThreshold: number; // default: 5 failures
circuitBreakerResetMs: number; // default: 60000 (60s)
}

interface SportProviderConfig {
sportId: number; // Hannibal's internal SportId — NOT provider-specific
primary: string; // provider ID
fallbacks: string[]; // ordered fallback list
features?: Partial<ProviderFeatures>;
}

Current config (in capability.ts):

  • defaultProvider: 'oddspapi' (legacy — will change)
  • Cricket (27) and Soccer (10) both point to 'oddspapi'

Planned config after Pinnacle integration:

SportIdSportPrimaryFallbacks
27Cricketbetfair['pinnacle']
10Soccerpinnacle['betfair']
1Basketballpinnacle['betfair']
6Tennispinnacle['betfair']

5. Sport ID Mapping

The Three ID Spaces

PAL uses Hannibal's internal SportId for routing. Each adapter maintains bidirectional maps to translate.

Hannibal SportIdSportBetfair eventTypeIdPinnacle sportId
10Soccer"1"29
27Cricket"4"TBD (confirm via /v3/sports)
1Basketball"7522"4
6Tennis"2"33
3American Football"6423"15
4Ice Hockey"7524"TBD
13Horse Racing"7"N/A (not on Pinnacle)

How Betfair Does It

Two maps in BetfairAdapter.ts (lines 78-104) and BetfairMapper.ts (lines 42-50):

Forward map (Hannibal → Betfair): SPORT_TO_EVENT_TYPE — 22 entries, used when calling Betfair API.

Reverse map (Betfair → Hannibal): BETFAIR_EVENT_TYPE_TO_SPORT — only 7 entries (incomplete vs forward map). Used when mapping responses back.

supportsSport(sportId): checks if sportId exists in SPORT_TO_EVENT_TYPE.

What Pinnacle Needs

Same pattern — two maps:

  • HANNIBAL_TO_PINNACLE_SPORT: Record<number, number> — for API calls
  • PINNACLE_TO_HANNIBAL_SPORT: Record<number, number> — for mapping responses

Start with 3 sports (soccer=29, basketball=4, tennis=33), easily extendable by adding entries.


6. Market Types — Structural Differences

This is the biggest architectural difference between Betfair and Pinnacle.

Betfair: One Market = One Entity

listMarketCatalogue → returns individual markets
Market "Match Odds" → marketId "1.234" → type: match_odds
Market "Over/Under 2.5" → marketId "1.235" → type: over_under
Market "Asian Handicap -1" → marketId "1.236" → type: asian_handicap

Each Betfair market maps to exactly one CanonicalMarket.

Type detection is heuristic — BetfairMapper.determineMarketType() (lines 92-136) parses marketType and marketName strings:

  • MATCH_ODDSmatch_odds
  • Contains "Over/Under" or "Goals" → over_under
  • ASIAN_HANDICAPasian_handicap
  • etc.

Pinnacle: One Period = Multiple Market Types

GET /v4/odds → returns all types nested in each period
Event 12345
→ Period 0 (Full Game)
→ moneyline: { home: 1.85, away: 2.10, draw: 3.40 }
→ spreads: [{ hdp: -1.5, home: 1.90, away: 1.95 }]
→ totals: [{ points: 2.5, over: 1.87, under: 1.98 }]
→ teamTotal: { home: [...], away: [...] }
→ Period 1 (1st Half)
→ moneyline: { ... }
→ spreads: [{ ... }]
→ totals: [{ ... }]

One Pinnacle period explodes into multiple CanonicalMarkets:

Pinnacle Period FieldCanonical MarketTypeCanonical Market ID PatternCanonical Market Name
moneyline: {home, away, draw}match_oddspin_{eventId}_p{period}_ml"Match Odds" / "1st Half Match Odds"
spreads[0]: {hdp, home, away}spreadpin_{eventId}_p{period}_spread_{hdp}"Spread -1.5"
spreads[1+] (alt lines)spreadpin_{eventId}_p{period}_spread_{hdp}_alt"Alt Spread +0.5"
totals[0]: {points, over, under}over_underpin_{eventId}_p{period}_ou_{points}"Over/Under 2.5"
totals[1+] (alt lines)over_underpin_{eventId}_p{period}_ou_{points}_alt"Alt Over/Under 3.0"
teamTotal.home[0]over_underpin_{eventId}_p{period}_tt_home_{points}"Team Total Home O/U 1.5"
teamTotal.away[0]over_underpin_{eventId}_p{period}_tt_away_{points}"Team Total Away O/U 1.5"

Summary Table

DimensionBetfairPinnacle
Market identityEach market type = separate marketIdMarket types implicit in period (moneyline, spreads, totals)
DiscoverylistMarketCatalogue returns individual markets/v4/odds returns ALL types nested in each period
Type detectionHeuristic string parsingExplicit fields
Canonical mapping1:11:N (one period → many CanonicalMarkets)
Alt linesDifferent marketId per linealtLineId within same period
Handicap valueIn market name/typehdp field on spread, points field on total

7. Periods

Betfair: No Periods

Betfair has no concept of periods. Instead, each time segment gets its own separate market:

  • "Match Odds" (full match)
  • "First Half Goals Over/Under 0.5" (first half)
  • "Second Half Match Odds" (second half)

BetfairMapper never sets CanonicalMarket.period — it's always undefined.

Pinnacle: Explicit Period System

Every market is scoped to a periodNumber. Discovered via GET /v1/periods?sportId=X.

SportPeriod 0Period 1Period 2Period 3+
Soccer (29)Full Match1st Half2nd HalfExtra Time (3)
Basketball (4)Full Game1st Half2nd Half1Q (3), 2Q (4), 3Q (5), 4Q (6)
Tennis (33)Full Match1st Set2nd Set3rd Set (3), 4th (4), 5th (5)

Period → CanonicalMarket.period Mapping

Pinnacle periodNumberCanonicalMarket period
0"full_time"
1"first_half" / "first_set" (sport-dependent)
2"second_half" / "second_set"
3+Sport-specific naming

Each period independently has its own:

  • lineId (for concurrency)
  • cutoff date (when wagering closes)
  • status (1=open, 2=offline)
  • maxSpread, maxMoneyline, maxTotal (bet limits)
  • Full set of moneyline/spreads/totals odds

8. lineId — Pinnacle's Optimistic Concurrency

What lineId Is

lineId is a snapshot identifier for odds at a specific point in time. It's an int64 that changes every time odds move for a given period + market type combination.

The Flow

1. GET /v4/odds → get lineId=9876 for event 12345, period 0, moneyline
2. GET /v2/line?lineId=9876&... → validate line is still valid, get fresh price + limits
3. POST /v4/bets/place { lineId: 9876, ... } → place bet

If odds moved between step 1 and step 3, the bet is rejected with LINE_CHANGED.

Why It Exists

Pinnacle is a bookmaker — they set the price. They need to ensure you bet at the price they currently offer. lineId is their optimistic concurrency control: "I'm offering these odds at this version — if the version changed, your bet is stale."

Comparison with Betfair

ConceptPinnacle lineIdBetfair
What it representsSnapshot of odds at a point in timeNo equivalent — you set your own price
Who sets pricePinnacle (the house)Other bettors (P2P exchange)
StalenessInvalid when odds move → LINE_CHANGED errorN/A — your order sits at your price until matched
Required for bet placementYes — must come from /v2/line or /v4/oddsNo — you pass desired price directly
If staleBet rejected, must re-fetchOrder stays unmatched (EXECUTABLE status)
altLineIdFor non-primary handicap/total linesDifferent marketId per line

For Alternate Lines

When Pinnacle offers multiple handicap or total lines (e.g., -1.5, -2.0, -2.5), the first is the primary line (uses lineId), and the rest are alternate lines (each has its own altLineId). Bet placement for alt lines requires both lineId and altLineId.


9. Live Betting & PENDING_ACCEPTANCE

The Core Difference

AspectBetfairPinnacle
ModelP2P exchange — time-based delay queueBookmaker — hold-and-decide
MechanismOrder enters delay (1-12s per sport) → if market suspends during delay, order lapses → otherwise enters order bookBet enters PENDING_ACCEPTANCE → Pinnacle reviews → ACCEPTED or NOT_ACCEPTED
betId returned?Always returned immediatelyNot returned during PENDING_ACCEPTANCE
Max wait12 seconds (sport-dependent)Up to ~30 seconds (undocumented)
User experienceBrief delay, then matched/unmatched in bookShow "pending" spinner, poll until resolved

Betfair Live Delay

User places bet → order enters delay queue (e.g., 5s for soccer)
→ During delay: market suspends (goal scored) → order LAPSES
→ Delay expires: order enters book → matches against opposite side

Orders always get a betId immediately. Status tracked via streaming or polling.

Pinnacle PENDING_ACCEPTANCE

User places bet → POST /v4/bets/place
→ Response: { status: "PENDING_ACCEPTANCE" } (no betId!)
→ Must poll: GET /v3/bets?uniqueRequestIds=<uuid>
→ Eventually: ACCEPTED (with betId) or NOT_ACCEPTED

Key implementation detail: Use uniqueRequestId (UUID) as the idempotency key and tracking handle. Store it in the order record to poll for resolution.

Soccer-Specific: Danger Zone

Pinnacle has a soccer-specific field betAcceptanceType:

ValueMeaning
0None — no live betting
1Danger Zone only — bets accepted with extra delay around dangerous moments
2Live Delay — standard delay
3Both — Danger Zone + Live Delay

10. Incremental Updates — since vs Streaming

Betfair: Push-Based Streaming

TLS socket → stream-api.betfair.com:443
→ Subscribe: { op: "marketSubscription", marketFilter: { marketIds: [...] } }
→ Receive MCM (Market Change Messages) with runner deltas
→ atb: available to back changes
→ atl: available to lay changes
→ ltp: last traded price
→ tv: total volume
→ Internal `clk` token tracks position
→ Reconnection: full image (img: true) + exponential backoff
→ Latency: ~50-150ms

Pinnacle: Poll-Based with since Cursor

1. GET /v3/fixtures?sportId=29 → response includes { last: 123456789 }
2. GET /v3/fixtures?sportId=29&since=123456789 → only changed fixtures since that cursor
3. GET /v4/odds?sportId=29&since=987654321 → only changed odds since that cursor
AspectBetfair StreamingPinnacle since
ProtocolTLS socket, push-basedHTTP polling, pull-based
Latency~50-150msUp to polling interval (recommend 5-10s for live)
Delta mechanismMCM with runner deltas (atb, atl, ltp)since=<last> returns only changed items
Clock/cursorInternal clk token (client doesn't manage)last field (int64) — must cache and pass back
ReconnectionRe-subscribe, get full image (img: true)Call without since for full snapshot
BandwidthMinimal deltasFull objects for changed items

Implementation for Pinnacle

PinnacleAdapter needs to:

  1. Store last cursors per sport (in Redis or in-memory cache)
  2. Poll on interval (5-10s for live events, 30-60s for pre-match)
  3. On first call or cache miss → omit since → get full snapshot
  4. On subsequent calls → include since=<cached_last> → get deltas only
  5. Merge deltas into local cache

11. ExternalIds

How Betfair Sets Them

From BetfairMapper.ts:

Fixture ExternalIds:

{ betfair: { eventId: "32456789", marketIds: { "1.234": "1.234" } } }

Market ExternalIds:

{ betfair: { eventId: "32456789", marketIds: { "1.234": "1.234" } } }

Outcome ExternalIds:

{ betfair: { eventId: "32456789", marketIds: { "1.234": "selectionId" } } }

Planned Pinnacle ExternalIds

The ExternalIds type already has a pinnacle?: string field. We encode compound keys as strings:

EntityPatternExample
Fixture"eventId""123456"
Market"eventId_p{period}_{betType}""123456_p0_ml", "123456_p1_spread_-1.5"
Outcome"eventId_p{period}_{team/side}""123456_p0_home", "123456_p0_over_2.5"

These compound keys allow reconstructing the Pinnacle API parameters needed for bet placement from a CanonicalMarket.externalIds.pinnacle string.


12. Order Placement Flow

Side-by-Side Comparison

StepBetfairPinnacle
1. AuthSSL cert + session token + appKey (header)HTTP Basic Auth (every request)
2. Find eventlistMarketCatalogue({ filter: { eventTypeIds, ... } })marketId, selectionIdGET /v3/fixtures?sportId=XeventId
3. Get pricelistMarketBook({ marketIds })availableToBack/Lay (or via streaming)GET /v4/odds?sportId=X&eventIds=YlineId, prices
4. Pre-bet validationgetBetslip() → checks market status, returns best available priceGET /v2/line?lineId=X&... → validates line, returns fresh price, min/maxRiskStake, min/maxWinStake
5. Place betplaceOrders({ marketId, instructions: [{ selectionId, price, size, side }] })POST /v4/bets/place({ sportId, eventId, periodNumber, betType, lineId, stake, winRiskStake, team/side })
6. ResponseSynchronous: EXECUTABLE (in book) or EXECUTION_COMPLETE (matched)ACCEPTED, PENDING_ACCEPTANCE, or PROCESSED_WITH_ERROR
7. Post-placementStream subscription for order updates; orderSyncJob polls every 30sPoll GET /v3/bets?uniqueRequestIds=X for PENDING_ACCEPTANCE resolution
8. SettlementcheckSettledOrders() polls listClearedOrders every 60sNew pinnacleSettlementJob polls GET /v3/fixtures/settled + GET /v3/bets?betlist=SETTLED

Pinnacle Bet Placement — Required Fields

{
oddsFormat: "DECIMAL", // Always decimal for our system
uniqueRequestId: "<uuid>", // Idempotency key — CRITICAL for PENDING tracking
acceptBetterLine: true, // Accept favorable line moves
stake: 100, // Amount
winRiskStake: "RISK", // RISK = stake is wager amount, WIN = stake is desired profit
lineId: 987654321, // From /v2/line — REQUIRED
altLineId: undefined, // Only for alternate lines
fillType: "NORMAL", // NORMAL | FILLANDKILL | FILLMAXLIMIT
sportId: 29, // Pinnacle sport ID
eventId: 123456, // Pinnacle event ID
periodNumber: 0, // 0 = full game
betType: "MONEYLINE", // MONEYLINE | SPREAD | TOTAL_POINTS | TEAM_TOTAL_POINTS
team: "TEAM1", // For moneyline/spread: TEAM1, TEAM2, DRAW
side: undefined, // For totals: OVER, UNDER
handicap: undefined // For spread/totals: the handicap/points value
}

Win vs Risk Stake

ModeMeaningExample (odds 2.5, stake 100)
RISKStake = amount wageredRisk: $100, Win: $150 (profit), Total return: $250
WINStake = desired profitRisk: $66.67, Win: $100 (profit), Total return: $166.67

Betfair always uses risk-based size. PAL should always use RISK for consistency.

fillType

TypeBehavior
NORMALReject if stake exceeds limit
FILLANDKILLPlace at max limit if stake exceeds, reject remainder
FILLMAXLIMITIgnore stake, always bet the maximum allowed

Default recommendation: NORMAL for standard bets.


13. Settlement & Bet Status Mapping

Bet Status — Complete State Map

Pinnacle StatusPinnacle betStatus2Betfair EquivalentCanonical OrderStatusCanonical SettlementResult
ACCEPTEDEXECUTION_COMPLETEaccepted
PENDING_ACCEPTANCEN/A (delay queue)submitted
NOT_ACCEPTEDN/Adeclined
PROCESSED_WITH_ERRORstatus !== SUCCESSdeclined
WONWONbetOutcome: WONsettledwin
LOSELOSTbetOutcome: LOSTsettledlose
CANCELLEDCANCELLEDREMOVEDsettledvoid
REFUNDEDREFUNDEDN/Asettledvoid
HALF_WON_HALF_PUSHEDN/Asettledhalf_win
HALF_LOST_HALF_PUSHEDN/Asettledhalf_lose
N/AN/AEXECUTABLEsubmitted
N/AN/AEXPIRED / LAPSEDlapsed

Note: Pinnacle has betStatus (basic) and betStatus2 (extended with half-win/half-lose). Always prefer betStatus2 for settlement mapping.

Settlement Polling Comparison

AspectBetfair (orderSyncJob.ts)Pinnacle (planned)
Job namecheckSettledOrders()checkPinnacleSettledOrders()
Interval60 seconds60 seconds
Detection endpointlistClearedOrders({ betStatus: 'SETTLED' })GET /v3/fixtures/settled?sportId=X&since=Y
Bet detailsgetMarketResult() for market-level resultsGET /v3/bets?betlist=SETTLED&fromDate=X
Score dataRunner WINNER/LOSER statusteam1Score, team2Score in settlement response
Re-settlementN/AStatus 2 = re-settled (new settlementId)
FilterroutingVenue: 'betfair' orders onlyroutingVenue: 'pinnacle' orders only

Settlement Status Values (from fixtures/settled)

StatusMeaningAction
1SettledProcess settlement
2Re-settledRe-process with new settlementId, reverse previous
3CancelledVoid the bet
4Re-settled as cancelledReverse previous settlement, void
5DeletedTreat as void

14. Bet Types — Straight, Parlay, Teaser, Special

Overview

Bet TypeDescriptionPinnacle SupportBetfair SupportPAL MVP?
StraightSingle bet on one outcomeYes — /v4/bets/placeYes — placeOrdersYes
ParlayMulti-event combo — all legs must winYes — /v4/bets/parlayNo (exchange model doesn't support)No
TeaserAdjusted spreads/totals in combo — all must winYes — /v4/bets/teaserNoNo
SpecialProps, futures, outrightsYes — /v4/bets/specialYes (separate markets, not "specials")No

Straight Bets

Standard single-outcome bet. Both providers support this. This is the only type needed for MVP.

The betType field on Pinnacle straight bets determines which market type:

betTypeMarketteam fieldside fieldhandicap field
MONEYLINEMatch resultTEAM1, TEAM2, DRAWN/AN/A
SPREADHandicapTEAM1, TEAM2N/AYes (e.g., -1.5)
TOTAL_POINTSOver/UnderN/AOVER, UNDERYes (e.g., 2.5)
TEAM_TOTAL_POINTSTeam total O/UTEAM1, TEAM2OVER, UNDERYes

Parlay Bets

Multi-leg accumulator. Requires separate odds endpoint (/v4/odds/parlay) and line validation (/v2/line/parlay). Not all events are parlay-eligible (parlayRestriction field on fixtures).

Teaser Bets

Adjusted point spreads/totals in exchange for adjusted odds. Specific to American sports. Requires /v1/teaser/groups for available adjustments.

Special Bets (Props/Futures/Outrights)

Pinnacle treats these differently from standard markets:

  • Separate endpoints: /v2/fixtures/special, /v2/odds/special, /v2/line/special, /v4/bets/special
  • Use specialId + contestantId instead of eventId + periodNumber + betType
  • Each contestant has individual lineId, price, and limits

Examples: "Player X to score first goal", "Team Y to win the league", "Total corners over 10.5"


15. Pinnacle API — Complete Endpoint Inventory

Base URL: https://api.ps3838.com Auth: HTTP Basic — Authorization: Basic <Base64("username:password")>

Lines API

MethodPathRequired ParamsReturnsNotes
GET/v3/fixturessportIdFixturesResponseV3Non-settled events. Optional: leagueIds, isLive, since, eventIds
GET/v2/fixtures/specialsportIdSpecialsFixturesResponseV2Non-settled specials
GET/v3/fixtures/settledsportIdSettledFixturesSportV3Settled last 24h with scores
GET/v3/fixtures/special/settledsportIdSettledSpecialsResponseV3Settled specials last 24h
GET/v4/oddssportIdOddsResponseV4Straight odds. Optional: leagueIds, oddsFormat, since, isLive, eventIds, toCurrencyCode
GET/v4/odds/parlaysportIdParlayOddsResponseV4Parlay-eligible odds
GET/v1/odds/teaserteaserIdTeaserOddsResponseTeaser odds
GET/v2/odds/specialsportIdSpecialOddsResponseV2Special/prop odds
GET/v2/lineleagueId, handicap, oddsFormat, sportId, eventId, periodNumber, betTypeLineResponseV2Pre-bet line validation
POST/v2/line/parlaybody: ParlayLinesRequestV2ParlayLinesResponseV2Validate parlay lines
GET/v2/line/specialoddsFormat, specialId, contestantIdSpecialLineResponseSpecial line
GET/v3/sportsnoneSportsResponseV3All sports with event counts
GET/v3/leaguessportIdLeaguesV3Leagues for a sport
GET/v1/periodssportIdSportPeriodPeriod definitions per sport
GET/v2/inrunningnoneInRunningResponseLive events with game states
GET/v1/teaser/groupsoddsFormatTeaserGroupsResponseTeaser group definitions
GET/v1/cancellationreasonsnoneCancellationReasonResponseCancellation codes
GET/v2/currenciesnoneSuccessfulCurrenciesResponseSupported currencies

Bets API

MethodPathRequired ParamsReturnsNotes
POST/v4/bets/placebody: PlaceBetRequestV2PlaceBetResponseV4Place straight bet
POST/v4/bets/parlaybody: PlaceParlayBetRequestPlaceParlayBetResponseV4Place parlay
POST/v4/bets/teaserbody: PlaceTeaserBetRequestPlaceTeaserBetResponseV4Place teaser
POST/v4/bets/specialbody: MultiBetRequest[SpecialBetRequest]MultiBetResponse[SpecialBetResponseV4]Place special
GET/v3/betsvaries by query typeGetBetsByTypeResponseV3Get bets by type/date/IDs
GET/v1/bets/betting-statusnoneBettingStatusResponseSystem betting status

GET /v3/bets Query Modes

Parameter SetUsage
betlist=RUNNINGAll unsettled bets
betlist=SETTLED&fromDate=X&toDate=YSettled bets in date range
betIds=1,2,3Specific bets by ID
uniqueRequestIds=uuid1,uuid2Track PENDING_ACCEPTANCE bets

16. Pinnacle Data Models

FixtureV3

FieldTypeDescription
idint64Unique event ID
parentIdint64?Linked event (live event has pre-game as parent)
startsdate-timeEvent start time UTC
homestringHome team name
awaystringAway team name
rotNumstringRotation number (deprecated)
liveStatusint320=No live, 1=Currently live, 2=Will offer live
statusstringO=Open, H=Halted, I=Red circle (deprecated)
betAcceptanceTypeint32Soccer: 0=None, 1=Danger, 2=Delay, 3=Both
parlayRestrictionint320=Unrestricted, 1=No parlay, 2=One leg/event
altTeaserbooleanAlt teaser points offered
resultingUnitstring?e.g., "Corners"
versionint64Increments on change

FixtureV3 → CanonicalFixture Mapping

Pinnacle FieldCanonical FieldTransform
idexternalIds.pinnacleString(id)
Generatedid"pin_" + id
sportId (from API call)sportIdPINNACLE_TO_HANNIBAL_SPORT[sportId]
homehomeTeamDirect
awayawayTeamDirect
startsstartTimenew Date(starts)
liveStatus + statusstatusSee status mapping below

Status mapping:

liveStatusstatusCanonicalFixture.status
1O'live'
0 or 2O'not_started'
anyH'suspended'
anyI'suspended'

OddsResponseV4 Structure

OddsResponseV4
sportId: number
last: number ← cursor for `since` polling
leagues: OddsLeagueV4[]
id: number
events: OddsEventV4[]
id: number ← matches FixtureV3.id
periods: OddsPeriodV4[]
lineId: number ← optimistic concurrency token
number: number ← period number (0, 1, 2, ...)
cutoff: date-time
status: number ← 1=online, 2=offline
maxSpread: number
maxMoneyline: number
maxTotal: number
maxTeamTotal: number
moneyline?: OddsMoneylineV4
home: number
away: number
draw?: number
spreads?: OddsSpreadV4[]
[0] = primary, [1+] = alt lines
altLineId?: number
hdp: number
home: number
away: number
max?: number
totals?: OddsTotalV4[]
[0] = primary, [1+] = alt lines
altLineId?: number
points: number
over: number
under: number
max?: number
teamTotal?: OddsTeamTotalsV4
home: OddsTeamTotalV4[]
away: OddsTeamTotalV4[]

LineResponseV2

FieldTypeDescription
statusstringSUCCESS or NOT_EXISTS
pricedoubleLatest price (may differ from odds response)
lineIdint64Current line ID (may differ from cached)
altLineIdint64?If alternate line
maxRiskStakedoubleMax risk-based stake
minRiskStakedoubleMin risk-based stake
maxWinStakedoubleMax win-based stake
minWinStakedoubleMin win-based stake

PlaceBetResponseV4

FieldTypeDescription
statusstringACCEPTED, PENDING_ACCEPTANCE, PROCESSED_WITH_ERROR
errorCodestring?See error codes table
betIdint64?Only present when ACCEPTED
uniqueRequestIdUUIDEcho back for tracking
windoubleWin amount
riskdoubleRisk amount
pricedoubleAccepted price
betterLineWasAcceptedbooleanIf acceptBetterLine triggered

StraightBetV3 (from GET /v3/bets)

FieldTypeDescription
betIdint64Bet ID
uniqueRequestIdUUIDIdempotency key
wagerNumberint32Wager sequence
placedAtdate-timePlacement time
betStatusenumACCEPTED, CANCELLED, LOSE, PENDING_ACCEPTANCE, REFUNDED, NOT_ACCEPTED, WON, REJECTED
betStatus2enumExtended: adds LOST, HALF_WON_HALF_PUSHED, HALF_LOST_HALF_PUSHED
betTypeenumMONEYLINE, SPREAD, TOTAL_POINTS, TEAM_TOTAL_POINTS, SPECIAL, PARLAY, TEASER
windoubleWin amount
riskdoubleRisk amount
winLossdouble?P&L for settled bets
oddsFormatenumFormat odds were placed in
customerCommissiondouble?If applicable
pricedoublePrice at placement
teamNamestring?Wagered team
isLivebooleanWas live when placed
sportIdint32
leagueIdint64
eventIdint64
handicapdouble?
periodNumberint32
team1, team2stringTeam names
team1Score, team2Scoredouble?Current/final scores
ftTeam1Score, ftTeam2Scoredouble?Full-time scores
cancellationReasonobject?If cancelled
updateSequenceint64Version for updates

17. Pinnacle Error Codes

Error CodeDescriptionRecommended Action
ALL_BETTING_CLOSEDAll betting closed on platformRetry later
ALL_LIVE_BETTING_CLOSEDLive betting closedRetry later
ABOVE_EVENT_MAXStake exceeds event maximumReduce stake
ABOVE_MAX_BET_AMOUNTStake exceeds max betReduce stake
BELOW_MIN_BET_AMOUNTStake below minimumIncrease stake
BLOCKED_BETTINGBetting blocked for eventSkip event
BLOCKED_CLIENTAccount blockedAlert admin
INSUFFICIENT_FUNDSNot enough balanceCheck/top up balance
INVALID_COUNTRYCountry restrictionAlert admin
INVALID_EVENTEvent not found/closedRefresh fixtures
INVALID_ODDS_FORMATBad odds formatFix request
LINE_CHANGEDOdds moved since lineIdRe-fetch line, retry
LISTED_PITCHERS_SELECTION_ERRORBaseball pitchers issueN/A for soccer/basketball/tennis
OFFLINE_EVENTEvent offlineSkip or retry
PAST_CUTOFFTIMEPast wagering cutoffCannot bet
RED_CARDS_CHANGEDRed card event (soccer)Re-fetch line, retry
SCORE_CHANGEDScore changed during placementRe-fetch line, retry
DUPLICATE_UNIQUE_REQUEST_IDDuplicate idempotency keyCheck existing bet
INCOMPLETE_CUSTOMER_BETTING_PROFILEAccount setup incompleteAlert admin
INVALID_CUSTOMER_PROFILEAccount issueAlert admin
LIMITS_CONFIGURATION_ISSUESystem config issueRetry later
RESPONSIBLE_BETTING_LOSS_LIMIT_EXCEEDEDLoss limit hitCannot bet
RESPONSIBLE_BETTING_RISK_LIMIT_EXCEEDEDRisk limit hitCannot bet
RESUBMIT_REQUESTTransient errorAuto-retry (once)
SYSTEM_ERROR_3System errorRetry with backoff
LICENCE_RESTRICTION_LIVE_BETTING_BLOCKEDLicense blocks liveSkip live bets
INVALID_HANDICAPBad handicap valueFix request
BETTING_SUSPENDEDBetting suspendedRetry later

Retryable vs Terminal Errors

CategoryError Codes
Auto-retry (once)RESUBMIT_REQUEST, SYSTEM_ERROR_3, LINE_CHANGED, SCORE_CHANGED, RED_CARDS_CHANGED
Reduce and retryABOVE_EVENT_MAX, ABOVE_MAX_BET_AMOUNT
Terminal (no retry)BLOCKED_CLIENT, INVALID_COUNTRY, PAST_CUTOFFTIME, DUPLICATE_UNIQUE_REQUEST_ID, INSUFFICIENT_FUNDS, BLOCKED_BETTING
Wait and retryALL_BETTING_CLOSED, ALL_LIVE_BETTING_CLOSED, OFFLINE_EVENT, BETTING_SUSPENDED

18. Betfair Adapter Reference

File Structure

backend/src/exchanges/adapters/betfair/
├── BetfairAdapter.ts — Main adapter (implements IExchangeAdapter)
├── BetfairClient.ts — HTTP REST client
├── BetfairStreamClient.ts — TLS streaming socket (MCM)
├── BetfairCache.ts — In-memory cache with streaming delta application
└── mappers/
└── BetfairMapper.ts — Betfair ↔ Canonical data transformation

Key Implementation Details

Sport mapping (BetfairAdapter.ts lines 78-104):

  • SPORT_TO_EVENT_TYPE: 22-entry forward map (Hannibal → Betfair eventTypeId)
  • SUPPORTED_SPORTS = Object.keys(SPORT_TO_EVENT_TYPE).map(Number)

Reverse mapping (BetfairMapper.ts lines 42-50):

  • BETFAIR_EVENT_TYPE_TO_SPORT: 7-entry reverse map (incomplete vs forward)

Capabilities: { layBetting: true, inPlay: true, streaming: true, cashOut: true, partialMatching: true, cancellation: true, priority: 100 }

Team name parsing (BetfairMapper.ts line ~170):

  • Splits event.name on /\s+v(?:s)?\s+/i regex to get home/away

Market type detection (BetfairMapper.ts lines 92-136):

  • Parses marketType string + marketName string
  • MATCH_ODDSmatch_odds, ASIAN_HANDICAPasian_handicap, etc.
  • Falls back to 'other' for unknown types

Price mapping (BetfairMapper.ts lines 263-285):

  • back = availableToBack[0].price, lay = availableToLay[0].price
  • Full depth: backDepth = availableToBack.map(...), layDepth = availableToLay.map(...)

getBetslip() (BetfairAdapter.ts lines 981-1062):

  • Check cache → fetch market book → check market status → find runner → return best price + available size

Settlement (orderSyncJob.ts):

  • Three jobs: runOrderSync() (30s), retryPendingOrders() (5min), checkSettledOrders() (60s)
  • checkSettledOrders(): find accepted orders with routingVenue: 'betfair' → call listClearedOrders → settle via settlementService

PAL Bootstrap (exchanges/index.ts)

// Simplified flow of initializePAL()
1. failoverManager.configure({ threshold: 5, resetMs: 60000 })
2. providerRegistry.register('betfair', betfairAdapter)
3. routingStrategy.updateConfig({ sportConfigs: [...] })
4. exchangeCoordinator.initialize()
5. // Initialize data providers (Roanuz for cricket)

Exports: exchangeCoordinator, providerRegistry, routingStrategy, failoverManager, idRegistry Functions: initializePAL(), shutdownPAL(), isPALReady(), getPALStatus()