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
- PAL Architecture Overview
- PAL Core Interfaces
- Canonical Models Deep-Dive
- Capability System
- Sport ID Mapping
- Market Types — Structural Differences
- Periods
- lineId — Pinnacle's Optimistic Concurrency
- Live Betting & PENDING_ACCEPTANCE
- Incremental Updates —
sincevs Streaming - ExternalIds
- Order Placement Flow
- Settlement & Bet Status Mapping
- Bet Types — Straight, Parlay, Teaser, Special
- Pinnacle API — Complete Endpoint Inventory
- Pinnacle Data Models
- Pinnacle Error Codes
- Betfair Adapter Reference
1. PAL Architecture Overview
PAL (Provider Abstraction Layer) is a plugin-based architecture with four pillars:
| Pillar | What It Does |
|---|---|
| Canonical Models | Universal data format (CanonicalFixture, CanonicalMarket, CanonicalOrder, etc.) — all adapters map to/from these |
| Plug-in Adapters | Each provider (Betfair, Pinnacle) implements the same interfaces; adapters handle API-specific logic + mapping |
| Capability-Based Routing | RoutingStrategy picks the right provider per sport/feature; SportProviderConfig declares primary + fallbacks |
| Graceful Degradation | FailoverManager 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)
| File | Purpose |
|---|---|
backend/src/exchanges/core/interfaces/IExchangeAdapter.ts | Main adapter interface — extends IOddsProvider + IOrderProvider |
backend/src/exchanges/core/interfaces/IOddsProvider.ts | getFixtures(), getMarkets(), getOdds(), getFixture(), supportsSport() |
backend/src/exchanges/core/interfaces/IOrderProvider.ts | placeOrder(), cancelOrder(), getOrder(), getOrders(), getBetslip() |
backend/src/exchanges/core/interfaces/IStreamProvider.ts | subscribeMarket(), subscribeOrders(), connect(), disconnect() |
backend/src/exchanges/core/models/canonical.ts | All canonical types |
backend/src/exchanges/core/models/capability.ts | ProviderCapability, ProviderFeatures, RoutingConfig |
backend/src/exchanges/core/models/events.ts | Stream event types |
backend/src/exchanges/core/coordinator/ExchangeCoordinator.ts | Main orchestrator — singleton exchangeCoordinator |
backend/src/exchanges/core/coordinator/RoutingStrategy.ts | Sport → provider routing — singleton routingStrategy |
backend/src/exchanges/core/coordinator/FailoverManager.ts | Circuit breaker — singleton failoverManager |
backend/src/exchanges/core/registry/ProviderRegistry.ts | Adapter registry — register(), get(), getForSport() |
backend/src/exchanges/index.ts | PAL bootstrap — initializePAL(), registers adapters, configures routing |
backend/src/exchanges/adapters/betfair/BetfairAdapter.ts | Reference adapter implementation |
backend/src/exchanges/adapters/betfair/mappers/BetfairMapper.ts | Reference 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
| Method | Params | Returns | Notes |
|---|---|---|---|
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 + sport | CanonicalFixture | null | Single fixture |
supportsSport(sportId) | Hannibal sportId | boolean |
IOrderProvider
| Method | Params | Returns | Notes |
|---|---|---|---|
placeOrder(params) | { userId, fixtureId, marketId, outcomeId, side, stake, odds, sportId } | CanonicalOrder | |
cancelOrder(params) | { orderId, userId } | CanonicalOrder | Pinnacle: not supported |
getOrder(params) | { orderId, userId } | CanonicalOrder | null | |
getOrders(params) | { userId, status?, dateFrom?, dateTo? } | CanonicalOrder[] | |
getBetslip(params) | { fixtureId, marketId, outcomeId, sportId } | betslip info | Pre-bet validation |
supportsOrdersForSport(sportId) | boolean |
3. Canonical Models Deep-Dive
CanonicalFixture
| Field | Type | Description |
|---|---|---|
id | string | Hannibal internal ID |
externalIds | ExternalIds | Provider-specific IDs (see ExternalIds section) |
sportId | number | Hannibal sport ID |
homeTeam | string | |
awayTeam | string | |
startTime | Date | |
status | 'not_started' | 'live' | 'finished' | 'suspended' | 'cancelled' | |
markets | CanonicalMarket[] | Optional — populated when fetching with markets |
competitionId | string | Optional |
competitionName | string | Optional |
CanonicalMarket
| Field | Type | Description |
|---|---|---|
id | string | Hannibal internal market ID |
fixtureId | string | Parent fixture |
externalIds | ExternalIds | |
type | MarketType | See below |
name | string | Human-readable name |
handicap | number | For spread/total markets |
period | string | Optional — "full_time", "first_half", etc. |
status | 'open' | 'closed' | 'suspended' | |
outcomes | CanonicalOutcome[] |
MarketType enum
'match_odds' | 'over_under' | 'asian_handicap' | 'spread' |
'moneyline' | 'both_teams_score' | 'correct_score' |
'player_prop' | 'outright' | 'other'
CanonicalOutcome
| Field | Type | Description |
|---|---|---|
id | string | |
externalIds | ExternalIds | |
name | string | e.g., "Home", "Away", "Over 2.5" |
price | CanonicalPrice | |
active | boolean |
CanonicalPrice
| Field | Type | Betfair Maps To | Pinnacle Maps To |
|---|---|---|---|
back | number? | availableToBack[0].price | moneyline/spread/total price (decimal) |
lay | number? | availableToLay[0].price | undefined (no lay) |
backDepth | PriceSize[]? | Up to 3 levels of back depth | undefined (single price) |
layDepth | PriceSize[]? | Up to 3 levels of lay depth | undefined |
limit | number? | Not set | maxMoneyline / maxSpread / maxTotal |
limitMin | number? | Not set | minRiskStake from /v2/line |
changedAt | Date |
CanonicalOrder
| Field | Type | Description |
|---|---|---|
id | string | Hannibal order ID |
userId | string | |
status | OrderStatus | See status mapping table |
side | 'back' | 'lay' | Pinnacle: always 'back' |
requestedStake | number | |
requestedOdds | number | |
matchedStake | number? | |
matchedOdds | number? | |
provider | string | 'betfair' or 'pinnacle' |
providerOrderId | string | Betfair betId or Pinnacle betId |
settlementResult | SettlementResult? |
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
| Feature | Type | Betfair | Pinnacle |
|---|---|---|---|
layBetting | boolean | true | false |
inPlay | boolean | true | true (with PENDING_ACCEPTANCE) |
streaming | boolean | true | false |
cashOut | boolean | true | false |
partialMatching | boolean | true | false |
betEditing | boolean | false | false |
cancellation | boolean | true | false |
accountBalance | boolean | true | true |
orderStatus | boolean | true | true |
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:
| SportId | Sport | Primary | Fallbacks |
|---|---|---|---|
| 27 | Cricket | betfair | ['pinnacle'] |
| 10 | Soccer | pinnacle | ['betfair'] |
| 1 | Basketball | pinnacle | ['betfair'] |
| 6 | Tennis | pinnacle | ['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 SportId | Sport | Betfair eventTypeId | Pinnacle sportId |
|---|---|---|---|
| 10 | Soccer | "1" | 29 |
| 27 | Cricket | "4" | TBD (confirm via /v3/sports) |
| 1 | Basketball | "7522" | 4 |
| 6 | Tennis | "2" | 33 |
| 3 | American Football | "6423" | 15 |
| 4 | Ice Hockey | "7524" | TBD |
| 13 | Horse 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 callsPINNACLE_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_ODDS→match_odds- Contains "Over/Under" or "Goals" →
over_under ASIAN_HANDICAP→asian_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 Field | Canonical MarketType | Canonical Market ID Pattern | Canonical Market Name |
|---|---|---|---|
moneyline: {home, away, draw} | match_odds | pin_{eventId}_p{period}_ml | "Match Odds" / "1st Half Match Odds" |
spreads[0]: {hdp, home, away} | spread | pin_{eventId}_p{period}_spread_{hdp} | "Spread -1.5" |
spreads[1+] (alt lines) | spread | pin_{eventId}_p{period}_spread_{hdp}_alt | "Alt Spread +0.5" |
totals[0]: {points, over, under} | over_under | pin_{eventId}_p{period}_ou_{points} | "Over/Under 2.5" |
totals[1+] (alt lines) | over_under | pin_{eventId}_p{period}_ou_{points}_alt | "Alt Over/Under 3.0" |
teamTotal.home[0] | over_under | pin_{eventId}_p{period}_tt_home_{points} | "Team Total Home O/U 1.5" |
teamTotal.away[0] | over_under | pin_{eventId}_p{period}_tt_away_{points} | "Team Total Away O/U 1.5" |
Summary Table
| Dimension | Betfair | Pinnacle |
|---|---|---|
| Market identity | Each market type = separate marketId | Market types implicit in period (moneyline, spreads, totals) |
| Discovery | listMarketCatalogue returns individual markets | /v4/odds returns ALL types nested in each period |
| Type detection | Heuristic string parsing | Explicit fields |
| Canonical mapping | 1:1 | 1:N (one period → many CanonicalMarkets) |
| Alt lines | Different marketId per line | altLineId within same period |
| Handicap value | In market name/type | hdp 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.
| Sport | Period 0 | Period 1 | Period 2 | Period 3+ |
|---|---|---|---|---|
| Soccer (29) | Full Match | 1st Half | 2nd Half | Extra Time (3) |
| Basketball (4) | Full Game | 1st Half | 2nd Half | 1Q (3), 2Q (4), 3Q (5), 4Q (6) |
| Tennis (33) | Full Match | 1st Set | 2nd Set | 3rd Set (3), 4th (4), 5th (5) |
Period → CanonicalMarket.period Mapping
Pinnacle periodNumber | CanonicalMarket 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)cutoffdate (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
| Concept | Pinnacle lineId | Betfair |
|---|---|---|
| What it represents | Snapshot of odds at a point in time | No equivalent — you set your own price |
| Who sets price | Pinnacle (the house) | Other bettors (P2P exchange) |
| Staleness | Invalid when odds move → LINE_CHANGED error | N/A — your order sits at your price until matched |
| Required for bet placement | Yes — must come from /v2/line or /v4/odds | No — you pass desired price directly |
| If stale | Bet rejected, must re-fetch | Order stays unmatched (EXECUTABLE status) |
altLineId | For non-primary handicap/total lines | Different 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
| Aspect | Betfair | Pinnacle |
|---|---|---|
| Model | P2P exchange — time-based delay queue | Bookmaker — hold-and-decide |
| Mechanism | Order enters delay (1-12s per sport) → if market suspends during delay, order lapses → otherwise enters order book | Bet enters PENDING_ACCEPTANCE → Pinnacle reviews → ACCEPTED or NOT_ACCEPTED |
| betId returned? | Always returned immediately | Not returned during PENDING_ACCEPTANCE |
| Max wait | 12 seconds (sport-dependent) | Up to ~30 seconds (undocumented) |
| User experience | Brief delay, then matched/unmatched in book | Show "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:
| Value | Meaning |
|---|---|
| 0 | None — no live betting |
| 1 | Danger Zone only — bets accepted with extra delay around dangerous moments |
| 2 | Live Delay — standard delay |
| 3 | Both — 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
| Aspect | Betfair Streaming | Pinnacle since |
|---|---|---|
| Protocol | TLS socket, push-based | HTTP polling, pull-based |
| Latency | ~50-150ms | Up to polling interval (recommend 5-10s for live) |
| Delta mechanism | MCM with runner deltas (atb, atl, ltp) | since=<last> returns only changed items |
| Clock/cursor | Internal clk token (client doesn't manage) | last field (int64) — must cache and pass back |
| Reconnection | Re-subscribe, get full image (img: true) | Call without since for full snapshot |
| Bandwidth | Minimal deltas | Full objects for changed items |
Implementation for Pinnacle
PinnacleAdapter needs to:
- Store
lastcursors per sport (in Redis or in-memory cache) - Poll on interval (5-10s for live events, 30-60s for pre-match)
- On first call or cache miss → omit
since→ get full snapshot - On subsequent calls → include
since=<cached_last>→ get deltas only - 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:
| Entity | Pattern | Example |
|---|---|---|
| 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
| Step | Betfair | Pinnacle |
|---|---|---|
| 1. Auth | SSL cert + session token + appKey (header) | HTTP Basic Auth (every request) |
| 2. Find event | listMarketCatalogue({ filter: { eventTypeIds, ... } }) → marketId, selectionId | GET /v3/fixtures?sportId=X → eventId |
| 3. Get price | listMarketBook({ marketIds }) → availableToBack/Lay (or via streaming) | GET /v4/odds?sportId=X&eventIds=Y → lineId, prices |
| 4. Pre-bet validation | getBetslip() → checks market status, returns best available price | GET /v2/line?lineId=X&... → validates line, returns fresh price, min/maxRiskStake, min/maxWinStake |
| 5. Place bet | placeOrders({ marketId, instructions: [{ selectionId, price, size, side }] }) | POST /v4/bets/place({ sportId, eventId, periodNumber, betType, lineId, stake, winRiskStake, team/side }) |
| 6. Response | Synchronous: EXECUTABLE (in book) or EXECUTION_COMPLETE (matched) | ACCEPTED, PENDING_ACCEPTANCE, or PROCESSED_WITH_ERROR |
| 7. Post-placement | Stream subscription for order updates; orderSyncJob polls every 30s | Poll GET /v3/bets?uniqueRequestIds=X for PENDING_ACCEPTANCE resolution |
| 8. Settlement | checkSettledOrders() polls listClearedOrders every 60s | New 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
| Mode | Meaning | Example (odds 2.5, stake 100) |
|---|---|---|
RISK | Stake = amount wagered | Risk: $100, Win: $150 (profit), Total return: $250 |
WIN | Stake = desired profit | Risk: $66.67, Win: $100 (profit), Total return: $166.67 |
Betfair always uses risk-based size. PAL should always use RISK for consistency.
fillType
| Type | Behavior |
|---|---|
NORMAL | Reject if stake exceeds limit |
FILLANDKILL | Place at max limit if stake exceeds, reject remainder |
FILLMAXLIMIT | Ignore stake, always bet the maximum allowed |
Default recommendation: NORMAL for standard bets.
13. Settlement & Bet Status Mapping
Bet Status — Complete State Map
| Pinnacle Status | Pinnacle betStatus2 | Betfair Equivalent | Canonical OrderStatus | Canonical SettlementResult |
|---|---|---|---|---|
ACCEPTED | — | EXECUTION_COMPLETE | accepted | — |
PENDING_ACCEPTANCE | — | N/A (delay queue) | submitted | — |
NOT_ACCEPTED | — | N/A | declined | — |
PROCESSED_WITH_ERROR | — | status !== SUCCESS | declined | — |
WON | WON | betOutcome: WON | settled | win |
LOSE | LOST | betOutcome: LOST | settled | lose |
CANCELLED | CANCELLED | REMOVED | settled | void |
REFUNDED | REFUNDED | N/A | settled | void |
| — | HALF_WON_HALF_PUSHED | N/A | settled | half_win |
| — | HALF_LOST_HALF_PUSHED | N/A | settled | half_lose |
| N/A | N/A | EXECUTABLE | submitted | — |
| N/A | N/A | EXPIRED / LAPSED | lapsed | — |
Note: Pinnacle has betStatus (basic) and betStatus2 (extended with half-win/half-lose). Always prefer betStatus2 for settlement mapping.
Settlement Polling Comparison
| Aspect | Betfair (orderSyncJob.ts) | Pinnacle (planned) |
|---|---|---|
| Job name | checkSettledOrders() | checkPinnacleSettledOrders() |
| Interval | 60 seconds | 60 seconds |
| Detection endpoint | listClearedOrders({ betStatus: 'SETTLED' }) | GET /v3/fixtures/settled?sportId=X&since=Y |
| Bet details | getMarketResult() for market-level results | GET /v3/bets?betlist=SETTLED&fromDate=X |
| Score data | Runner WINNER/LOSER status | team1Score, team2Score in settlement response |
| Re-settlement | N/A | Status 2 = re-settled (new settlementId) |
| Filter | routingVenue: 'betfair' orders only | routingVenue: 'pinnacle' orders only |
Settlement Status Values (from fixtures/settled)
| Status | Meaning | Action |
|---|---|---|
| 1 | Settled | Process settlement |
| 2 | Re-settled | Re-process with new settlementId, reverse previous |
| 3 | Cancelled | Void the bet |
| 4 | Re-settled as cancelled | Reverse previous settlement, void |
| 5 | Deleted | Treat as void |
14. Bet Types — Straight, Parlay, Teaser, Special
Overview
| Bet Type | Description | Pinnacle Support | Betfair Support | PAL MVP? |
|---|---|---|---|---|
| Straight | Single bet on one outcome | Yes — /v4/bets/place | Yes — placeOrders | Yes |
| Parlay | Multi-event combo — all legs must win | Yes — /v4/bets/parlay | No (exchange model doesn't support) | No |
| Teaser | Adjusted spreads/totals in combo — all must win | Yes — /v4/bets/teaser | No | No |
| Special | Props, futures, outrights | Yes — /v4/bets/special | Yes (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:
betType | Market | team field | side field | handicap field |
|---|---|---|---|---|
MONEYLINE | Match result | TEAM1, TEAM2, DRAW | N/A | N/A |
SPREAD | Handicap | TEAM1, TEAM2 | N/A | Yes (e.g., -1.5) |
TOTAL_POINTS | Over/Under | N/A | OVER, UNDER | Yes (e.g., 2.5) |
TEAM_TOTAL_POINTS | Team total O/U | TEAM1, TEAM2 | OVER, UNDER | Yes |
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+contestantIdinstead ofeventId+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
| Method | Path | Required Params | Returns | Notes |
|---|---|---|---|---|
| GET | /v3/fixtures | sportId | FixturesResponseV3 | Non-settled events. Optional: leagueIds, isLive, since, eventIds |
| GET | /v2/fixtures/special | sportId | SpecialsFixturesResponseV2 | Non-settled specials |
| GET | /v3/fixtures/settled | sportId | SettledFixturesSportV3 | Settled last 24h with scores |
| GET | /v3/fixtures/special/settled | sportId | SettledSpecialsResponseV3 | Settled specials last 24h |
| GET | /v4/odds | sportId | OddsResponseV4 | Straight odds. Optional: leagueIds, oddsFormat, since, isLive, eventIds, toCurrencyCode |
| GET | /v4/odds/parlay | sportId | ParlayOddsResponseV4 | Parlay-eligible odds |
| GET | /v1/odds/teaser | teaserId | TeaserOddsResponse | Teaser odds |
| GET | /v2/odds/special | sportId | SpecialOddsResponseV2 | Special/prop odds |
| GET | /v2/line | leagueId, handicap, oddsFormat, sportId, eventId, periodNumber, betType | LineResponseV2 | Pre-bet line validation |
| POST | /v2/line/parlay | body: ParlayLinesRequestV2 | ParlayLinesResponseV2 | Validate parlay lines |
| GET | /v2/line/special | oddsFormat, specialId, contestantId | SpecialLineResponse | Special line |
| GET | /v3/sports | none | SportsResponseV3 | All sports with event counts |
| GET | /v3/leagues | sportId | LeaguesV3 | Leagues for a sport |
| GET | /v1/periods | sportId | SportPeriod | Period definitions per sport |
| GET | /v2/inrunning | none | InRunningResponse | Live events with game states |
| GET | /v1/teaser/groups | oddsFormat | TeaserGroupsResponse | Teaser group definitions |
| GET | /v1/cancellationreasons | none | CancellationReasonResponse | Cancellation codes |
| GET | /v2/currencies | none | SuccessfulCurrenciesResponse | Supported currencies |
Bets API
| Method | Path | Required Params | Returns | Notes |
|---|---|---|---|---|
| POST | /v4/bets/place | body: PlaceBetRequestV2 | PlaceBetResponseV4 | Place straight bet |
| POST | /v4/bets/parlay | body: PlaceParlayBetRequest | PlaceParlayBetResponseV4 | Place parlay |
| POST | /v4/bets/teaser | body: PlaceTeaserBetRequest | PlaceTeaserBetResponseV4 | Place teaser |
| POST | /v4/bets/special | body: MultiBetRequest[SpecialBetRequest] | MultiBetResponse[SpecialBetResponseV4] | Place special |
| GET | /v3/bets | varies by query type | GetBetsByTypeResponseV3 | Get bets by type/date/IDs |
| GET | /v1/bets/betting-status | none | BettingStatusResponse | System betting status |
GET /v3/bets Query Modes
| Parameter Set | Usage |
|---|---|
betlist=RUNNING | All unsettled bets |
betlist=SETTLED&fromDate=X&toDate=Y | Settled bets in date range |
betIds=1,2,3 | Specific bets by ID |
uniqueRequestIds=uuid1,uuid2 | Track PENDING_ACCEPTANCE bets |
16. Pinnacle Data Models
FixtureV3
| Field | Type | Description |
|---|---|---|
id | int64 | Unique event ID |
parentId | int64? | Linked event (live event has pre-game as parent) |
starts | date-time | Event start time UTC |
home | string | Home team name |
away | string | Away team name |
rotNum | string | Rotation number (deprecated) |
liveStatus | int32 | 0=No live, 1=Currently live, 2=Will offer live |
status | string | O=Open, H=Halted, I=Red circle (deprecated) |
betAcceptanceType | int32 | Soccer: 0=None, 1=Danger, 2=Delay, 3=Both |
parlayRestriction | int32 | 0=Unrestricted, 1=No parlay, 2=One leg/event |
altTeaser | boolean | Alt teaser points offered |
resultingUnit | string? | e.g., "Corners" |
version | int64 | Increments on change |
FixtureV3 → CanonicalFixture Mapping
| Pinnacle Field | Canonical Field | Transform |
|---|---|---|
id | externalIds.pinnacle | String(id) |
| Generated | id | "pin_" + id |
sportId (from API call) | sportId | PINNACLE_TO_HANNIBAL_SPORT[sportId] |
home | homeTeam | Direct |
away | awayTeam | Direct |
starts | startTime | new Date(starts) |
liveStatus + status | status | See status mapping below |
Status mapping:
| liveStatus | status | CanonicalFixture.status |
|---|---|---|
| 1 | O | 'live' |
| 0 or 2 | O | 'not_started' |
| any | H | 'suspended' |
| any | I | '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
| Field | Type | Description |
|---|---|---|
status | string | SUCCESS or NOT_EXISTS |
price | double | Latest price (may differ from odds response) |
lineId | int64 | Current line ID (may differ from cached) |
altLineId | int64? | If alternate line |
maxRiskStake | double | Max risk-based stake |
minRiskStake | double | Min risk-based stake |
maxWinStake | double | Max win-based stake |
minWinStake | double | Min win-based stake |
PlaceBetResponseV4
| Field | Type | Description |
|---|---|---|
status | string | ACCEPTED, PENDING_ACCEPTANCE, PROCESSED_WITH_ERROR |
errorCode | string? | See error codes table |
betId | int64? | Only present when ACCEPTED |
uniqueRequestId | UUID | Echo back for tracking |
win | double | Win amount |
risk | double | Risk amount |
price | double | Accepted price |
betterLineWasAccepted | boolean | If acceptBetterLine triggered |
StraightBetV3 (from GET /v3/bets)
| Field | Type | Description |
|---|---|---|
betId | int64 | Bet ID |
uniqueRequestId | UUID | Idempotency key |
wagerNumber | int32 | Wager sequence |
placedAt | date-time | Placement time |
betStatus | enum | ACCEPTED, CANCELLED, LOSE, PENDING_ACCEPTANCE, REFUNDED, NOT_ACCEPTED, WON, REJECTED |
betStatus2 | enum | Extended: adds LOST, HALF_WON_HALF_PUSHED, HALF_LOST_HALF_PUSHED |
betType | enum | MONEYLINE, SPREAD, TOTAL_POINTS, TEAM_TOTAL_POINTS, SPECIAL, PARLAY, TEASER |
win | double | Win amount |
risk | double | Risk amount |
winLoss | double? | P&L for settled bets |
oddsFormat | enum | Format odds were placed in |
customerCommission | double? | If applicable |
price | double | Price at placement |
teamName | string? | Wagered team |
isLive | boolean | Was live when placed |
sportId | int32 | |
leagueId | int64 | |
eventId | int64 | |
handicap | double? | |
periodNumber | int32 | |
team1, team2 | string | Team names |
team1Score, team2Score | double? | Current/final scores |
ftTeam1Score, ftTeam2Score | double? | Full-time scores |
cancellationReason | object? | If cancelled |
updateSequence | int64 | Version for updates |
17. Pinnacle Error Codes
| Error Code | Description | Recommended Action |
|---|---|---|
ALL_BETTING_CLOSED | All betting closed on platform | Retry later |
ALL_LIVE_BETTING_CLOSED | Live betting closed | Retry later |
ABOVE_EVENT_MAX | Stake exceeds event maximum | Reduce stake |
ABOVE_MAX_BET_AMOUNT | Stake exceeds max bet | Reduce stake |
BELOW_MIN_BET_AMOUNT | Stake below minimum | Increase stake |
BLOCKED_BETTING | Betting blocked for event | Skip event |
BLOCKED_CLIENT | Account blocked | Alert admin |
INSUFFICIENT_FUNDS | Not enough balance | Check/top up balance |
INVALID_COUNTRY | Country restriction | Alert admin |
INVALID_EVENT | Event not found/closed | Refresh fixtures |
INVALID_ODDS_FORMAT | Bad odds format | Fix request |
LINE_CHANGED | Odds moved since lineId | Re-fetch line, retry |
LISTED_PITCHERS_SELECTION_ERROR | Baseball pitchers issue | N/A for soccer/basketball/tennis |
OFFLINE_EVENT | Event offline | Skip or retry |
PAST_CUTOFFTIME | Past wagering cutoff | Cannot bet |
RED_CARDS_CHANGED | Red card event (soccer) | Re-fetch line, retry |
SCORE_CHANGED | Score changed during placement | Re-fetch line, retry |
DUPLICATE_UNIQUE_REQUEST_ID | Duplicate idempotency key | Check existing bet |
INCOMPLETE_CUSTOMER_BETTING_PROFILE | Account setup incomplete | Alert admin |
INVALID_CUSTOMER_PROFILE | Account issue | Alert admin |
LIMITS_CONFIGURATION_ISSUE | System config issue | Retry later |
RESPONSIBLE_BETTING_LOSS_LIMIT_EXCEEDED | Loss limit hit | Cannot bet |
RESPONSIBLE_BETTING_RISK_LIMIT_EXCEEDED | Risk limit hit | Cannot bet |
RESUBMIT_REQUEST | Transient error | Auto-retry (once) |
SYSTEM_ERROR_3 | System error | Retry with backoff |
LICENCE_RESTRICTION_LIVE_BETTING_BLOCKED | License blocks live | Skip live bets |
INVALID_HANDICAP | Bad handicap value | Fix request |
BETTING_SUSPENDED | Betting suspended | Retry later |
Retryable vs Terminal Errors
| Category | Error Codes |
|---|---|
| Auto-retry (once) | RESUBMIT_REQUEST, SYSTEM_ERROR_3, LINE_CHANGED, SCORE_CHANGED, RED_CARDS_CHANGED |
| Reduce and retry | ABOVE_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 retry | ALL_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 → BetfaireventTypeId)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.nameon/\s+v(?:s)?\s+/iregex to get home/away
Market type detection (BetfairMapper.ts lines 92-136):
- Parses
marketTypestring +marketNamestring MATCH_ODDS→match_odds,ASIAN_HANDICAP→asian_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 withroutingVenue: 'betfair'→ calllistClearedOrders→ settle viasettlementService
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()