<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: orbistats</title>
    <description>The latest articles on DEV Community by orbistats (@orbistats).</description>
    <link>https://dev.to/orbistats</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4128592%2F14967761-af2b-4040-8ef3-7763e7f4e21b.png</url>
      <title>DEV Community: orbistats</title>
      <link>https://dev.to/orbistats</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/orbistats"/>
    <language>en</language>
    <item>
      <title>Top 10 Sports Data Providers in 2026: The Complete Ranked Guide</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:05:33 +0000</pubDate>
      <link>https://dev.to/orbistats/top-10-sports-data-providers-in-2026-the-complete-ranked-guide-1f58</link>
      <guid>https://dev.to/orbistats/top-10-sports-data-providers-in-2026-the-complete-ranked-guide-1f58</guid>
      <description>&lt;p&gt;Choosing a sports data provider feels like a small decision in week one. By month six it decides your infrastructure bill, your data quality, your legal exposure, and how many weekends your team spends parsing weird edge cases at 2 a.m. on a match day.&lt;/p&gt;

&lt;p&gt;The market in 2026 is also more confusing than it’s ever been. There are official-data giants that sell licensed feeds to sportsbooks and leagues. There are developer-first APIs you can start using in five minutes with a credit card. There are odds-only aggregators, football-only specialists, and multi-sport platforms that try to do everything behind one schema. They all call themselves “the best.” They’re all best at something different.&lt;/p&gt;

&lt;p&gt;So instead of one more “best API” list written by someone who never integrated any of them, this guide ranks 10 sports data providers against the same criteria, tells you who each one is genuinely for, and — importantly — tells you where each one will hurt you. At the end you’ll also get a copy-paste benchmark script and a provider-agnostic adapter pattern so you can test any shortlist yourself in an afternoon.&lt;/p&gt;

&lt;p&gt;TL;DR — the ranking at a glance&lt;/p&gt;

&lt;h1&gt;
  
  
  Provider    Best for    Delivery    Entry point
&lt;/h1&gt;

&lt;p&gt;1   Sportradar  Licensed, enterprise-grade multi-sport data REST + push feeds   30-day free trial, then sales&lt;br&gt;
2   Genius Sports (Betgenius)   Regulated sportsbooks needing official data Feeds / APIs    Licensing + sales&lt;br&gt;
3   Stats Perform (Opta)    Deep football/event data, in-play betting, media    Feeds + APIs    Demo / quote&lt;br&gt;
4   SportsDataIO    US sports depth, fantasy, props REST    Free trial (scrambled data)&lt;br&gt;
5   Sportmonks  Football-first apps with broad league coverage  REST    Free plan + 14-day trial&lt;br&gt;
6   Orbistats   13 sports behind one schema, REST + WebSocket + webhooks    REST, WebSocket, webhooks, widgets  Free 1-hour access, paid from €19&lt;br&gt;
7   API-Sports  Prototypes and MVPs on a tight budget   REST    Free tier (100 req/day)&lt;br&gt;
8   The Odds API    Simple odds comparison, hobby and indie projects    REST    Free 500 credits/month&lt;br&gt;
9   Odds-API.io Bookmaker breadth, odds screeners   REST + WebSocket    Free tier, then paid&lt;br&gt;
10  LSports Betting operators wanting many sports and books Feeds / APIs    Quote-based&lt;/p&gt;

&lt;p&gt;Rankings are about fit for developer and product teams in 2026, not about which company is biggest. A giant that you can’t legally or financially use is not the best provider for you.&lt;/p&gt;

&lt;p&gt;How we ranked them&lt;/p&gt;

&lt;p&gt;Every provider was judged on the same six questions:&lt;/p&gt;

&lt;p&gt;Coverage — how many sports, leagues and data types (fixtures, live events, stats, lineups, odds, history)?&lt;br&gt;
Delivery &amp;amp; speed — REST only, or also WebSocket, push feeds and webhooks? What do they claim about latency?&lt;br&gt;
Data licensing — is the data official or licensed for betting use, or is it “mainstream coverage”?&lt;br&gt;
Pricing clarity — can you see a number without talking to sales?&lt;br&gt;
Developer experience — docs, sandbox, trial, SDKs, time-to-first-call.&lt;br&gt;
Fit for a team that isn’t an enterprise — can a small team realistically adopt it?&lt;/p&gt;

&lt;p&gt;No provider wins every category. The ranking blends all six, weighted toward breadth, reliability and licensing clarity, because those are the things that are hardest to fix after you’ve shipped.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sportradar — the enterprise standard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: sportsbooks, media companies and platforms that need licensed, deep, multi-sport data at scale.&lt;/p&gt;

&lt;p&gt;Sportradar is the name most procurement teams already know. Its Sports Data API markets coverage of 80+ sports, 500+ leagues and 750,000+ events a year, delivered as JSON or XML, with REST endpoints and push-based streaming feeds. It counts major US leagues such as the NBA, NFL, MLB, NHL and NASCAR among its official partners, and it offers both league-specific APIs (maximum depth for a single league) and general sport APIs (consistent structure across many leagues worldwide).&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Unmatched breadth and depth, including full play-by-play on major US leagues.&lt;br&gt;
A free 30-day trial and an API sandbox, so you can test before talking to sales.&lt;br&gt;
Extras beyond raw data: images, editorial content, odds and widgets.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Pricing is package- and sport-based and ends in a sales conversation.&lt;br&gt;
It’s a B2B service — calls are meant to come from your backend, never from a client app.&lt;br&gt;
Overkill (and over-budget) for a side project or an early MVP.&lt;/p&gt;

&lt;p&gt;Verdict: if licensing, depth and brand trust matter more than price, start here.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Genius Sports (Betgenius) — official data for regulated betting&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: licensed sportsbook operators in regulated markets.&lt;/p&gt;

&lt;p&gt;Genius Sports and its betting division Betgenius position themselves as an official betting data partner to more than 200 rights holders. The company became widely known after winning official live-data betting rights for English and Scottish football in 2019, and it works with governing bodies on the integrity side of the business. Commercial use of official data — including betting and predictive or fantasy games — typically requires a separate data licence.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Official, rights-holder-backed data is the safest legal foundation for a regulated sportsbook.&lt;br&gt;
Built around in-play betting products, not just stats display.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Not self-serve. Expect licensing, contracts and onboarding.&lt;br&gt;
Rights agreements change from cycle to cycle, so always confirm which competitions are currently covered.&lt;br&gt;
Not designed for developers who just want to experiment on a weekend.&lt;/p&gt;

&lt;p&gt;Verdict: the right call when “official data” is a legal or commercial requirement, the wrong call when it isn’t.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stats Perform (Opta) — depth, metrics and in-play&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: football-heavy products, media and broadcast, analytics-driven betting.&lt;/p&gt;

&lt;p&gt;Stats Perform sits behind the Opta brand that fans see on broadcasts, plus RunningBall, its ultra-fast in-play feed collected at the stadium by a global scouting network. It offers Opta data feeds, a Dynamic Stats API and betting-specific data including live expected-goals shot quality. It also announced a role as an official betting data and streaming rights distributor for FIFA tournaments.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Some of the richest event-level football data available commercially.&lt;br&gt;
Advanced metrics (xG and predictive layers) that are hard to rebuild yourself.&lt;br&gt;
Strong credentials in both media and betting.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Demo-and-quote model; there’s no “paste your card and go.”&lt;br&gt;
Premium pricing for premium depth — usually out of scope for small teams.&lt;/p&gt;

&lt;p&gt;Verdict: if your product is judged on the quality of the football analysis, Opta-grade data is worth the conversation.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SportsDataIO — the US-sports specialist&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: fantasy apps, US sports media, props and betting content built around NFL, NBA, MLB and NHL.&lt;/p&gt;

&lt;p&gt;SportsDataIO is deepest where American sports are concerned: scores, stats, play-by-play, injuries, lineups, depth charts, projections and betting data. Its leagues API spans 13 sports, including NFL, NBA, MLB, NHL, WNBA, college football and basketball, MMA, golf, NASCAR, F1 and soccer, with a separate Global API for far more leagues that is sold through sales.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Fantasy-friendly data: projections, salaries and slates alongside stats.&lt;br&gt;
A free trial with realistic-but-scrambled data, plus a free Replay feature that plays back real historical events in real time — genuinely useful for testing live logic in the off-season.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Pricing varies by sport and volume, so the bill grows with every league you add.&lt;br&gt;
Trial data is scrambled, so you can’t validate real accuracy until you pay.&lt;br&gt;
International coverage depends on the Global API and a sales conversation.&lt;/p&gt;

&lt;p&gt;Verdict: a top pick for US-centric products; less compelling if your users follow cricket, handball or European leagues.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sportmonks — football-first with broad league reach&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: football apps, fantasy platforms and live-score products that need many leagues without an enterprise contract.&lt;/p&gt;

&lt;p&gt;Sportmonks is a Dutch provider focused on football, with cricket and Formula 1 as additional products. It advertises 2,200–2,500+ football leagues, and its football plans are gated by how many leagues you pick rather than by features — mid-2026 listings show roughly €29/month for 5 leagues, €99 for 30 and €249 for 120, with enterprise pricing for everything. It also offers a free plan covering a couple of leagues and a 14-day trial on paid plans.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Very wide football league coverage, with xG, predictions and lineups available.&lt;br&gt;
Predictable cost if you only need a handful of competitions.&lt;br&gt;
Clean v3 REST design and human support.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;League-gated pricing gets complicated fast as you add competitions; odds and extras can be paid add-ons.&lt;br&gt;
Football is the main product — it’s not a 13-sport platform.&lt;/p&gt;

&lt;p&gt;Verdict: an excellent default if football is your product.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Orbistats — 13 sports, one schema, every delivery method&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: teams that want mainstream multi-sport coverage — including cricket, esports, combat sports and horse racing — behind one consistent API, and want REST, WebSocket, webhooks and drop-in widgets from the same account.&lt;/p&gt;

&lt;p&gt;This is us, so we’ll be both specific and candid. Orbistats covers 13 sports: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing. The platform is organised around a few products — the Sports Data API for fixtures, results, standings and teams; the Live Scores API for real-time events; the Odds API for pre-match and live odds normalised across bookmakers; and the Historical Data API built for backtesting. You can consume it via REST, the WebSocket API for persistent streaming, webhooks for push delivery, or embeddable widgets.&lt;/p&gt;

&lt;p&gt;What the pricing looks like. Orbistats sells access windows rather than monthly seat plans, and every tier includes all sports and endpoints. At the time of writing, the site lists Free (€0, 1 hour), Starter (€19, 24 hours), Growth (€79, 5 days) and Unlimited (€1,999/month, 30 days of unlimited usage). Your timer starts only when you open the activation link we email you after your access request is approved. See the live pricing page for current numbers.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Genuinely broad sport coverage on every tier, including sports many cheaper APIs skip.&lt;br&gt;
Four ways to consume the same feed, so you can start with REST and add streaming later without switching vendors.&lt;br&gt;
A public documentation, quickstart and sandbox so you can see real responses before you commit.&lt;/p&gt;

&lt;p&gt;Watch-outs (the honest list)&lt;/p&gt;

&lt;p&gt;We’re a younger provider with “mainstream coverage,” not a rights-holder-backed official data licensor. If your regulator or your contract demands official data, choose Sportradar or Genius Sports.&lt;br&gt;
Access is approval-based and time-windowed, which is great for sprints and evaluations, but you should confirm production terms and SLAs with the team before building a business on it.&lt;br&gt;
Our sub-50ms WebSocket figure is our own published claim. Treat it — and everyone else’s latency claim — as a hypothesis until you run the benchmark below.&lt;/p&gt;

&lt;p&gt;Verdict: a strong fit for startups, analytics teams and media products that want many sports and several delivery methods without an enterprise contract, and a poor fit if official-data licensing is non-negotiable. If cricket is your priority, start with the cricket coverage page.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;API-Sports — the cheapest way to start&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: prototypes, student projects, MVPs and apps where budget beats depth.&lt;/p&gt;

&lt;p&gt;API-Sports runs a family of single-sport APIs (API-Football, API-NBA and others). The free plan gives around 100 requests per day per API, and paid plans typically start in the low tens of dollars per month. It also offers free embeddable widgets.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Excellent free tier and the lowest cost of entry on this list.&lt;br&gt;
Large community, plenty of tutorials and wrappers.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Each sport is its own API and plan, so costs and integration work multiply as you add sports.&lt;br&gt;
Check statistics depth per league before launch; some lower leagues return fixtures but thinner match and player stats.&lt;br&gt;
Fine for an MVP, but test uptime and support carefully before you scale on it.&lt;/p&gt;

&lt;p&gt;Verdict: the best “learn it and ship a prototype this weekend” option.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Odds API — odds, simply&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: odds comparison pages, hobby tools and indie projects.&lt;/p&gt;

&lt;p&gt;The Odds API has been running since 2017 and focuses on one job: betting odds. It covers 70+ sports and 40+ bookmakers across the US, UK, EU and Australia, with a free tier (500 credits a month), historical odds snapshots back to 2020, and paid plans from about $30 up to roughly $249 a month on a credit-based model.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Clean, well-regarded documentation and a gentle pricing curve.&lt;br&gt;
Strong on US books and mainstream leagues.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;It’s odds only — no deep stats, lineups or play-by-play.&lt;br&gt;
Credit-based pricing needs watching when you poll frequently.&lt;br&gt;
Not aimed at long-tail or Asian-market bookmaker depth.&lt;/p&gt;

&lt;p&gt;Verdict: the safe, simple choice when odds are the entire product.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Odds-API.io — bookmaker breadth&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: odds screeners, arbitrage and value-betting tools, and comparison sites that need many bookmakers.&lt;/p&gt;

&lt;p&gt;Odds-API.io advertises 250+ bookmakers, thousands of leagues, pre-match and live odds, 100+ market types and both REST and WebSocket delivery. All paid plans share the same rate limit (5,000 requests per hour); tiers differ mainly by how many bookmakers you can access, and there’s a free tier with a limited bookmaker selection.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Wide bookmaker coverage, including sharper and Asian books.&lt;br&gt;
Simple, rate-limit-based pricing.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;Odds only — pair it with a stats provider if you need fixtures, lineups or events.&lt;br&gt;
Most of the bookmaker and league numbers are the vendor’s own, so verify the books you care about.&lt;/p&gt;

&lt;p&gt;Verdict: a good specialist for odds-heavy products.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;LSports — operator-grade odds and sports feeds&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Best for: betting operators and trading teams that want many sports and bookmakers through a single commercial relationship.&lt;/p&gt;

&lt;p&gt;LSports sells betting-oriented data products; its OddService is marketed as covering 100+ sports and bookmakers, with custom, quote-based pricing. It sits in the same “talk to sales” tier as the enterprise providers, but with a stronger odds-and-trading focus than a general sports-data focus.&lt;/p&gt;

&lt;p&gt;Strengths&lt;/p&gt;

&lt;p&gt;Built for operators and trading rather than simple display apps.&lt;br&gt;
Broad sport and market coverage.&lt;/p&gt;

&lt;p&gt;Watch-outs&lt;/p&gt;

&lt;p&gt;No public price list, and coverage claims need to be validated in a trial.&lt;br&gt;
Not the right shape for a small team that wants to self-serve.&lt;/p&gt;

&lt;p&gt;Verdict: worth a call if you’re running a betting product at volume and want an alternative to the biggest names.&lt;/p&gt;

&lt;p&gt;Which provider should you actually pick?&lt;/p&gt;

&lt;p&gt;Skip the ranking for a minute and match your situation:&lt;/p&gt;

&lt;p&gt;You’re a regulated sportsbook: start with Sportradar or Genius Sports and confirm official-data coverage for your leagues.&lt;br&gt;
You’re building a football product: shortlist Sportmonks and Stats Perform; use API-Sports to prototype.&lt;br&gt;
You’re building for US sports and fantasy: SportsDataIO first, Sportradar if you need more depth.&lt;br&gt;
You need many sports behind one schema, plus streaming: Sportradar at the high end, Orbistats if you want multi-sport coverage and four delivery methods without an enterprise contract.&lt;br&gt;
You only need odds: The Odds API for simplicity, Odds-API.io for bookmaker breadth, LSports for operator-scale.&lt;br&gt;
You’re just learning: API-Sports or a free tier from Sportmonks.&lt;/p&gt;

&lt;p&gt;If you’re comparing providers head-to-head, Orbistats also publishes a comparison hub that goes into more detail on specific alternatives.&lt;/p&gt;

&lt;p&gt;Test any provider yourself in 15 minutes&lt;/p&gt;

&lt;p&gt;Marketing pages tell you what a provider wants you to believe. A quick benchmark tells you what happens from your servers. This script (Node 20+, no dependencies) hits each provider’s live-match endpoint 30 times and reports success rate plus p50 and p95 round-trip latency:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// benchmark.mjs — run with: node benchmark.mjs&lt;br&gt;
const RUNS = 30;&lt;/p&gt;

&lt;p&gt;const PROVIDERS = [&lt;br&gt;
  {&lt;br&gt;
    name: "orbistats",&lt;br&gt;
    url: "&lt;a href="https://api.orbistats.com/v1/football/matches/live" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/matches/live&lt;/a&gt;",&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_KEY}&lt;/code&gt; },&lt;br&gt;
  },&lt;br&gt;
  // Replace the placeholders below with the endpoint + auth of each provider you're testing&lt;br&gt;
  {&lt;br&gt;
    name: "provider-b",&lt;br&gt;
    url: "&lt;a href="https://api.provider-b.example/live" rel="noopener noreferrer"&gt;https://api.provider-b.example/live&lt;/a&gt;",&lt;br&gt;
    headers: { "x-api-key": process.env.PROVIDER_B_KEY },&lt;br&gt;
  },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;const percentile = (values, p) =&amp;gt; {&lt;br&gt;
  const sorted = [...values].sort((a, b) =&amp;gt; a - b);&lt;br&gt;
  return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;async function probe({ name, url, headers }) {&lt;br&gt;
  const times = [];&lt;br&gt;
  let ok = 0;&lt;/p&gt;

&lt;p&gt;for (let i = 0; i &amp;lt; RUNS; i++) {&lt;br&gt;
    const start = performance.now();&lt;br&gt;
    try {&lt;br&gt;
      const res = await fetch(url, { headers });&lt;br&gt;
      await res.json();            // include parse time, like a real client&lt;br&gt;
      if (res.ok) ok++;&lt;br&gt;
    } catch {&lt;br&gt;
      /* network error counts as a failure */&lt;br&gt;
    }&lt;br&gt;
    times.push(performance.now() - start);&lt;br&gt;
    await new Promise((r) =&amp;gt; setTimeout(r, 500)); // be polite, avoid rate limits&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;return {&lt;br&gt;
    provider: name,&lt;br&gt;
    success: &lt;code&gt;${ok}/${RUNS}&lt;/code&gt;,&lt;br&gt;
    p50_ms: Math.round(percentile(times, 50)),&lt;br&gt;
    p95_ms: Math.round(percentile(times, 95)),&lt;br&gt;
  };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;console.table(await Promise.all(PROVIDERS.map(probe)));&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, this measures round-trip API latency from your machine, not the end-to-end delay between a goal being scored and the feed reporting it — for that you need to compare timestamps against a trusted live source during a real match. Second, run it from the region where your production servers will live, not from your laptop on café Wi-Fi.&lt;/p&gt;

&lt;p&gt;Don’t marry one provider: use an adapter&lt;/p&gt;

&lt;p&gt;Whichever provider you choose, the safest architecture is one that makes switching boring. Define your own match shape and write one tiny adapter per provider:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
// types.ts&lt;br&gt;
export interface LiveMatch {&lt;br&gt;
  id: string;&lt;br&gt;
  status: "scheduled" | "live" | "finished";&lt;br&gt;
  minute: number | null;&lt;br&gt;
  home: { name: string; score: number };&lt;br&gt;
  away: { name: string; score: number };&lt;br&gt;
  source: string;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// adapters/orbistats.ts — maps the documented live-match payload to our shape&lt;br&gt;
export const fromOrbistats = (raw: any): LiveMatch =&amp;gt; ({&lt;br&gt;
  id: raw.match_id,&lt;br&gt;
  status: raw.status,            // e.g. "live"&lt;br&gt;
  minute: raw.minute ?? null,&lt;br&gt;
  home: { name: raw.home.name, score: raw.home.score },&lt;br&gt;
  away: { name: raw.away.name, score: raw.away.score },&lt;br&gt;
  source: "orbistats",&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// adapters/providerB.ts — same output, different input&lt;br&gt;
export const fromProviderB = (raw: any): LiveMatch =&amp;gt; ({&lt;br&gt;
  id: String(raw.fixture.id),&lt;br&gt;
  status: raw.fixture.state === "FT" ? "finished" : "live",&lt;br&gt;
  minute: raw.fixture.elapsed ?? null,&lt;br&gt;
  home: { name: raw.teams.home, score: raw.goals.home },&lt;br&gt;
  away: { name: raw.teams.away, score: raw.goals.away },&lt;br&gt;
  source: "provider-b",&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Your UI, database and alerting only ever see LiveMatch. When you outgrow a provider — or need a second one as a fallback during a big match — you add an adapter instead of rewriting your app.&lt;/p&gt;

&lt;p&gt;If you’d rather skip building the normalisation layer yourself for odds and stats across many sports, that’s exactly the problem a multi-sport API with a single schema is meant to solve. The Orbistats quickstart shows the full flow from request to first response, and the sandbox lets you try endpoints before you commit.&lt;/p&gt;

&lt;p&gt;Seven questions to ask every provider before you sign&lt;br&gt;
Which exact leagues and competitions are covered, and at what coverage level (scores only, or full events and lineups)?&lt;br&gt;
Is the data official or licensed for betting use? If you’re regulated, get this in writing.&lt;br&gt;
What’s the real latency, and how is it measured — API response time or event-to-delivery time?&lt;br&gt;
What are the rate limits and what happens when you exceed them — throttling, overage fees or a blocked key?&lt;br&gt;
Do you get WebSocket or webhooks, or only polling? Polling a live match every few seconds burns quota fast.&lt;br&gt;
How deep and how clean is the history? Backtesting a model on patchy history gives you confident wrong answers.&lt;br&gt;
What does the exit look like? Can you export data, and how hard is it to switch if prices rise?&lt;br&gt;
Common mistakes teams make when choosing a data provider&lt;br&gt;
Buying coverage you’ll never use. A 90-sport feed is no help if 95% of your traffic is cricket and football.&lt;br&gt;
Ignoring licensing until launch. Official-data requirements can kill a product late in the game.&lt;br&gt;
Trusting the free tier as a preview of production. Scrambled trial data, small quotas and short access windows don’t always reflect real-world behaviour.&lt;br&gt;
Polling instead of streaming. For live data, a persistent connection or webhook is cheaper and faster than hammering REST endpoints.&lt;br&gt;
Skipping the abstraction layer. Hard-coding one vendor’s schema into your database is a bill you pay later.&lt;br&gt;
FAQ&lt;br&gt;
What is the best sports data provider in 2026?&lt;/p&gt;

&lt;p&gt;There isn’t one winner. Sportradar and Genius Sports lead on official, enterprise-grade data; Stats Perform leads on football depth; SportsDataIO leads on US sports; Sportmonks is a strong football-first choice; and API-Sports or The Odds API are the cheapest places to start. The best provider is the one that covers your sports with the licensing and latency your product needs.&lt;/p&gt;

&lt;p&gt;What’s the cheapest sports data API?&lt;/p&gt;

&lt;p&gt;For football and basketball prototypes, API-Sports and Sportmonks’ free plan are the usual starting points, and The Odds API has a free monthly credit allowance for odds. For multi-sport evaluation, Orbistats offers a free one-hour access window across all sports and endpoints, with paid windows starting at €19. “Cheapest” depends on volume, so model your real request count before deciding.&lt;/p&gt;

&lt;p&gt;Do I need official data for a betting app?&lt;/p&gt;

&lt;p&gt;If you operate as a licensed sportsbook in a regulated market, usually yes — or at least data sourced through rights-holder-approved channels. Providers such as Genius Sports and Sportradar specialise in this. For odds-comparison tools, fantasy apps and analytics, licensed-official data is often not required, but check your local regulations and your contracts.&lt;/p&gt;

&lt;p&gt;REST, WebSocket or webhooks — which should I use?&lt;/p&gt;

&lt;p&gt;Use REST for fixtures, standings, stats and history. Use WebSocket for live scores and odds when you need continuous updates in the browser or a trading service. Use webhooks when you’d rather have events pushed to your server than hold a connection open. Providers that offer all three, like Orbistats, let you mix them as your product grows.&lt;/p&gt;

&lt;p&gt;How should I compare providers fairly?&lt;/p&gt;

&lt;p&gt;Pick two or three candidates, define a fixed test (the same match, the same endpoints, the same region), run the benchmark script above, and compare coverage on your specific leagues — not the vendor’s headline numbers.&lt;/p&gt;

&lt;p&gt;Final thoughts&lt;/p&gt;

&lt;p&gt;The right sports data provider isn’t the biggest, the cheapest or the one with the loudest marketing page. It’s the one whose coverage, licensing, latency and pricing model match the product you’re actually building — and that you can replace without a rewrite when your needs change.&lt;/p&gt;

&lt;p&gt;If you want to see how a multi-sport, multi-delivery API feels in practice, request access to Orbistats, read the documentation, or open the live feed on the homepage and run your first request. And if you’ve used any of the providers above in production, tell us in the comments where we got it wrong — we’ll update this guide.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>backend</category>
      <category>datascience</category>
    </item>
    <item>
      <title>How to Evaluate a Sports Data API: A Developer’s Checklist (Benchmark Script Included)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 26 Oct 2026 04:25:43 +0000</pubDate>
      <link>https://dev.to/orbistats/how-to-evaluate-a-sports-data-api-a-developers-checklist-benchmark-script-included-27bd</link>
      <guid>https://dev.to/orbistats/how-to-evaluate-a-sports-data-api-a-developers-checklist-benchmark-script-included-27bd</guid>
      <description>&lt;p&gt;Disclosure: I work on Orbistats, a sports data API, and it is used as one example below. The checklist and scripts work with any provider.&lt;/p&gt;

&lt;p&gt;Picking a sports data API usually goes like this: you read ten landing pages that all say “real-time, reliable, comprehensive,” sign up for two or three, and choose whichever one you got working first. Six months later you discover the cricket data lags, the odds schema changed without notice, or the rate limits do not fit your traffic.&lt;/p&gt;

&lt;p&gt;This post gives you a repeatable way to compare providers using numbers instead of marketing. You will get:&lt;/p&gt;

&lt;p&gt;A checklist of what to evaluate and why&lt;br&gt;
A Python benchmark script that measures latency and reliability&lt;br&gt;
A provider-agnostic client so switching providers later is a config change, not a rewrite&lt;br&gt;
A results template you can fill in for your own shortlist&lt;br&gt;
Why free tiers are the right place to start&lt;/p&gt;

&lt;p&gt;Free tiers are the cheapest way to test real endpoints. But they differ a lot in shape, and that matters when you compare:&lt;/p&gt;

&lt;p&gt;Some give a daily request cap.&lt;br&gt;
Some give limited sports or endpoints.&lt;br&gt;
Some give a time-boxed window. Orbistats’ Free plan, for example, is 1 hour of access across all sports and endpoints with no card, and the timer starts only when you open the activation link.&lt;/p&gt;

&lt;p&gt;A 1-hour window means you should prepare your test script before you activate. The script below is built for that.&lt;/p&gt;

&lt;p&gt;Part 1: The developer’s checklist&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Coverage: sports, leagues and depth&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not just count sports. Check the exact leagues and markets you need. A provider can list “cricket” and still lack the domestic tournaments your users care about.&lt;/p&gt;

&lt;p&gt;Orbistats covers 13 sports, including football, cricket and tennis. Whichever provider you test, pick three leagues you actually need and check them one by one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Latency, measured from your own server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Marketing pages quote best-case numbers from the provider’s network. Measure from where your code runs. Track p50 and p95, not just the average, because p95 is what your users feel on a bad day.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reliability&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Over a fixed number of requests, what percentage succeed? Which errors appear (429, 500, timeouts)? Is there a public status page with history?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delivery methods&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;REST alone is fine for fixtures and standings. For live products, check whether the provider offers a WebSocket API for streaming and webhooks for push events, so you are not polling every second.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Schema quality and stability&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Look for consistent field names across sports, stable IDs, ISO timestamps and typed values. Then read the changelog to see how often things break. Also check the versioning policy: Orbistats uses /v1/ and moves breaking changes to a new version.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Odds normalization (if you need odds)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you consume prices from multiple bookmakers, check whether the Odds API normalizes formats and market names. Otherwise you will write that mapping layer yourself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Historical depth&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For modeling, check historical data for opening and closing odds, event data and how far back each sport goes. Verify coverage per sport rather than assuming it is uniform.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pricing transparency&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Can you see a number without a sales call? Does the plan structure match how you will actually use it (access window, request cap, monthly)? Compare against the pricing page and the provider comparison pages.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developer experience&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Time yourself: from signup to first successful response. A good quickstart, a sandbox and clear documentation can save days.&lt;/p&gt;

&lt;p&gt;Part 2: The benchmark script&lt;/p&gt;

&lt;p&gt;This script sends a fixed number of requests to any endpoint, measures latency, records status codes and checks that the response contains the fields you expect. It uses httpx and runs requests concurrently in small batches to avoid tripping rate limits.&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install httpx&lt;br&gt;
python&lt;br&gt;
import asyncio&lt;br&gt;
import statistics&lt;br&gt;
import time&lt;br&gt;
from collections import Counter&lt;br&gt;
from dataclasses import dataclass, field&lt;/p&gt;

&lt;p&gt;import httpx&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class Target:&lt;br&gt;
    name: str&lt;br&gt;
    url: str&lt;br&gt;
    headers: dict = field(default_factory=dict)&lt;br&gt;
    required_fields: list[str] = field(default_factory=list)&lt;/p&gt;

&lt;p&gt;def has_fields(payload, required: list[str]) -&amp;gt; bool:&lt;br&gt;
    """Check that required top-level keys exist (or exist in the first item of a list)."""&lt;br&gt;
    if isinstance(payload, list):&lt;br&gt;
        payload = payload[0] if payload else {}&lt;br&gt;
    if isinstance(payload, dict) and "data" in payload:&lt;br&gt;
        inner = payload["data"]&lt;br&gt;
        payload = inner[0] if isinstance(inner, list) and inner else inner&lt;br&gt;
    return isinstance(payload, dict) and all(k in payload for k in required)&lt;/p&gt;

&lt;p&gt;async def one_request(client: httpx.AsyncClient, target: Target):&lt;br&gt;
    start = time.perf_counter()&lt;br&gt;
    try:&lt;br&gt;
        r = await client.get(target.url, headers=target.headers, timeout=10)&lt;br&gt;
        elapsed_ms = (time.perf_counter() - start) * 1000&lt;br&gt;
        schema_ok = False&lt;br&gt;
        if r.status_code == 200:&lt;br&gt;
            try:&lt;br&gt;
                schema_ok = has_fields(r.json(), target.required_fields)&lt;br&gt;
            except ValueError:&lt;br&gt;
                pass&lt;br&gt;
        return elapsed_ms, r.status_code, schema_ok&lt;br&gt;
    except httpx.HTTPError:&lt;br&gt;
        return (time.perf_counter() - start) * 1000, "timeout/error", False&lt;/p&gt;

&lt;p&gt;async def benchmark(target: Target, total=50, batch=5, pause=0.5):&lt;br&gt;
    results = []&lt;br&gt;
    async with httpx.AsyncClient() as client:&lt;br&gt;
        for _ in range(total // batch):&lt;br&gt;
            results += await asyncio.gather(&lt;br&gt;
                *[one_request(client, target) for _ in range(batch)]&lt;br&gt;
            )&lt;br&gt;
            await asyncio.sleep(pause)  # be polite to free tiers&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;latencies = sorted(r[0] for r in results if r[1] == 200)
statuses = Counter(r[1] for r in results)
ok = statuses.get(200, 0)

def pct(p):
    return latencies[min(int(len(latencies) * p), len(latencies) - 1)] if latencies else None

return {
    "provider": target.name,
    "requests": total,
    "success_rate": round(100 * ok / total, 1),
    "p50_ms": round(pct(0.50), 1) if latencies else None,
    "p95_ms": round(pct(0.95), 1) if latencies else None,
    "mean_ms": round(statistics.mean(latencies), 1) if latencies else None,
    "schema_ok_pct": round(100 * sum(r[2] for r in results) / total, 1),
    "status_codes": dict(statuses),
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;async def main():&lt;br&gt;
    targets = [&lt;br&gt;
        Target(&lt;br&gt;
            name="Orbistats",&lt;br&gt;
            url="&lt;a href="https://api.orbistats.com/v1/football/matches/live" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/matches/live&lt;/a&gt;",&lt;br&gt;
            headers={"Authorization": "Bearer YOUR_API_KEY"},&lt;br&gt;
            required_fields=["match_id", "status"],&lt;br&gt;
        ),&lt;br&gt;
        # Add up to 9 more providers here, using each one's own URL and auth.&lt;br&gt;
    ]&lt;br&gt;
    for t in targets:&lt;br&gt;
        print(await benchmark(t))&lt;/p&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    asyncio.run(main())&lt;/p&gt;

&lt;p&gt;Notes on running it fairly:&lt;/p&gt;

&lt;p&gt;Run from the same machine and network for every provider.&lt;br&gt;
Run at different times of day, including during live matches, because latency under load is what matters.&lt;br&gt;
Use the same type of endpoint for each provider (for example, live football matches).&lt;br&gt;
Check each provider’s terms before benchmarking. Keep request counts modest on free tiers.&lt;br&gt;
Adjust required_fields to match each provider’s actual response shape. The Orbistats example uses match_id and status, which appear in the live response.&lt;br&gt;
Part 3: A provider-agnostic client&lt;/p&gt;

&lt;p&gt;Once you pick a provider, you do not want it to leak into your whole codebase. Define your own model and write one small adapter per provider.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from abc import ABC, abstractmethod&lt;br&gt;
from dataclasses import dataclass&lt;/p&gt;

&lt;p&gt;import httpx&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class LiveMatch:&lt;br&gt;
    match_id: str&lt;br&gt;
    status: str&lt;br&gt;
    minute: int | None&lt;br&gt;
    home_name: str&lt;br&gt;
    home_score: int&lt;br&gt;
    away_name: str&lt;br&gt;
    away_score: int&lt;/p&gt;

&lt;p&gt;class SportsProvider(ABC):&lt;br&gt;
    &lt;a class="mentioned-user" href="https://dev.to/abstractmethod"&gt;@abstractmethod&lt;/a&gt;&lt;br&gt;
    def live_matches(self, sport: str) -&amp;gt; list[LiveMatch]: ...&lt;/p&gt;

&lt;p&gt;class OrbistatsProvider(SportsProvider):&lt;br&gt;
    BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def __init__(self, api_key: str):
    self.client = httpx.Client(
        headers={"Authorization": f"Bearer {api_key}"}, timeout=10
    )

def live_matches(self, sport: str) -&amp;gt; list[LiveMatch]:
    r = self.client.get(f"{self.BASE}/{sport}/matches/live")
    r.raise_for_status()
    data = r.json()
    items = data if isinstance(data, list) else data.get("data", [data])
    return [
        LiveMatch(
            match_id=m["match_id"],
            status=m["status"],
            minute=m.get("minute"),
            home_name=m["home"]["name"],
            home_score=m["home"]["score"],
            away_name=m["away"]["name"],
            away_score=m["away"]["score"],
        )
        for m in items
    ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def get_provider(name: str, api_key: str) -&amp;gt; SportsProvider:&lt;br&gt;
    providers = {"orbistats": OrbistatsProvider}&lt;br&gt;
    return providers&lt;a href="https://dev.toapi_key"&gt;name&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To add another provider, write one more class that maps its response into LiveMatch. Your app only ever talks to SportsProvider, so switching is a one-line config change. It also lets you run two providers side by side and compare their output during a trial.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>sportsdata</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Evaluate a Sports Data API: A Developer’s Checklist (Checked Against 10 Providers)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:33:39 +0000</pubDate>
      <link>https://dev.to/orbistats/how-to-evaluate-a-sports-data-api-a-developers-checklist-checked-against-10-providers-4pd9</link>
      <guid>https://dev.to/orbistats/how-to-evaluate-a-sports-data-api-a-developers-checklist-checked-against-10-providers-4pd9</guid>
      <description>&lt;p&gt;Why a checklist beats a feature table&lt;/p&gt;

&lt;p&gt;Every sports data vendor has a feature table, and most of them look alike: live scores, odds, history, WebSocket. The differences that hurt you in production are not on the table. They are things like unstable IDs, inconsistent timestamps, a quota that counts differently from what you assumed, or a licence that forbids the exact use you planned.&lt;/p&gt;

&lt;p&gt;So this post gives you a checklist you can run against any provider, plus a small Python script that automates the checks that can be automated. Use it before you sign a contract, during a trial, and again every time you reconsider your vendor.&lt;/p&gt;

&lt;p&gt;Read this first: I work with Orbistats, which is one of the ten providers I reference. I have not run benchmarks, so there are no performance numbers here. Vendor facts come from public pages as of 12 October 2026 and may change, so verify them before relying on them.&lt;/p&gt;

&lt;p&gt;The ten providers this checklist was built around&lt;/p&gt;

&lt;p&gt;The list covers the whole market, from enterprise licences to hobby-friendly free tiers:&lt;/p&gt;

&lt;p&gt;Sportradar: enterprise, quote-based, claims 80+ sports and 500+ leagues.&lt;br&gt;
Stats Perform (Opta): premium analytics depth, tailored licensing.&lt;br&gt;
Genius Sports: official data and integrity, custom quote.&lt;br&gt;
Sportmonks: self-serve football, €29, €99 and €249 per month plans.&lt;br&gt;
SportsDataIO: US leagues and fantasy, product-specific pricing.&lt;br&gt;
Orbistats: 13 sports, odds, WebSocket, webhooks, history, pass-based pricing.&lt;br&gt;
OddsPapi: odds-focused, details unverified.&lt;br&gt;
SportsGameOdds: odds-first, billed by returned objects.&lt;br&gt;
API-Football: football only, billed by daily requests.&lt;br&gt;
The Odds API: odds, quota scoped by sport, market and region.&lt;br&gt;
How to use the checklist&lt;/p&gt;

&lt;p&gt;Score each question as pass, fail or unknown. Unknown is a legitimate answer: it means ask the vendor, in writing, before you commit. Weight the sections by your product. A sportsbook cares most about rights and latency, a hobby app about price and ease of start, a machine-learning team about history.&lt;/p&gt;

&lt;p&gt;Section 1: Coverage&lt;br&gt;
Does it cover every sport you need, today and in your next two roadmap quarters?&lt;br&gt;
Does it cover the exact leagues and competitions, not just the sport?&lt;br&gt;
Are lower divisions, youth and women’s competitions included if you need them?&lt;br&gt;
Is coverage depth the same for all leagues, or do some only have scores without events and stats?&lt;br&gt;
Is there a written coverage matrix you can keep, not just a marketing claim?&lt;/p&gt;

&lt;p&gt;Vendors describe coverage differently: Sportradar claims 80+ sports, Sportmonks advertises 2,200+ football leagues, API-Football lists 1,248 leagues and cups, and Orbistats lists 13 sports. These are company claims. A big number tells you little until you check your own competitions. Orbistats, for example, has a page per sport, such as football, cricket, tennis and basketball, and its thirteen sports are football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing.&lt;/p&gt;

&lt;p&gt;Section 2: Rights and licensing&lt;br&gt;
Are you allowed to display the data publicly?&lt;br&gt;
Are you allowed to redistribute it to your own customers?&lt;br&gt;
Is betting use permitted, and in which territories?&lt;br&gt;
Are derived metrics and models trained on the data covered?&lt;br&gt;
Is the data official or licensed, and from whom?&lt;br&gt;
What happens to your rights if you cancel?&lt;/p&gt;

&lt;p&gt;This section is the one developers skip and later regret. Enterprise vendors such as Sportradar, Stats Perform and Genius Sports sell on rights as much as on data, which is why their pricing is quote-based. Self-serve vendors publish prices, but you still need to read the terms. For Orbistats, check its data licensing and terms pages for your exact use.&lt;/p&gt;

&lt;p&gt;Section 3: Pricing model&lt;br&gt;
What is actually metered: requests, objects, leagues, or time?&lt;br&gt;
What counts as one unit? A single call, a returned event, a market?&lt;br&gt;
What are the per-minute limits on top of the daily or monthly quota?&lt;br&gt;
Which features are add-ons: odds, xG, predictions, history?&lt;br&gt;
What is the price jump between plan tiers?&lt;br&gt;
What happens when you exceed the limit: throttle, error or overage bill?&lt;/p&gt;

&lt;p&gt;The same price can mean very different things. API-Football meters daily requests, SportsGameOdds meters returned objects, Sportmonks meters leagues, and Orbistats sells time-window passes: Free is €0 for 1 hour, Starter €19 for 24 hours, Growth €79 for 5 days, and Unlimited €1,999 per month for 30 days, all with every sport and endpoint, per its pricing page. Always translate the price into your own expected usage before comparing.&lt;/p&gt;

&lt;p&gt;Section 4: Technical quality&lt;br&gt;
Is there a documented, versioned API, and what is the policy for breaking changes?&lt;br&gt;
Are IDs for matches, teams and players stable over time?&lt;br&gt;
Are timestamps ISO 8601 with an explicit timezone?&lt;br&gt;
Are errors returned in a consistent, documented format?&lt;br&gt;
Is pagination documented and consistent?&lt;br&gt;
Are rate-limit headers returned so you can self-throttle?&lt;br&gt;
Is there a sandbox or test environment?&lt;br&gt;
Are there SDKs and examples in your language?&lt;/p&gt;

&lt;p&gt;On versioning, Orbistats documents a /v1/ base and says breaking changes would ship under a new version such as /v2/, with non-breaking fields added to the current one, per its documentation. It also lists a changelog, SDKs, examples and a sandbox. Whatever vendor you consider, ask for the equivalent.&lt;/p&gt;

&lt;p&gt;Section 5: Delivery methods&lt;br&gt;
Is REST enough, or do you need WebSocket or webhooks for live data?&lt;br&gt;
Do WebSocket connections reconnect cleanly and resume without gaps?&lt;br&gt;
Are webhooks signed, retried and idempotent?&lt;br&gt;
What is the published latency, and how is it measured?&lt;br&gt;
Is push available on the plan you will actually pay for?&lt;/p&gt;

&lt;p&gt;Orbistats lists a WebSocket API, where the company cites sub-50ms, and webhooks, plus drop-in widgets. Treat any latency figure as a claim to measure yourself, not a fact. SportsGameOdds lists WebSocket for higher tiers, and Sportradar offers push on selected packages. Never assume a transport exists on your plan.&lt;/p&gt;

&lt;p&gt;Section 6: Odds&lt;br&gt;
Which bookmakers and markets are covered, and can you get a written list?&lt;br&gt;
Are odds normalised into one schema across bookmakers?&lt;br&gt;
Are odds decimal, American or fractional, and can you choose?&lt;br&gt;
Is line movement history available?&lt;br&gt;
Are closing odds stored for backtesting?&lt;br&gt;
How fresh is the live odds refresh?&lt;/p&gt;

&lt;p&gt;The Orbistats Odds API advertises pre-match and live odds normalised across bookmakers, including 1X2, moneyline, spreads, totals and props. The odds-first vendors, SportsGameOdds, OddsPapi and The Odds API, compete mainly on this section, so test them hardest here.&lt;/p&gt;

&lt;p&gt;Section 7: History&lt;br&gt;
How many seasons are available, per sport, not just overall?&lt;br&gt;
Does history include lineups, events and statistics, or only results?&lt;br&gt;
Are historical odds and closing odds included?&lt;br&gt;
Can you bulk export, or only page through an API?&lt;br&gt;
Is history included in your plan or a paid add-on?&lt;/p&gt;

&lt;p&gt;Orbistats describes a Historical Data API and says some seasons go back to 2010. Check retention for your specific sport and competition. Sportmonks notes that older history can be an add-on on some plans.&lt;/p&gt;

&lt;p&gt;Section 8: Reliability and support&lt;br&gt;
Is there a public status page?&lt;br&gt;
Is there an SLA, and what are the remedies?&lt;br&gt;
What are the support channels and response times?&lt;br&gt;
Is there a public changelog for incidents and changes?&lt;br&gt;
How does the vendor communicate deprecations?&lt;/p&gt;

&lt;p&gt;Orbistats links a public status page. For any vendor, compare the status page against your own monitoring over time rather than trusting either alone.&lt;/p&gt;

&lt;p&gt;Automate what you can: a checklist script&lt;/p&gt;

&lt;p&gt;Some checks are mechanical. This script tests six of them against any endpoint: does it reject a missing key, does it return a JSON content type, are timestamps ISO 8601, are IDs stable across calls, does it expose rate-limit headers, and does it respond within a time limit.&lt;/p&gt;

&lt;p&gt;Install the dependency:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install requests&lt;/p&gt;

&lt;p&gt;Create eval_api.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import json&lt;br&gt;
import re&lt;br&gt;
import sys&lt;br&gt;
import time&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;ISO_8601 = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(.\d+)?(Z|[+-]\d{2}:?\d{2})$")&lt;/p&gt;

&lt;p&gt;def walk(obj, path=""):&lt;br&gt;
    if isinstance(obj, dict):&lt;br&gt;
        for key, value in obj.items():&lt;br&gt;
            yield from walk(value, path + "." + key)&lt;br&gt;
    elif isinstance(obj, list):&lt;br&gt;
        for item in obj[:20]:&lt;br&gt;
            yield from walk(item, path + "[]")&lt;br&gt;
    else:&lt;br&gt;
        yield path, obj&lt;/p&gt;

&lt;p&gt;def check_rejects_missing_key(url, params):&lt;br&gt;
    r = requests.get(url, params=params, timeout=10)&lt;br&gt;
    return r.status_code in (401, 403), "status " + str(r.status_code) + " without credentials"&lt;/p&gt;

&lt;p&gt;def check_json_content_type(response):&lt;br&gt;
    kind = response.headers.get("Content-Type", "")&lt;br&gt;
    return "json" in kind.lower(), kind or "no Content-Type header"&lt;/p&gt;

&lt;p&gt;def check_iso_timestamps(data):&lt;br&gt;
    bad = []&lt;br&gt;
    seen = 0&lt;br&gt;
    for path, value in walk(data):&lt;br&gt;
        name = path.split(".")[-1].lower()&lt;br&gt;
        if isinstance(value, str) and any(word in name for word in ("time", "date", "at")):&lt;br&gt;
            seen += 1&lt;br&gt;
            if not ISO_8601.match(value):&lt;br&gt;
                bad.append(path + "=" + value)&lt;br&gt;
    if seen == 0:&lt;br&gt;
        return None, "no timestamp-like fields found"&lt;br&gt;
    return len(bad) == 0, "bad examples: " + str(bad[:3]) if bad else str(seen) + " fields look ISO 8601"&lt;/p&gt;

&lt;p&gt;def collect_ids(data):&lt;br&gt;
    ids = set()&lt;br&gt;
    for path, value in walk(data):&lt;br&gt;
        if path.split(".")[-1].lower().endswith("id"):&lt;br&gt;
            ids.add(str(value))&lt;br&gt;
    return ids&lt;/p&gt;

&lt;p&gt;def check_stable_ids(first, second):&lt;br&gt;
    a, b = collect_ids(first), collect_ids(second)&lt;br&gt;
    if not a or not b:&lt;br&gt;
        return None, "no id fields found"&lt;br&gt;
    overlap = len(a &amp;amp; b) / len(a | b)&lt;br&gt;
    return overlap &amp;gt; 0.5, "id overlap between two calls: " + str(round(overlap, 2))&lt;/p&gt;

&lt;p&gt;def check_rate_limit_headers(response):&lt;br&gt;
    found = {}&lt;br&gt;
    for name, value in response.headers.items():&lt;br&gt;
        low = name.lower()&lt;br&gt;
        if "ratelimit" in low or "rate-limit" in low or "remaining" in low or "retry-after" in low:&lt;br&gt;
            found[name] = value&lt;br&gt;
    return len(found) &amp;gt; 0, str(found) if found else "no rate-limit headers"&lt;/p&gt;

&lt;p&gt;def check_latency(elapsed_ms, limit_ms=1500):&lt;br&gt;
    return elapsed_ms &amp;lt;= limit_ms, str(elapsed_ms) + " ms (limit " + str(limit_ms) + ")"&lt;/p&gt;

&lt;p&gt;def main(url, headers, params):&lt;br&gt;
    print("Evaluating", url)&lt;br&gt;
    results = []&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok, note = check_rejects_missing_key(url, params)
results.append(("rejects missing key", ok, note))

start = time.perf_counter_ns()
r1 = requests.get(url, headers=headers, params=params, timeout=10)
elapsed = (time.perf_counter_ns() - start) // 1_000_000
r1.raise_for_status()
data1 = r1.json()
time.sleep(2)
r2 = requests.get(url, headers=headers, params=params, timeout=10)
data2 = r2.json()

results.append(("json content type",) + check_json_content_type(r1))
results.append(("iso 8601 timestamps",) + check_iso_timestamps(data1))
results.append(("stable ids",) + check_stable_ids(data1, data2))
results.append(("rate-limit headers",) + check_rate_limit_headers(r1))
results.append(("latency",) + check_latency(elapsed))

for name, ok, note in results:
    label = "UNKNOWN" if ok is None else ("PASS" if ok else "FAIL")
    print(label.ljust(8), name.ljust(24), note)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    api_url = sys.argv[1]&lt;br&gt;
    key_header = json.loads(sys.argv[2]) if len(sys.argv) &amp;gt; 2 else {}&lt;br&gt;
    query = json.loads(sys.argv[3]) if len(sys.argv) &amp;gt; 3 else {}&lt;br&gt;
    main(api_url, key_header, query)&lt;/p&gt;

&lt;p&gt;Run it against Orbistats’ documented live endpoint:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python eval_api.py "&lt;a href="https://api.orbistats.com/v1/football/matches/live" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/matches/live&lt;/a&gt;" '{"Authorization": "Bearer YOUR_API_KEY"}'&lt;/p&gt;

&lt;p&gt;Run it against any other vendor by changing the URL, header and query parameters according to that vendor’s docs. Confirm each vendor’s endpoint and authentication method in its documentation before running, for example API-Football and The Odds API each use their own key style.&lt;/p&gt;

&lt;p&gt;A few honest limits of this script. A live endpoint may return an empty list when no match is on, which makes some checks report UNKNOWN, so run it during a match window. A free-tier key may behave differently from a paid one. And passing six mechanical checks says nothing about rights, coverage depth or support, which still need the human checklist above.&lt;/p&gt;

&lt;p&gt;Score your shortlist&lt;/p&gt;

&lt;p&gt;Write each vendor’s result in a simple grid. Eight sections, each scored pass, fail or unknown, and a weight from 1 to 3 for how much that section matters to you. Multiply, add up, and then look closely at the unknowns, because they are the questions to put in your email to the vendor.&lt;/p&gt;

&lt;p&gt;Questions worth sending to any vendor in writing:&lt;/p&gt;

&lt;p&gt;A coverage matrix for my exact leagues and markets.&lt;br&gt;
Sample payloads for a live match and for historical data.&lt;br&gt;
The redistribution and betting-use terms for my territory.&lt;br&gt;
Per-minute and daily limits on the plan I will buy.&lt;br&gt;
SLA, status page and incident history.&lt;br&gt;
Which vendors fit which kind of product&lt;br&gt;
Regulated sportsbook or broadcaster: Sportradar, Genius Sports or Stats Perform, because rights and SLAs justify a contract.&lt;br&gt;
Football-only product: Sportmonks for production, API-Football for a cheap prototype.&lt;br&gt;
US fantasy app: SportsDataIO, with a written quote for your league.&lt;br&gt;
Odds-focused app: SportsGameOdds, OddsPapi or The Odds API, judged by bookmaker coverage and billing model.&lt;br&gt;
Multi-sport app with odds, streaming and history in one account: Orbistats is worth testing, for example through the quickstart and the API reference. Remember its pass-based pricing suits sprints and tests better than a long-running single-sport app, and that access starts after a request is approved.&lt;br&gt;
Common mistakes when evaluating&lt;br&gt;
Choosing on the feature table and never checking your own leagues.&lt;br&gt;
Skipping the licence until after launch.&lt;br&gt;
Comparing prices without translating them into your own usage.&lt;br&gt;
Trusting a latency claim instead of measuring it from your region.&lt;br&gt;
Testing only the happy path and never the rate-limit and error behaviour.&lt;br&gt;
Coupling your code to one vendor’s schema, which makes switching painful. Put an adapter layer between your app and the vendor.&lt;br&gt;
Next steps&lt;/p&gt;

&lt;p&gt;Pick two vendors, run the script against both, send the five written questions, and score them on the grid. If a multi-sport feed with odds and streaming fits your project, you can request an Orbistats access pass and test it against your own sports. The glossary explains the odds terms if any are new, and the research page shows the kind of data the company publishes from its own feeds.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>10 Sports Data APIs, 1 Benchmark Script: How to Compare Response Time and Reliability</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:31:38 +0000</pubDate>
      <link>https://dev.to/orbistats/10-sports-data-apis-1-benchmark-script-how-to-compare-response-time-and-reliability-1g76</link>
      <guid>https://dev.to/orbistats/10-sports-data-apis-1-benchmark-script-how-to-compare-response-time-and-reliability-1g76</guid>
      <description>&lt;p&gt;Why one-off latency tests mislead&lt;/p&gt;

&lt;p&gt;Run a speed test once and you learn how one API behaved for 20 seconds from your laptop. That is not reliability. Reliability is what happens over days: how often a call succeeds, how often it times out, what happens when you hit a rate limit, and whether the slow tail (the p95 and p99) stays acceptable.&lt;/p&gt;

&lt;p&gt;So instead of a single run, we build a small monitor that probes each provider on a schedule, stores every result, and produces a report. By the end you will have two scripts and a SQLite file you can chart, share or attach to a vendor negotiation.&lt;/p&gt;

&lt;p&gt;Read this first: I work with Orbistats, which is one of the ten providers. I have not run this monitor over time, so there are no results in this post. Vendor facts come from public pages as of 12 October 2026, and you should verify them. Publish your own numbers, with your region and dates, once you have them.&lt;/p&gt;

&lt;p&gt;The ten providers and what they let you test&lt;/p&gt;

&lt;p&gt;Not every vendor offers free access, so plan which ones you can actually probe:&lt;/p&gt;

&lt;p&gt;Sportradar: enterprise, quote-based, a trial is advertised for many APIs.&lt;br&gt;
Stats Perform (Opta): premium analytics, tailored licensing, sales contact.&lt;br&gt;
Genius Sports: official data and integrity, custom quote.&lt;br&gt;
Sportmonks: self-serve football, entry plan at €29 per month.&lt;br&gt;
SportsDataIO: US leagues and fantasy, product-specific pricing.&lt;br&gt;
Orbistats: 13 sports, Free pass of 1 hour after request and approval.&lt;br&gt;
OddsPapi: odds-focused, details unverified.&lt;br&gt;
SportsGameOdds: free tier, billed by returned objects.&lt;br&gt;
API-Football: free plan with 100 requests per day.&lt;br&gt;
The Odds API: free tier with a quota by sport, market and region.&lt;/p&gt;

&lt;p&gt;A fair note on the free passes: Orbistats’ Free pass lasts 1 hour from the moment you open your activation link, so a multi-day monitor needs a longer pass. Its pricing is pass-based: Starter is €19 for 24 hours, Growth €79 for 5 days, and Unlimited €1,999 for 30 days. For API-Football, 100 requests per day limits how often you can probe, which we handle below.&lt;/p&gt;

&lt;p&gt;What we will measure&lt;/p&gt;

&lt;p&gt;For every probe we record four things: the HTTP status, the response time, whether the body was valid JSON, and an outcome label. The outcome label is the important part. A timeout, a 429 rate limit and a 503 are three different problems, and lumping them together as “failed” hides what is really going on.&lt;/p&gt;

&lt;p&gt;ok: a 2xx response with valid JSON.&lt;br&gt;
rate_limited: HTTP 429. This is your quota, not the vendor’s outage.&lt;br&gt;
client_error: other 4xx, usually a bad key or wrong endpoint.&lt;br&gt;
server_error: 5xx, the vendor’s problem.&lt;br&gt;
timeout: no answer within the limit.&lt;br&gt;
connection_error: DNS, TLS or network failure.&lt;br&gt;
bad_json: a 2xx whose body is not valid JSON.&lt;br&gt;
Step 1: Setup&lt;br&gt;
bash&lt;br&gt;
mkdir sports-reliability &amp;amp;&amp;amp; cd sports-reliability&lt;br&gt;
python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate&lt;br&gt;
pip install requests&lt;/p&gt;

&lt;p&gt;Set your keys as environment variables so they never appear in code:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
export ORBISTATS_KEY="your_key_here"&lt;br&gt;
export APIFOOTBALL_KEY="your_key_here"&lt;br&gt;
export ODDSAPI_KEY="your_key_here"&lt;br&gt;
Step 2: Describe your targets&lt;/p&gt;

&lt;p&gt;Treat each vendor as data. Orbistats’ base URL and bearer authentication come from its documentation and quickstart. For every other vendor, confirm the endpoint and auth header in its own docs. The two extra entries below are examples to verify, not guaranteed values.&lt;/p&gt;

&lt;p&gt;Create targets.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import os&lt;/p&gt;

&lt;p&gt;TARGETS = {&lt;br&gt;
    "orbistats": {&lt;br&gt;
        "url": "&lt;a href="https://api.orbistats.com/v1/football/matches/live" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/matches/live&lt;/a&gt;",&lt;br&gt;
        "headers": {"Authorization": "Bearer " + os.getenv("ORBISTATS_KEY", "")},&lt;br&gt;
        "params": {},&lt;br&gt;
        "interval": 60,&lt;br&gt;
    },&lt;br&gt;
    "api_football": {&lt;br&gt;
        "url": "&lt;a href="https://v3.football.api-sports.io/fixtures" rel="noopener noreferrer"&gt;https://v3.football.api-sports.io/fixtures&lt;/a&gt;",&lt;br&gt;
        "headers": {"x-apisports-key": os.getenv("APIFOOTBALL_KEY", "")},&lt;br&gt;
        "params": {"live": "all"},&lt;br&gt;
        "interval": 900,&lt;br&gt;
    },&lt;br&gt;
    "the_odds_api": {&lt;br&gt;
        "url": "&lt;a href="https://api.the-odds-api.com/v4/sports" rel="noopener noreferrer"&gt;https://api.the-odds-api.com/v4/sports&lt;/a&gt;",&lt;br&gt;
        "headers": {},&lt;br&gt;
        "params": {"apiKey": os.getenv("ODDSAPI_KEY", "")},&lt;br&gt;
        "interval": 900,&lt;br&gt;
    },&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Each target has its own interval in seconds. That is how you respect a 100-requests-per-day free plan: at one probe every 900 seconds you use 96 requests a day.&lt;/p&gt;

&lt;p&gt;Step 3: The probe and the database&lt;/p&gt;

&lt;p&gt;Create monitor.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import sqlite3&lt;br&gt;
import time&lt;br&gt;
import requests&lt;br&gt;
from targets import TARGETS&lt;/p&gt;

&lt;p&gt;DB_FILE = "reliability.db"&lt;br&gt;
TIMEOUT = 10&lt;/p&gt;

&lt;p&gt;def init_db():&lt;br&gt;
    conn = sqlite3.connect(DB_FILE)&lt;br&gt;
    conn.execute(&lt;br&gt;
        "CREATE TABLE IF NOT EXISTS probes ("&lt;br&gt;
        "ts INTEGER, provider TEXT, status INTEGER, "&lt;br&gt;
        "latency_ms INTEGER, outcome TEXT, size_bytes INTEGER)"&lt;br&gt;
    )&lt;br&gt;
    conn.commit()&lt;br&gt;
    return conn&lt;/p&gt;

&lt;p&gt;def probe(cfg):&lt;br&gt;
    start = time.perf_counter_ns()&lt;br&gt;
    try:&lt;br&gt;
        r = requests.get(&lt;br&gt;
            cfg["url"],&lt;br&gt;
            headers=cfg["headers"],&lt;br&gt;
            params=cfg["params"],&lt;br&gt;
            timeout=TIMEOUT,&lt;br&gt;
        )&lt;br&gt;
    except requests.Timeout:&lt;br&gt;
        return None, TIMEOUT * 1000, "timeout", 0&lt;br&gt;
    except requests.RequestException:&lt;br&gt;
        return None, (time.perf_counter_ns() - start) // 1_000_000, "connection_error", 0&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;latency = (time.perf_counter_ns() - start) // 1_000_000
size = len(r.content)

if r.status_code == 429:
    return r.status_code, latency, "rate_limited", size
if 400 &amp;lt;= r.status_code &amp;lt; 500:
    return r.status_code, latency, "client_error", size
if r.status_code &amp;gt;= 500:
    return r.status_code, latency, "server_error", size
try:
    r.json()
except ValueError:
    return r.status_code, latency, "bad_json", size
return r.status_code, latency, "ok", size
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def record(conn, provider, result):&lt;br&gt;
    status, latency, outcome, size = result&lt;br&gt;
    conn.execute(&lt;br&gt;
        "INSERT INTO probes VALUES (?, ?, ?, ?, ?, ?)",&lt;br&gt;
        (int(time.time()), provider, status, latency, outcome, size),&lt;br&gt;
    )&lt;br&gt;
    conn.commit()&lt;/p&gt;

&lt;p&gt;def run_forever():&lt;br&gt;
    conn = init_db()&lt;br&gt;
    next_due = {name: 0 for name in TARGETS}&lt;br&gt;
    while True:&lt;br&gt;
        now = time.time()&lt;br&gt;
        for name, cfg in TARGETS.items():&lt;br&gt;
            if now &amp;gt;= next_due[name]:&lt;br&gt;
                record(conn, name, probe(cfg))&lt;br&gt;
                next_due[name] = now + cfg["interval"]&lt;br&gt;
                print("probed", name)&lt;br&gt;
        time.sleep(1)&lt;/p&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    run_forever()&lt;/p&gt;

&lt;p&gt;Run it with: python monitor.py&lt;/p&gt;

&lt;p&gt;Leave it running for hours or days, ideally on a small always-on machine or server in the region where your real users are. A laptop that sleeps will give you gaps that look like outages.&lt;/p&gt;

&lt;p&gt;Step 4: The report&lt;/p&gt;

&lt;p&gt;Create report.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import sqlite3&lt;br&gt;
import statistics&lt;/p&gt;

&lt;p&gt;conn = sqlite3.connect("reliability.db")&lt;/p&gt;

&lt;p&gt;def percentile(values, p):&lt;br&gt;
    values = sorted(values)&lt;br&gt;
    if not values:&lt;br&gt;
        return None&lt;br&gt;
    index = round((p / 100) * (len(values) - 1))&lt;br&gt;
    return values[index]&lt;/p&gt;

&lt;p&gt;providers = [row[0] for row in conn.execute("SELECT DISTINCT provider FROM probes")]&lt;/p&gt;

&lt;p&gt;for provider in providers:&lt;br&gt;
    rows = conn.execute(&lt;br&gt;
        "SELECT latency_ms, outcome FROM probes WHERE provider = ?", (provider,)&lt;br&gt;
    ).fetchall()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total = len(rows)
ok_latencies = [lat for lat, outcome in rows if outcome == "ok"]
counts = {}
for _, outcome in rows:
    counts[outcome] = counts.get(outcome, 0) + 1

counted = total - counts.get("rate_limited", 0)
availability = (len(ok_latencies) / counted * 100) if counted else 0

print(provider)
print("  probes:", total)
print("  availability (excluding 429s):", round(availability, 2), "percent")
print("  median ms:", round(statistics.median(ok_latencies)) if ok_latencies else None)
print("  p95 ms:", percentile(ok_latencies, 95))
print("  p99 ms:", percentile(ok_latencies, 99))
print("  outcomes:", counts)
print()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Run it with: python report.py&lt;/p&gt;

&lt;p&gt;Two choices here are worth explaining. Latency percentiles use only successful calls, because a timeout recorded as 10,000 ms would distort the median in a misleading way. And availability excludes 429s, because hitting your own quota is not the vendor being down. Report both numbers if you publish results, so readers can see how you counted.&lt;/p&gt;

&lt;p&gt;Step 5: Measure live streaming separately&lt;/p&gt;

&lt;p&gt;Request and response APIs are only half the picture. For live scores and odds, the question is whether a stream stays connected. Orbistats offers a WebSocket API, where the company cites sub-50ms, and webhooks. Take the exact connection URL and subscribe message from the documentation and never guess them. This script counts how long a connection survives and how many messages arrive:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import json&lt;br&gt;
import time&lt;br&gt;
import websocket&lt;/p&gt;

&lt;p&gt;WS_URL = "PASTE_WS_URL_FROM_DOCS"&lt;br&gt;
SUBSCRIBE = {"action": "subscribe", "sport": "football"}&lt;/p&gt;

&lt;p&gt;connected_at = time.time()&lt;br&gt;
messages = 0&lt;br&gt;
try:&lt;br&gt;
    ws = websocket.create_connection(WS_URL, timeout=60)&lt;br&gt;
    ws.send(json.dumps(SUBSCRIBE))&lt;br&gt;
    while True:&lt;br&gt;
        ws.recv()&lt;br&gt;
        messages += 1&lt;br&gt;
except Exception as e:&lt;br&gt;
    uptime = round(time.time() - connected_at)&lt;br&gt;
    print("disconnected after", uptime, "seconds,", messages, "messages, reason:", repr(e))&lt;/p&gt;

&lt;p&gt;Install the extra dependency with: pip install websocket-client. The SUBSCRIBE message above is illustrative. Log how often the connection drops, and how long reconnecting takes, across a full match window.&lt;/p&gt;

&lt;p&gt;Step 6: Read the results honestly&lt;/p&gt;

&lt;p&gt;Numbers are easy to misuse. Before you draw conclusions, check these:&lt;/p&gt;

&lt;p&gt;Sample size. A few dozen probes tell you almost nothing about p99. Collect hundreds, ideally thousands.&lt;br&gt;
Time of day. Probe during live matches and quiet hours, since load differs.&lt;br&gt;
Region. Latency from India is not latency from Frankfurt. Always state where the monitor ran.&lt;br&gt;
Compare like with like. A live-scores endpoint and a static standings endpoint are not the same workload.&lt;br&gt;
Caching. A response served from a vendor cache can look unrealistically fast.&lt;br&gt;
Plan differences. A free tier can be throttled differently from a paid one.&lt;br&gt;
Empty live endpoints. A live endpoint may legitimately return an empty list when no match is on, so label those periods.&lt;/p&gt;

&lt;p&gt;For vendors that publish a status page, compare your own results with it. Orbistats lists a public status page, and checking it against your own logs is a useful sanity test of both.&lt;/p&gt;

&lt;p&gt;A results template to publish&lt;/p&gt;

&lt;p&gt;When you have real data, present it like this, one block per provider, with your own measurements:&lt;/p&gt;

&lt;p&gt;Provider: name&lt;br&gt;
Plan tested: free, trial or paid&lt;br&gt;
Region and dates: where the monitor ran and for how long&lt;br&gt;
Probes: total number&lt;br&gt;
Availability: percent, excluding 429s&lt;br&gt;
Median and p95 latency: in milliseconds&lt;br&gt;
Dominant failure: timeout, 5xx or rate limit&lt;/p&gt;

&lt;p&gt;Keep the method next to the numbers: interval, timeout, endpoint, and whether a match was live.&lt;/p&gt;

&lt;p&gt;Which provider should you probe first?&lt;br&gt;
Enterprise vendors (Sportradar, Stats Perform, Genius Sports): ask sales for a trial key and written SLA terms, then probe the same way.&lt;br&gt;
Football products: Sportmonks and API-Football, probed on the leagues you will actually show.&lt;br&gt;
Odds apps: SportsGameOdds, OddsPapi and The Odds API, probed on the markets and bookmakers you need.&lt;br&gt;
Multi-sport apps with odds and streaming: Orbistats covers 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing), so probe your own sports, for example cricket or basketball, in the sandbox before committing.&lt;br&gt;
Backtesting and machine learning: probe the history endpoint separately, for example the Historical Data API, and verify retention per sport. The company says some seasons go back to 2010.&lt;br&gt;
Common mistakes&lt;br&gt;
Counting 429s as vendor downtime, which blames them for your own quota.&lt;br&gt;
Probing too often on a request-metered plan and burning the free tier in an afternoon.&lt;br&gt;
Running the monitor on a laptop that sleeps.&lt;br&gt;
Measuring from one region and generalising to everywhere.&lt;br&gt;
Publishing a table with no method, so nobody can trust or reproduce it.&lt;br&gt;
Next steps&lt;/p&gt;

&lt;p&gt;Chart the SQLite data with a notebook, add an alert when availability drops below a threshold, and run the same monitor against your own production traffic so you compare vendor claims with what your users actually experience. If you want an access pass to add a multi-sport feed to the test, follow the quickstart and request access here. The glossary is useful if the odds terms are new, and the API reference lists the exact response shapes.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Provider-Agnostic Sports Data Client: What 10 Different APIs Teach You About Design</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:29:01 +0000</pubDate>
      <link>https://dev.to/orbistats/building-a-provider-agnostic-sports-data-client-what-10-different-apis-teach-you-about-design-p08</link>
      <guid>https://dev.to/orbistats/building-a-provider-agnostic-sports-data-client-what-10-different-apis-teach-you-about-design-p08</guid>
      <description>&lt;p&gt;Why you need a provider-agnostic client&lt;/p&gt;

&lt;p&gt;Most sports apps start with one API and one hard-coded JSON shape. Six months later the vendor changes pricing, drops a league, or you need a second source for odds, and suddenly the vendor’s field names are spread across your whole codebase.&lt;/p&gt;

&lt;p&gt;The fix is old and boring: put every vendor behind an adapter and make your app depend only on your own models. This tutorial builds that client in Python, using ten real providers as the design reference.&lt;/p&gt;

&lt;p&gt;Read this first: I work with Orbistats, which is one of the ten providers. I did not run performance tests for this post, so there are no benchmark numbers here. Everything about vendors comes from their public pages as of 12 October 2026, and you should verify it before relying on it.&lt;/p&gt;

&lt;p&gt;The ten providers and how they differ&lt;/p&gt;

&lt;p&gt;The differences between vendors are exactly why an adapter layer is worth building:&lt;/p&gt;

&lt;p&gt;Sportradar: enterprise, quote-based, claims 80+ sports and 500+ leagues, push on selected packages.&lt;br&gt;
Stats Perform (Opta): premium analytics, tailored licensing.&lt;br&gt;
Genius Sports: official data and integrity, custom quote.&lt;br&gt;
Sportmonks: self-serve football, plans metered by number of leagues.&lt;br&gt;
SportsDataIO: US leagues and fantasy, product-specific pricing.&lt;br&gt;
Orbistats: 13 sports, odds, WebSocket, webhooks, history, access passes instead of monthly plans.&lt;br&gt;
OddsPapi: odds-focused, details unverified, check the live page.&lt;br&gt;
SportsGameOdds: odds-first, billed by returned objects.&lt;br&gt;
API-Football: football only, billed by daily requests.&lt;br&gt;
The Odds API: odds, quota scoped by sport, market and region.&lt;/p&gt;

&lt;p&gt;Notice what varies: authentication style, what is metered (requests, objects, leagues, time), whether push delivery exists, and the shape of the JSON. Your client must hide all of that.&lt;/p&gt;

&lt;p&gt;Architecture overview&lt;/p&gt;

&lt;p&gt;The design has five small pieces:&lt;/p&gt;

&lt;p&gt;Models: your own dataclasses, the only types the rest of the app sees.&lt;br&gt;
Provider adapters: one class per vendor, converting vendor JSON into your models.&lt;br&gt;
HTTP helper: retries, backoff and rate-limit handling in one place.&lt;br&gt;
Cache and failover: protect your quota and survive an outage.&lt;br&gt;
Streaming layer: WebSocket with reconnects, and a webhook receiver.&lt;br&gt;
Step 1: Define your own models&lt;/p&gt;

&lt;p&gt;Create models.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from dataclasses import dataclass&lt;br&gt;
from typing import Optional&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class LiveMatch:&lt;br&gt;
    provider: str&lt;br&gt;
    match_id: str&lt;br&gt;
    minute: Optional[int]&lt;br&gt;
    home_name: str&lt;br&gt;
    home_score: int&lt;br&gt;
    away_name: str&lt;br&gt;
    away_score: int&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class OneXTwo:&lt;br&gt;
    home: float&lt;br&gt;
    draw: float&lt;br&gt;
    away: float&lt;/p&gt;

&lt;p&gt;Your UI, your database and your analytics code import only these. Vendor field names never leave the adapter.&lt;/p&gt;

&lt;p&gt;Step 2: One robust HTTP helper&lt;/p&gt;

&lt;p&gt;Every vendor rate-limits differently, so handle it once. Create http_client.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import random&lt;br&gt;
import time&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;RETRY_STATUS = {429, 500, 502, 503, 504}&lt;/p&gt;

&lt;p&gt;def get_json(url, headers=None, params=None, retries=3, timeout=10):&lt;br&gt;
    last_error = None&lt;br&gt;
    for attempt in range(retries + 1):&lt;br&gt;
        delay = pow(2, attempt) / 2&lt;br&gt;
        try:&lt;br&gt;
            r = requests.get(url, headers=headers or {}, params=params or {}, timeout=timeout)&lt;br&gt;
            if r.status_code not in RETRY_STATUS:&lt;br&gt;
                r.raise_for_status()&lt;br&gt;
                return r.json()&lt;br&gt;
            retry_after = r.headers.get("Retry-After", "")&lt;br&gt;
            if retry_after.isdigit():&lt;br&gt;
                delay = float(retry_after)&lt;br&gt;
            last_error = "HTTP " + str(r.status_code)&lt;br&gt;
        except requests.RequestException as e:&lt;br&gt;
            last_error = str(e)&lt;br&gt;
        if attempt &amp;lt; retries:&lt;br&gt;
            time.sleep(delay + random.uniform(0, 0.3))&lt;br&gt;
    raise RuntimeError("request failed after retries: " + str(last_error))&lt;/p&gt;

&lt;p&gt;Two details matter here. Exponential backoff with a little random jitter stops many clients retrying in lockstep, and honoring Retry-After respects whatever the vendor tells you.&lt;/p&gt;

&lt;p&gt;Step 3: The provider interface&lt;/p&gt;

&lt;p&gt;Create providers.py with a base class:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from abc import ABC, abstractmethod&lt;/p&gt;

&lt;p&gt;class SportsProvider(ABC):&lt;br&gt;
    name = "base"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@abstractmethod
def live_matches(self, sport):
    pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The Orbistats adapter&lt;/p&gt;

&lt;p&gt;Orbistats documents a base URL of &lt;a href="https://api.orbistats.com/v1/" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/&lt;/a&gt; with bearer-token authentication, and a live endpoint shaped like GET /v1/football/matches/live. The live example on its homepage returns match_id, status, minute, and home and away objects with name and score. Check the API reference for the exact response shape before shipping, in particular whether the endpoint returns one object or a list.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from http_client import get_json&lt;br&gt;
from models import LiveMatch&lt;/p&gt;

&lt;p&gt;class OrbistatsProvider(SportsProvider):&lt;br&gt;
    name = "orbistats"&lt;br&gt;
    BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def __init__(self, api_key):
    self.headers = {"Authorization": "Bearer " + api_key}

def live_matches(self, sport="football"):
    data = get_json(self.BASE + "/" + sport + "/matches/live", headers=self.headers)
    items = data if isinstance(data, list) else [data]
    return [
        LiveMatch(
            provider=self.name,
            match_id=str(m["match_id"]),
            minute=m.get("minute"),
            home_name=m["home"]["name"],
            home_score=m["home"]["score"],
            away_name=m["away"]["name"],
            away_score=m["away"]["score"],
        )
        for m in items
    ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Writing the next adapter&lt;/p&gt;

&lt;p&gt;For any other vendor, copy this class, change the URL and auth header from that vendor’s docs, and rewrite only the mapping inside live_matches. For example, API-Football authenticates with its own key header and The Odds API takes the key as a query parameter, but confirm both in their documentation (API-Football, The Odds API) before coding. That is the whole point of the pattern: the difference lives in one small class.&lt;/p&gt;

&lt;p&gt;Step 4: Cache to protect your quota&lt;/p&gt;

&lt;p&gt;Request-metered plans punish naive polling. A tiny time-based cache is often the cheapest optimization you will ever make.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;class TtlCache:&lt;br&gt;
    def &lt;strong&gt;init&lt;/strong&gt;(self, ttl_seconds):&lt;br&gt;
        self.ttl = ttl_seconds&lt;br&gt;
        self.store = {}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def get(self, key):
    item = self.store.get(key)
    if item and time.monotonic() - item[0] &amp;lt; self.ttl:
        return item[1]
    return None

def set(self, key, value):
    self.store[key] = (time.monotonic(), value)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Pick the TTL per data type: fixtures and standings can live for minutes, live scores for seconds.&lt;/p&gt;

&lt;p&gt;Step 5: Failover between providers&lt;/p&gt;

&lt;p&gt;Create client.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
class SportsClient:&lt;br&gt;
    def &lt;strong&gt;init&lt;/strong&gt;(self, providers, cache):&lt;br&gt;
        self.providers = providers&lt;br&gt;
        self.cache = cache&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def live_matches(self, sport="football"):
    key = "live:" + sport
    cached = self.cache.get(key)
    if cached is not None:
        return cached

    errors = {}
    for provider in self.providers:
        try:
            result = provider.live_matches(sport)
            self.cache.set(key, result)
            return result
        except Exception as e:
            errors[provider.name] = str(e)
    raise RuntimeError("all providers failed: " + str(errors))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Use it like this:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import os&lt;br&gt;
from client import SportsClient&lt;br&gt;
from providers import OrbistatsProvider&lt;br&gt;
from cache import TtlCache&lt;/p&gt;

&lt;p&gt;client = SportsClient(&lt;br&gt;
    providers=[OrbistatsProvider(os.getenv("ORBISTATS_KEY", ""))],&lt;br&gt;
    cache=TtlCache(ttl_seconds=5),&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;for match in client.live_matches("football"):&lt;br&gt;
    print(match.home_name, match.home_score, "-", match.away_score, match.away_name)&lt;/p&gt;

&lt;p&gt;Add a second provider to the list and failover works with no other change. Remember that failing over between vendors can change data rights and coverage, so check the licence of each source before you mix them.&lt;/p&gt;

&lt;p&gt;Step 6: Normalize odds across bookmakers&lt;/p&gt;

&lt;p&gt;Odds are where schemas diverge most. Convert everything to decimal odds in your own model, then compute implied probability and the bookmaker margin. The Orbistats Odds API advertises normalized 1X2, moneyline, spreads, totals and props across books, and its docs example shows a 1X2 market like home 1.91, draw 3.40, away 4.20.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from models import OneXTwo&lt;/p&gt;

&lt;p&gt;def implied_probabilities(odds):&lt;br&gt;
    return {&lt;br&gt;
        "home": 1 / odds.home,&lt;br&gt;
        "draw": 1 / odds.draw,&lt;br&gt;
        "away": 1 / odds.away,&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;def overround(odds):&lt;br&gt;
    return sum(implied_probabilities(odds).values())&lt;/p&gt;

&lt;p&gt;example = OneXTwo(home=1.91, draw=3.40, away=4.20)&lt;br&gt;
print(round(overround(example), 4))&lt;/p&gt;

&lt;p&gt;For that example the three implied probabilities add up to roughly 1.056, meaning the book builds in about a 5.6 percent margin. Comparing that number across vendors for the same fixture is a quick sanity check on data quality. If you are new to these terms, the Orbistats glossary explains implied probability and more.&lt;/p&gt;

&lt;p&gt;Step 7: Streaming with automatic reconnects&lt;/p&gt;

&lt;p&gt;Polling wastes quota and adds delay for live data. Orbistats offers a WebSocket API, where the company cites sub-50ms, and webhooks. Take the exact connection URL and subscribe message from the documentation. Connections drop in the real world, so reconnect with backoff:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import json&lt;br&gt;
import time&lt;br&gt;
import websocket&lt;/p&gt;

&lt;p&gt;def stream(url, subscribe_message, on_event):&lt;br&gt;
    attempt = 0&lt;br&gt;
    while True:&lt;br&gt;
        try:&lt;br&gt;
            ws = websocket.create_connection(url, timeout=30)&lt;br&gt;
            ws.send(json.dumps(subscribe_message))&lt;br&gt;
            attempt = 0&lt;br&gt;
            while True:&lt;br&gt;
                on_event(json.loads(ws.recv()))&lt;br&gt;
        except Exception:&lt;br&gt;
            attempt += 1&lt;br&gt;
            time.sleep(min(30, pow(2, attempt)))&lt;/p&gt;

&lt;p&gt;Install the dependency with: pip install websocket-client. The subscribe message is vendor-specific, so never guess it.&lt;/p&gt;

&lt;p&gt;Step 8: A safe webhook receiver&lt;/p&gt;

&lt;p&gt;Webhooks can be delivered more than once, so make your handler idempotent. This example fingerprints the raw body so a repeated delivery is ignored. Check the vendor docs for whether deliveries are signed, and verify signatures if they are.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import hashlib&lt;br&gt;
from flask import Flask, request&lt;/p&gt;

&lt;p&gt;app = Flask(&lt;strong&gt;name&lt;/strong&gt;)&lt;br&gt;
seen = set()&lt;/p&gt;

&lt;p&gt;@app.route("/webhook/sports", methods=["POST"])&lt;br&gt;
def webhook():&lt;br&gt;
    fingerprint = hashlib.sha256(request.get_data()).hexdigest()&lt;br&gt;
    if fingerprint in seen:&lt;br&gt;
        return "", 200&lt;br&gt;
    seen.add(fingerprint)&lt;br&gt;
    print(request.get_json(silent=True))&lt;br&gt;
    return "", 200&lt;/p&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    app.run(port=3000)&lt;/p&gt;

&lt;p&gt;Install with: pip install flask. Acknowledge quickly and do heavy work in a background job. In production, replace the in-memory set with Redis or a database table.&lt;/p&gt;

&lt;p&gt;Step 9: Test without calling real APIs&lt;/p&gt;

&lt;p&gt;Because your app depends on the interface, you can test failover with fakes and spend zero quota:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from cache import TtlCache&lt;br&gt;
from client import SportsClient&lt;br&gt;
from models import LiveMatch&lt;/p&gt;

&lt;p&gt;class BrokenProvider:&lt;br&gt;
    name = "broken"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def live_matches(self, sport):
    raise RuntimeError("down")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;class FakeProvider:&lt;br&gt;
    name = "fake"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def live_matches(self, sport):
    return [LiveMatch("fake", "1", 72, "Home", 2, "Away", 1)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def test_failover_uses_second_provider():&lt;br&gt;
    client = SportsClient([BrokenProvider(), FakeProvider()], TtlCache(0))&lt;br&gt;
    result = client.live_matches("football")&lt;br&gt;
    assert result[0].provider == "fake"&lt;/p&gt;

&lt;p&gt;Run with: pip install pytest, then pytest.&lt;/p&gt;

&lt;p&gt;Design lessons that apply to every vendor&lt;br&gt;
Hide metering. Requests, objects, leagues and time windows are different units, so track your own usage and alert before you hit limits.&lt;br&gt;
Never trust one schema. Parse defensively and fail loudly when a required field is missing.&lt;br&gt;
Separate rights from technology. A fast feed you cannot legally redistribute is worthless, so read each licence.&lt;br&gt;
Prefer push for live data. REST suits fixtures, standings and history, while WebSocket or webhooks suit live scores and odds.&lt;br&gt;
Keep history behind its own interface. Backtesting data has different retention and cost rules. Orbistats describes a historical archive and says some seasons go back to 2010, but verify retention per sport.&lt;br&gt;
Which vendor should sit behind your adapter?&lt;br&gt;
Regulated sportsbook or broadcaster: Sportradar, Genius Sports or Stats Perform, because rights and SLAs justify a contract.&lt;br&gt;
Football-only product: Sportmonks for production, API-Football for a cheap prototype.&lt;br&gt;
US fantasy app: SportsDataIO, with a written quote.&lt;br&gt;
Odds-focused app: SportsGameOdds, OddsPapi or The Odds API, judged by bookmaker coverage and billing model.&lt;br&gt;
Multi-sport app with odds and streaming: Orbistats covers 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing). Its pricing is pass-based, not monthly: Free is €0 for 1 hour, Starter €19 for 24 hours, Growth €79 for 5 days and Unlimited €1,999 per month for 30 days. Access starts after you request it, so try the sandbox and check the status page on your own leagues, for example cricket or basketball, before committing.&lt;/p&gt;

&lt;p&gt;Because of the adapter layer, this choice is no longer a one-way door.&lt;/p&gt;

&lt;p&gt;Common mistakes&lt;br&gt;
Letting vendor JSON leak into your database schema.&lt;br&gt;
Retrying on every error, including 401 and 403, which burns quota for nothing.&lt;br&gt;
Polling live endpoints every second on a request-metered plan.&lt;br&gt;
Forgetting that free passes and trials are time or quota limited.&lt;br&gt;
Mixing providers without checking redistribution rights.&lt;br&gt;
Next steps&lt;/p&gt;

&lt;p&gt;Add a second adapter, add per-provider usage counters, and put a small dashboard on top of your logs. If you want to try the multi-sport feed, follow the quickstart, request an access pass and plug it in as one more provider. The whole point of this design is that trying a new vendor costs you one class, not a rewrite.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Built a Benchmark for 10 Sports Data APIs’ Free Tiers — Here’s the Code (and How to Run It)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:24:19 +0000</pubDate>
      <link>https://dev.to/orbistats/i-built-a-benchmark-for-10-sports-data-apis-free-tiers-heres-the-code-and-how-to-run-it-128p</link>
      <guid>https://dev.to/orbistats/i-built-a-benchmark-for-10-sports-data-apis-free-tiers-heres-the-code-and-how-to-run-it-128p</guid>
      <description>&lt;p&gt;A provider-agnostic Python harness that measures latency, schema consistency and quota burn, so you can produce your own results in 30 minutes.&lt;/p&gt;

&lt;p&gt;Read this first: I work with Orbistats (&lt;a href="https://orbistats.com/" rel="noopener noreferrer"&gt;https://orbistats.com/&lt;/a&gt;), which is one of the ten providers below. I have not published benchmark numbers here, because latency depends on your region, network and plan, and several vendors have no self-serve free tier at all. Instead you get the harness and a results template. Run it, then publish your own numbers. Public claims and prices are as of 12 October 2026.&lt;/p&gt;

&lt;p&gt;What we’re building&lt;/p&gt;

&lt;p&gt;A small tool that, for any sports data API, answers four questions:&lt;/p&gt;

&lt;p&gt;How fast is a typical response? (median and p95 latency)&lt;br&gt;
Is the data shape stable? (same fields on every call)&lt;br&gt;
What does a call cost against my quota?&lt;br&gt;
Does live data arrive by push, or only by polling?&lt;/p&gt;

&lt;p&gt;By the end you will have a benchmark.py that writes a CSV you can paste into a blog post, a spreadsheet or a vendor negotiation.&lt;/p&gt;

&lt;p&gt;Which of the ten actually offer free access?&lt;/p&gt;

&lt;p&gt;The title says “free tiers,” but only some vendors have one. Here is what the public information says:&lt;/p&gt;

&lt;p&gt;Sportradar (&lt;a href="https://sportradar.com/media-tech/data-content/sports-data-api/):" rel="noopener noreferrer"&gt;https://sportradar.com/media-tech/data-content/sports-data-api/):&lt;/a&gt; trial advertised for many APIs (30 days for the sports data API).&lt;br&gt;
Stats Perform / Opta (&lt;a href="https://www.statsperform.com/products/):" rel="noopener noreferrer"&gt;https://www.statsperform.com/products/):&lt;/a&gt; sales contact, tailored licensing.&lt;br&gt;
Genius Sports (&lt;a href="https://www.geniussports.com/engage/official-sports-data-api/):" rel="noopener noreferrer"&gt;https://www.geniussports.com/engage/official-sports-data-api/):&lt;/a&gt; sales contact, no public self-serve price.&lt;br&gt;
Sportmonks (&lt;a href="https://www.sportmonks.com/football-api/plans-pricing/):" rel="noopener noreferrer"&gt;https://www.sportmonks.com/football-api/plans-pricing/):&lt;/a&gt; entry plan €29/month, check the page for any trial.&lt;br&gt;
SportsDataIO (&lt;a href="https://sportsdata.io/):" rel="noopener noreferrer"&gt;https://sportsdata.io/):&lt;/a&gt; product-specific, verify directly.&lt;br&gt;
Orbistats (&lt;a href="https://orbistats.com/pricing.html):" rel="noopener noreferrer"&gt;https://orbistats.com/pricing.html):&lt;/a&gt; Free €0 pass, 1 hour, after request and approval.&lt;br&gt;
OddsPapi (&lt;a href="https://oddspapi.io/):" rel="noopener noreferrer"&gt;https://oddspapi.io/):&lt;/a&gt; not verified, check the live page.&lt;br&gt;
SportsGameOdds (&lt;a href="https://sportsgameodds.com/pricing):" rel="noopener noreferrer"&gt;https://sportsgameodds.com/pricing):&lt;/a&gt; free tier, 2,500 objects/month.&lt;br&gt;
API-Football (&lt;a href="https://www.api-football.com/pricing):" rel="noopener noreferrer"&gt;https://www.api-football.com/pricing):&lt;/a&gt; free, 100 requests/day.&lt;br&gt;
The Odds API (&lt;a href="https://the-odds-api.com/):" rel="noopener noreferrer"&gt;https://the-odds-api.com/):&lt;/a&gt; free tier, recheck quota.&lt;/p&gt;

&lt;p&gt;So a true free-tier shootout is possible for only about four of the ten. For the rest, the harness works with a trial key or a paid entry plan.&lt;/p&gt;

&lt;p&gt;Step 1: Project setup&lt;/p&gt;

&lt;p&gt;mkdir sports-api-bench &amp;amp;&amp;amp; cd sports-api-bench&lt;br&gt;
python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate&lt;br&gt;
pip install requests websocket-client&lt;/p&gt;

&lt;p&gt;Set your keys as environment variables so they never touch the code:&lt;/p&gt;

&lt;p&gt;export ORBISTATS_KEY="your_key_here"&lt;br&gt;
export APIFOOTBALL_KEY="your_key_here"&lt;br&gt;
export ODDSAPI_KEY="your_key_here"&lt;/p&gt;

&lt;p&gt;Step 2: Describe each provider as data&lt;/p&gt;

&lt;p&gt;The trick is to treat every vendor as a config entry, not a code branch. Orbistats’ URL and bearer auth come from its documentation (quickstart: &lt;a href="https://orbistats.com/developers/quickstart.html" rel="noopener noreferrer"&gt;https://orbistats.com/developers/quickstart.html&lt;/a&gt;, docs: &lt;a href="https://orbistats.com/developers/documentation.html" rel="noopener noreferrer"&gt;https://orbistats.com/developers/documentation.html&lt;/a&gt;). For the others, confirm the endpoint and auth header in each vendor’s docs. The two below are shown as examples to verify.&lt;/p&gt;

&lt;p&gt;Save this as providers.py:&lt;/p&gt;

&lt;p&gt;import os&lt;/p&gt;

&lt;p&gt;PROVIDERS = {&lt;br&gt;
“orbistats”: {&lt;br&gt;
“url”: “&lt;a href="https://api.orbistats.com/v1/football/matches/live%E2%80%9D" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/matches/live”&lt;/a&gt;,&lt;br&gt;
“headers”: {“Authorization”: “Bearer “ + os.getenv(“ORBISTATS_KEY”, “”)},&lt;br&gt;
“params”: {},&lt;br&gt;
},&lt;br&gt;
“api_football”: {&lt;br&gt;
“url”: “&lt;a href="https://v3.football.api-sports.io/fixtures%E2%80%9D" rel="noopener noreferrer"&gt;https://v3.football.api-sports.io/fixtures”&lt;/a&gt;,&lt;br&gt;
“headers”: {“x-apisports-key”: os.getenv(“APIFOOTBALL_KEY”, “”)},&lt;br&gt;
“params”: {“live”: “all”},&lt;br&gt;
},&lt;br&gt;
“the_odds_api”: {&lt;br&gt;
“url”: “&lt;a href="https://api.the-odds-api.com/v4/sports%E2%80%9D" rel="noopener noreferrer"&gt;https://api.the-odds-api.com/v4/sports”&lt;/a&gt;,&lt;br&gt;
“headers”: {},&lt;br&gt;
“params”: {“apiKey”: os.getenv(“ODDSAPI_KEY”, “”)},&lt;br&gt;
},&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Adding a vendor is now one dictionary entry. (Verify the api_football and the_odds_api entries against their docs before running.)&lt;/p&gt;

&lt;p&gt;Step 3: Measure latency and schema stability&lt;/p&gt;

&lt;p&gt;Save this as benchmark.py:&lt;/p&gt;

&lt;p&gt;import csv, statistics, time, requests&lt;br&gt;
from providers import PROVIDERS&lt;/p&gt;

&lt;p&gt;N = 20&lt;br&gt;
PAUSE = 1.0&lt;/p&gt;

&lt;p&gt;def field_paths(obj, prefix=""):&lt;br&gt;
paths = set()&lt;br&gt;
if isinstance(obj, dict):&lt;br&gt;
for k, v in obj.items():&lt;br&gt;
paths |= field_paths(v, prefix + k + “.”)&lt;br&gt;
elif isinstance(obj, list) and obj:&lt;br&gt;
paths |= field_paths(obj[0], prefix)&lt;br&gt;
else:&lt;br&gt;
paths.add(prefix.rstrip(“.”))&lt;br&gt;
return paths&lt;/p&gt;

&lt;p&gt;def bench(name, cfg):&lt;br&gt;
times, shapes, quota_headers = [], [], {}&lt;br&gt;
for _ in range(N):&lt;br&gt;
t0 = time.perf_counter_ns()&lt;br&gt;
try:&lt;br&gt;
r = requests.get(cfg[“url”], headers=cfg[“headers”],&lt;br&gt;
params=cfg[“params”], timeout=10)&lt;br&gt;
except requests.RequestException:&lt;br&gt;
continue&lt;br&gt;
times.append((time.perf_counter_ns() - t0) // 1_000_000)&lt;br&gt;
for h, v in r.headers.items():&lt;br&gt;
low = h.lower()&lt;br&gt;
if “limit” in low or “remaining” in low or “used” in low:&lt;br&gt;
quota_headers[h] = v&lt;br&gt;
try:&lt;br&gt;
shapes.append(frozenset(field_paths(r.json())))&lt;br&gt;
except ValueError:&lt;br&gt;
pass&lt;br&gt;
time.sleep(PAUSE)&lt;/p&gt;

&lt;p&gt;times.sort()&lt;br&gt;
ok = len(times)&lt;br&gt;
return {&lt;br&gt;
    "provider": name,&lt;br&gt;
    "ok_calls": ok,&lt;br&gt;
    "median_ms": round(statistics.median(times)) if ok else None,&lt;br&gt;
    "p95_ms": times[-1 - ok // 20] if ok else None,&lt;br&gt;
    "schema_stable": (len(set(shapes)) &amp;lt;= 1) if shapes else None,&lt;br&gt;
    "quota_headers": quota_headers,&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;if name == “main”:&lt;br&gt;
rows = [bench(n, c) for n, c in PROVIDERS.items()]&lt;br&gt;
with open(“results.csv”, “w”, newline="") as f:&lt;br&gt;
w = csv.DictWriter(f, fieldnames=rows[0].keys())&lt;br&gt;
w.writeheader()&lt;br&gt;
w.writerows(rows)&lt;br&gt;
for r in rows:&lt;br&gt;
print(r)&lt;/p&gt;

&lt;p&gt;Run it with: python benchmark.py&lt;/p&gt;

&lt;p&gt;Two cautions about what the numbers mean. First, this measures request round-trip from your machine, which includes your own network. Second, a “live” endpoint may legitimately return an empty list when no match is on, so run it during a match window. Keep N low on free tiers, because every call burns quota.&lt;/p&gt;

&lt;p&gt;Step 4: Test push delivery (WebSocket)&lt;/p&gt;

&lt;p&gt;Polling latency is only half the story. For live scores and odds, compare how long a push takes to reach you. Orbistats lists WebSocket streaming (&lt;a href="https://orbistats.com/api/websocket-api.html" rel="noopener noreferrer"&gt;https://orbistats.com/api/websocket-api.html&lt;/a&gt;), where the company cites sub-50ms, and webhooks (&lt;a href="https://orbistats.com/api/webhooks-api.html" rel="noopener noreferrer"&gt;https://orbistats.com/api/webhooks-api.html&lt;/a&gt;). Take the connection URL and subscribe message from the docs, then log the gaps between messages.&lt;/p&gt;

&lt;p&gt;Save this as ws_probe.py:&lt;/p&gt;

&lt;p&gt;import json, time, websocket&lt;/p&gt;

&lt;p&gt;WS_URL = “PASTE_WS_URL_FROM_DOCS”&lt;br&gt;
SUBSCRIBE = {“action”: “subscribe”, “sport”: “football”}&lt;/p&gt;

&lt;p&gt;ws = websocket.create_connection(WS_URL, timeout=30)&lt;br&gt;
ws.send(json.dumps(SUBSCRIBE))&lt;/p&gt;

&lt;p&gt;last = None&lt;br&gt;
gaps = []&lt;br&gt;
for _ in range(100):&lt;br&gt;
msg = ws.recv()&lt;br&gt;
now = time.perf_counter_ns()&lt;br&gt;
if last is not None:&lt;br&gt;
gaps.append((now - last) // 1_000_000)&lt;br&gt;
last = now&lt;br&gt;
print(json.loads(msg))&lt;/p&gt;

&lt;p&gt;gaps.sort()&lt;br&gt;
print(“messages:”, len(gaps) + 1, “median gap ms:”, gaps[len(gaps) // 2])&lt;/p&gt;

&lt;p&gt;The SUBSCRIBE message above is illustrative. Use the exact one from the documentation.&lt;/p&gt;

&lt;p&gt;Honest limitation: gaps between messages show update cadence, not true end-to-end latency. To measure that, compare each event’s timestamp against a trusted reference such as an official match clock or a second vendor.&lt;/p&gt;

&lt;p&gt;Step 5: Receive webhooks locally&lt;/p&gt;

&lt;p&gt;Save this as server.js:&lt;/p&gt;

&lt;p&gt;import express from “express”;&lt;br&gt;
const app = express();&lt;br&gt;
app.use(express.json());&lt;/p&gt;

&lt;p&gt;app.post(“/webhook/sports”, (req, res) =&amp;gt; {&lt;br&gt;
const receivedAt = Date.now();&lt;br&gt;
console.log(receivedAt, JSON.stringify(req.body).slice(0, 200));&lt;br&gt;
res.sendStatus(200);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(3000, () =&amp;gt; console.log(“listening on port 3000”));&lt;/p&gt;

&lt;p&gt;Acknowledge fast and process asynchronously. Expose the server with a tunnel such as ngrok during testing, and register the URL as described in the documentation (&lt;a href="https://orbistats.com/developers/documentation.html" rel="noopener noreferrer"&gt;https://orbistats.com/developers/documentation.html&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Step 6: Your results template&lt;/p&gt;

&lt;p&gt;Fill this in after running the harness. Replace every dash with your own measurement, region and date.&lt;/p&gt;

&lt;p&gt;Orbistats | Free pass | Region: - | Median ms: - | p95 ms: - | Schema stable: - | Push: WebSocket + webhooks (per docs) | Quota used: -&lt;br&gt;
API-Football | Free | Region: - | Median ms: - | p95 ms: - | Schema stable: - | Push: verify | Quota used: -&lt;br&gt;
SportsGameOdds | Free | Region: - | Median ms: - | p95 ms: - | Schema stable: - | Push: higher tiers (per docs) | Quota used: -&lt;br&gt;
The Odds API | Free | Region: - | Median ms: - | p95 ms: - | Schema stable: - | Push: verify | Quota used: -&lt;br&gt;
Sportmonks | Entry | Region: - | Median ms: - | p95 ms: - | Schema stable: - | Push: verify | Quota used: -&lt;/p&gt;

&lt;p&gt;Publish the methodology next to the table: region, time of day, number of samples and whether a match was live.&lt;/p&gt;

&lt;p&gt;What the public docs already tell you&lt;/p&gt;

&lt;p&gt;While you wait for your own data, here is what the vendors say about themselves. Verify all of it.&lt;/p&gt;

&lt;p&gt;Sportradar (&lt;a href="https://developer.sportradar.com/getting-started/docs/get-started):" rel="noopener noreferrer"&gt;https://developer.sportradar.com/getting-started/docs/get-started):&lt;/a&gt; 80+ sports, 500+ leagues, push on selected packages, quote-based.&lt;br&gt;
Sportmonks (&lt;a href="https://www.sportmonks.com/football-api/coverage/):" rel="noopener noreferrer"&gt;https://www.sportmonks.com/football-api/coverage/):&lt;/a&gt; 2,200+ football leagues, plans at €29, €99 and €249 per month.&lt;br&gt;
SportsGameOdds (&lt;a href="https://sportsgameodds.com/docs/basics):" rel="noopener noreferrer"&gt;https://sportsgameodds.com/docs/basics):&lt;/a&gt; 67+ leagues, 28+ sports, 85+ bookmakers depending on plan, object-based billing.&lt;br&gt;
API-Football (&lt;a href="https://www.api-football.com/coverage):" rel="noopener noreferrer"&gt;https://www.api-football.com/coverage):&lt;/a&gt; 1,248 leagues and cups, 100 free requests a day.&lt;br&gt;
Orbistats (&lt;a href="https://orbistats.com/):" rel="noopener noreferrer"&gt;https://orbistats.com/):&lt;/a&gt; 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf, horse racing). Passes at €0, €19, €79 and €1,999 per month. Some seasons back to 2010, per the company. Try its sandbox (&lt;a href="https://orbistats.com/developers/sandbox.html" rel="noopener noreferrer"&gt;https://orbistats.com/developers/sandbox.html&lt;/a&gt;) and check the status page (&lt;a href="https://orbistats.com/developers/status.html" rel="noopener noreferrer"&gt;https://orbistats.com/developers/status.html&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Pitfalls to avoid&lt;/p&gt;

&lt;p&gt;Benchmarking one endpoint and generalising to the whole API.&lt;br&gt;
Forgetting that free tiers can be throttled differently from paid ones.&lt;br&gt;
Comparing a cached response to a fresh one.&lt;br&gt;
Ignoring commercial rights: a fast feed you cannot redistribute is useless.&lt;br&gt;
Burning your free quota in the first run. Lower N.&lt;/p&gt;

&lt;p&gt;Next steps&lt;/p&gt;

&lt;p&gt;Extend the harness with odds checks (compare the same fixture’s 1X2 prices across vendors), a coverage check for your target leagues using Orbistats’ per-sport pages (&lt;a href="https://orbistats.com/sports/cricket.html" rel="noopener noreferrer"&gt;https://orbistats.com/sports/cricket.html&lt;/a&gt;), and a nightly GitHub Action that appends to results.csv so you can chart reliability over weeks. If you want an access pass to try the multi-sport feed, you can request one here (&lt;a href="https://orbistats.com/request-access.html" rel="noopener noreferrer"&gt;https://orbistats.com/request-access.html&lt;/a&gt;), and the glossary (&lt;a href="https://orbistats.com/resources/glossary.html" rel="noopener noreferrer"&gt;https://orbistats.com/resources/glossary.html&lt;/a&gt;) helps if odds terms are new.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Surviving a 150-Requests-a-Day Free Tier: Caching Patterns for Sports APIs</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:37:21 +0000</pubDate>
      <link>https://dev.to/orbistats/surviving-a-150-requests-a-day-free-tier-caching-patterns-for-sports-apis-3hf7</link>
      <guid>https://dev.to/orbistats/surviving-a-150-requests-a-day-free-tier-caching-patterns-for-sports-apis-3hf7</guid>
      <description>&lt;p&gt;Surviving a 150-Requests-a-Day Free Tier: Caching Patterns for Sports APIs&lt;/p&gt;

&lt;p&gt;You sign up for a free sports API key, make your first request, and it works. Then you read the limit: 150 requests per day.&lt;/p&gt;

&lt;p&gt;In a terminal, that feels generous. In a real app, it disappears fast.&lt;/p&gt;

&lt;p&gt;Here is the math. 150 calls spread over 24 hours is one call every 9.6 minutes. If your app gets 10,000 page views a day and each view calls the API, your quota is gone in about 90 seconds of traffic. To survive, your cache hit rate must be at least 98.5%.&lt;/p&gt;

&lt;p&gt;That is not a tuning problem. It is an architecture problem, and this guide solves it step by step in plain Node.js.&lt;/p&gt;

&lt;p&gt;A note on limits. 150/day is a common free-tier ceiling across sports-data providers, and it is the constraint this guide designs for. Limits differ by provider and plan. The Orbistats Free plan, for example, is a time-boxed access window rather than a daily counter. The discipline is the same: every call is precious.&lt;/p&gt;

&lt;p&gt;Prerequisites: Node.js 18+ (built-in fetch), basic JavaScript, and an API key. The quickstart guide takes you from signup to your first request.&lt;/p&gt;

&lt;p&gt;About endpoints: the paths below follow the pattern in the Orbistats docs (&lt;a href="https://api.orbistats.com/v1/%7Bsport%7D/" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/{sport}/&lt;/a&gt;... with Bearer auth). Confirm exact paths and query parameters in the API reference before shipping.&lt;/p&gt;

&lt;p&gt;Step 1: Know the shelf life of your data&lt;/p&gt;

&lt;p&gt;The most common mistake with a small quota is using one TTL for everything. Sports data has very different shelf lives:&lt;/p&gt;

&lt;p&gt;Data    Changes Sensible TTL&lt;br&gt;
Countries, competitions Almost never    7 days&lt;br&gt;
Teams, player profiles  Rarely  24 hours&lt;br&gt;
Fixtures    A few times a day   3-6 hours&lt;br&gt;
Standings   After each match ends   30-60 minutes&lt;br&gt;
Live score  Every few seconds   30-60 seconds&lt;br&gt;
Finished match  Never   Forever&lt;br&gt;
Historical seasons  Never   Forever&lt;/p&gt;

&lt;p&gt;The last two rows are where free tiers are won. A finished match is immutable. Asking for it twice wastes a call.&lt;/p&gt;

&lt;p&gt;Step 2: Budget your 150 calls like money&lt;/p&gt;

&lt;p&gt;Write the budget before you write any cache code. Here is one for an app covering all 13 sports, including football, basketball and cricket:&lt;/p&gt;

&lt;p&gt;Bucket  Calls/day   Why&lt;br&gt;
Live reserve    40  Spent only when a match is on&lt;br&gt;
Fixtures (13 sports x 1)    13  Schedules barely move&lt;br&gt;
Standings (13 sports x 2)   26  Morning and late evening&lt;br&gt;
Results sync (13 sports x 2)    26  Save finished matches forever&lt;br&gt;
Cache-miss buffer   45  Retries and surprises&lt;br&gt;
Total   150 &lt;/p&gt;

&lt;p&gt;That is about 11.5 calls per sport per day.&lt;/p&gt;

&lt;p&gt;Notice what is missing: “one call per user request.” Users never talk to the API. Users talk to your cache. Only your background jobs talk to the API.&lt;/p&gt;

&lt;p&gt;Covering fewer sports? Your budget is much roomier. Start with the sports your audience actually watches.&lt;/p&gt;

&lt;p&gt;Step 3: Build a TTL cache with LRU eviction&lt;/p&gt;

&lt;p&gt;The foundation is an in-memory cache that tracks freshness and staleness separately. Later steps depend on that split.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// cache.js&lt;br&gt;
export class TTLCache {&lt;br&gt;
  constructor({ maxEntries = 500 } = {}) {&lt;br&gt;
    this.store = new Map();&lt;br&gt;
    this.maxEntries = maxEntries;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;get(key) {&lt;br&gt;
    const entry = this.store.get(key);&lt;br&gt;
    if (!entry) return null;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// LRU bump: re-insert so recently used keys sit at the end
this.store.delete(key);
this.store.set(key, entry);

const now = Date.now();
return {
  value: entry.value,
  fresh: now &amp;lt; entry.freshUntil,   // serve with zero network
  usable: now &amp;lt; entry.staleUntil,  // serve, but refresh in background
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;set(key, value, ttlMs, staleMs = 0) {&lt;br&gt;
    const now = Date.now();&lt;br&gt;
    this.store.delete(key);&lt;br&gt;
    this.store.set(key, {&lt;br&gt;
      value,&lt;br&gt;
      freshUntil: now + ttlMs,&lt;br&gt;
      staleUntil: now + ttlMs + staleMs,&lt;br&gt;
    });&lt;br&gt;
    if (this.store.size &amp;gt; this.maxEntries) {&lt;br&gt;
      this.store.delete(this.store.keys().next().value); // evict LRU&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Each entry has two clocks:&lt;/p&gt;

&lt;p&gt;Fresh: serve instantly, no call.&lt;br&gt;
Stale but usable: serve instantly and refresh quietly.&lt;br&gt;
Expired: keep it only as an emergency fallback (Step 5).&lt;/p&gt;

&lt;p&gt;Step 4: Stop cache stampedes with single-flight&lt;/p&gt;

&lt;p&gt;Here is a quiet quota killer. Your football standings entry expires at 12:00:00. At 12:00:01, 200 users load your page. Every request sees an empty cache and fires its own API call. You just spent 200 calls on one piece of data.&lt;/p&gt;

&lt;p&gt;The fix is single-flight: if a request for key X is already running, everyone else waits for the same promise.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
refresh(key, url, ttlMs, staleMs, kind) {&lt;br&gt;
  // Someone is already fetching this key? Join them.&lt;br&gt;
  if (this.inflight.has(key)) return this.inflight.get(key);&lt;/p&gt;

&lt;p&gt;const promise = (async () =&amp;gt; {&lt;br&gt;
    // budget check + fetch + cache.set (full version in Step 7)&lt;br&gt;
  })().finally(() =&amp;gt; this.inflight.delete(key));&lt;/p&gt;

&lt;p&gt;this.inflight.set(key, promise);&lt;br&gt;
  return promise;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;200 simultaneous users now cost 1 call. This one pattern often saves more quota than any TTL tweak.&lt;/p&gt;

&lt;p&gt;Step 5: Serve stale data on purpose&lt;/p&gt;

&lt;p&gt;Stale-while-revalidate (SWR) works like this:&lt;/p&gt;

&lt;p&gt;The entry is a bit old, so return it immediately.&lt;br&gt;
Start one background refresh.&lt;br&gt;
The next visitor gets fresh data.&lt;/p&gt;

&lt;p&gt;Stale-if-error is its cousin. If the API fails or your quota is spent, serve the last known value instead of an error page. A ten-minute-old standings table beats a 500 every time.&lt;/p&gt;

&lt;p&gt;When something looks wrong, check the provider’s status page first. It is the fastest way to learn whether the problem is your cache or their infrastructure.&lt;/p&gt;

&lt;p&gt;Step 6: Add a budget guard&lt;/p&gt;

&lt;p&gt;Caching reduces calls. A budget guard guarantees you never exceed the limit, even when you ship a bug. It also reserves part of the quota for live data, so a runaway standings job cannot starve a live match.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// budget.js&lt;br&gt;
export class DailyBudget {&lt;br&gt;
  constructor({ limit = 150, reserveForLive = 40 } = {}) {&lt;br&gt;
    this.limit = limit;&lt;br&gt;
    this.reserveForLive = reserveForLive;&lt;br&gt;
    this.day = this.today();&lt;br&gt;
    this.used = 0;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;today() {&lt;br&gt;
    // Check your provider: UTC midnight reset, or a rolling 24h window?&lt;br&gt;
    return new Date().toISOString().slice(0, 10);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;roll() {&lt;br&gt;
    const t = this.today();&lt;br&gt;
    if (t !== this.day) { this.day = t; this.used = 0; }&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;canSpend(kind = 'static') {&lt;br&gt;
    this.roll();&lt;br&gt;
    const ceiling = kind === 'live' ? this.limit : this.limit - this.reserveForLive;&lt;br&gt;
    return this.used &amp;lt; ceiling;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;spend() { this.roll(); this.used += 1; }&lt;br&gt;
  remaining() { this.roll(); return this.limit - this.used; }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;This counter lives in memory, so it resets on restart. Step 14 shows how to persist it.&lt;/p&gt;

&lt;p&gt;Step 7: Assemble the cached client&lt;/p&gt;

&lt;p&gt;Now combine everything: cache, single-flight, SWR, stale-if-error and the budget guard.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// client.js&lt;br&gt;
import { TTLCache } from './cache.js';&lt;br&gt;
import { DailyBudget } from './budget.js';&lt;/p&gt;

&lt;p&gt;export class SportsClient {&lt;br&gt;
  constructor({&lt;br&gt;
    apiKey,&lt;br&gt;
    baseUrl = '&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;',&lt;br&gt;
    cache = new TTLCache(),&lt;br&gt;
    budget = new DailyBudget(),&lt;br&gt;
  }) {&lt;br&gt;
    Object.assign(this, { apiKey, baseUrl, cache, budget });&lt;br&gt;
    this.inflight = new Map();&lt;br&gt;
    this.stats = { hits: 0, stale: 0, network: 0, errors: 0 };&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;buildUrl(path, params = {}) {&lt;br&gt;
    const url = new URL(this.baseUrl + path);&lt;br&gt;
    // Sorted params: ?a=1&amp;amp;b=2 and ?b=2&amp;amp;a=1 share one cache key&lt;br&gt;
    Object.keys(params).sort().forEach((k) =&amp;gt; url.searchParams.set(k, params[k]));&lt;br&gt;
    return url;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;async get(path, { params, ttlMs, staleMs = 0, kind = 'static' } = {}) {&lt;br&gt;
    const url = this.buildUrl(path, params);&lt;br&gt;
    const key = url.pathname + url.search;&lt;br&gt;
    const hit = this.cache.get(key);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (hit?.fresh) {                       // 1) fresh: free answer
  this.stats.hits++;
  return { data: hit.value, source: 'cache' };
}

if (hit?.usable) {                      // 2) stale: answer now, refresh quietly
  this.stats.stale++;
  this.refresh(key, url, ttlMs, staleMs, kind).catch(() =&amp;gt; {});
  return { data: hit.value, source: 'stale' };
}

try {                                   // 3) miss: network, fall back on failure
  const data = await this.refresh(key, url, ttlMs, staleMs, kind);
  return { data, source: 'network' };
} catch (err) {
  this.stats.errors++;
  if (hit) return { data: hit.value, source: 'stale-if-error' };
  throw err;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;refresh(key, url, ttlMs, staleMs, kind) {&lt;br&gt;
    if (this.inflight.has(key)) return this.inflight.get(key);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const promise = (async () =&amp;gt; {
  if (!this.budget.canSpend(kind)) throw new Error('BUDGET_EXHAUSTED');
  this.budget.spend();
  this.stats.network++;

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${this.apiKey}` },
  });

  if (res.status === 429) {
    this.budget.used = this.budget.limit; // our count was wrong; stop spending
    throw new Error('RATE_LIMITED');
  }
  if (!res.ok) throw new Error(`HTTP_${res.status}`);

  const data = await res.json();
  this.cache.set(key, data, ttlMs, staleMs);
  return data;
})().finally(() =&amp;gt; this.inflight.delete(key));

this.inflight.set(key, promise);
return promise;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;hitRate() {&lt;br&gt;
    const { hits, stale, network } = this.stats;&lt;br&gt;
    const total = hits + stale + network;&lt;br&gt;
    return total ? (hits + stale) / total : 0;&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Usage:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const client = new SportsClient({ apiKey: process.env.ORBISTATS_KEY });&lt;/p&gt;

&lt;p&gt;const { data, source } = await client.get('/football/standings', {&lt;br&gt;
  params: { competition: 'premier-league' },&lt;br&gt;
  ttlMs: 2_700_000,       // fresh for 45 minutes&lt;br&gt;
  staleMs: 10_800_000,    // then usable for 3 more hours&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Every call site declares its own TTL. Standings and live scores never share a lifetime by accident.&lt;/p&gt;

&lt;p&gt;Step 8: Store finished matches forever&lt;/p&gt;

&lt;p&gt;An in-memory cache vanishes on restart. For data that can never change, use a permanent store. SQLite is perfect: no server, one file, very fast.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// finished.js&lt;br&gt;
import Database from 'better-sqlite3';&lt;/p&gt;

&lt;p&gt;const db = new Database('sports.db');&lt;br&gt;
db.exec(&lt;code&gt;&lt;br&gt;
  CREATE TABLE IF NOT EXISTS finished_matches (&lt;br&gt;
    id       TEXT PRIMARY KEY,&lt;br&gt;
    sport    TEXT NOT NULL,&lt;br&gt;
    payload  TEXT NOT NULL,&lt;br&gt;
    saved_at INTEGER NOT NULL&lt;br&gt;
  )&lt;br&gt;
&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;const selectOne = db.prepare('SELECT payload FROM finished_matches WHERE id = ?');&lt;br&gt;
const upsert = db.prepare(&lt;br&gt;
  'INSERT OR REPLACE INTO finished_matches VALUES (?, ?, ?, ?)'&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;export const getFinished = (id) =&amp;gt; {&lt;br&gt;
  const row = selectOne.get(id);&lt;br&gt;
  return row ? JSON.parse(row.payload) : null;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export function saveFinished(match, sport) {&lt;br&gt;
  // Confirm the exact "finished" status value in the API reference&lt;br&gt;
  if (match.status !== 'finished') return false;&lt;br&gt;
  upsert.run(match.match_id, sport, JSON.stringify(match), Date.now());&lt;br&gt;
  return true;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A nightly results-sync job now makes one call per sport, saves every finished match, and never asks for it again.&lt;/p&gt;

&lt;p&gt;The same idea powers model training. If you are backtesting on past seasons, download once and keep your own copy. The Historical Sports Data API is built for exactly that, and the Sports Statistics API pairs well with it for player and team aggregates.&lt;/p&gt;

&lt;p&gt;Step 9: Warm the cache on a schedule&lt;/p&gt;

&lt;p&gt;This is the mental shift that makes 150 calls viable.&lt;/p&gt;

&lt;p&gt;Before: user opens page, your server calls the API, user waits.&lt;br&gt;
After: a scheduled job calls the API, the cache stays warm, the user gets an instant answer with zero API calls.&lt;br&gt;
js&lt;br&gt;
// warmer.js&lt;br&gt;
const SPORTS = [&lt;br&gt;
  'football', 'basketball', 'american-football', 'cricket', 'tennis',&lt;br&gt;
  'baseball', 'esports', 'combat-sports', 'volleyball', 'handball',&lt;br&gt;
  'ice-hockey', 'golf', 'horse-racing',&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;const ONE_HOUR = 3_600_000;&lt;br&gt;
const SIX_HOURS = 21_600_000;&lt;br&gt;
const TWELVE_HOURS = 43_200_000;&lt;/p&gt;

&lt;p&gt;async function warm(client, resource, ttlMs, staleMs) {&lt;br&gt;
  for (const sport of SPORTS) {&lt;br&gt;
    try {&lt;br&gt;
      await client.get(&lt;code&gt;/${sport}/${resource}&lt;/code&gt;, { ttlMs, staleMs });&lt;br&gt;
    } catch (err) {&lt;br&gt;
      console.warn(&lt;code&gt;warm ${sport}/${resource} failed:&lt;/code&gt;, err.message);&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function startWarmers(client) {&lt;br&gt;
  warm(client, 'fixtures', SIX_HOURS, TWELVE_HOURS);   // on boot&lt;br&gt;
  setInterval(() =&amp;gt; warm(client, 'fixtures', SIX_HOURS, TWELVE_HOURS), SIX_HOURS);&lt;br&gt;
  setInterval(() =&amp;gt; warm(client, 'standings', ONE_HOUR, SIX_HOURS), TWELVE_HOURS);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Public routes then read only from the cache and never fall through to the network:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
app.get('/api/standings/:sport', (req, res) =&amp;gt; {&lt;br&gt;
  const hit = client.cache.get(&lt;code&gt;/v1/${req.params.sport}/standings&lt;/code&gt;);&lt;br&gt;
  if (!hit) return res.status(503).json({ error: 'Warming up, retry shortly' });&lt;br&gt;
  res.set('Cache-Control', 'public, max-age=60'); // CDN/browser = second cache layer&lt;br&gt;
  res.json(hit.value);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;That Cache-Control header gives you a free second cache layer at your CDN and in the browser.&lt;/p&gt;

&lt;p&gt;Step 10: Fetch wide, slice locally&lt;/p&gt;

&lt;p&gt;Per-item endpoints drain quota:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
GET /football/matches/101   &amp;lt;- 1 call&lt;br&gt;
GET /football/matches/102   &amp;lt;- 1 call&lt;br&gt;
GET /football/matches/103   &amp;lt;- 1 call&lt;/p&gt;

&lt;p&gt;List endpoints are cheap:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
GET /football/fixtures      &amp;lt;- 1 call, filter in your own code&lt;/p&gt;

&lt;p&gt;Whenever you write a loop that makes one API call per item, stop and look for a list endpoint. The Sports Data API covers fixtures, results, standings, teams and competitions, so a list is usually available. Check the documentation for date and competition filters.&lt;/p&gt;

&lt;p&gt;Fetch the day’s fixtures once, then slice by team, league or kickoff time in memory.&lt;/p&gt;

&lt;p&gt;Step 11: Push instead of poll for live data&lt;/p&gt;

&lt;p&gt;Live scores are the hardest data to cache, because a 30-second TTL on a busy match still means two calls a minute. Poll one match for 2 hours and you have spent 240 calls on a single game.&lt;/p&gt;

&lt;p&gt;Polling live data on a small quota does not work. Switch to push.&lt;/p&gt;

&lt;p&gt;Webhooks: the provider calls your endpoint when something happens (goal, card, full time).&lt;br&gt;
WebSocket API: one persistent connection streams events as they occur.&lt;br&gt;
Live Scores API: the REST endpoint for snapshots when you need current state.&lt;/p&gt;

&lt;p&gt;A webhook receiver that writes straight into your cache:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import crypto from 'node:crypto';&lt;br&gt;
import express from 'express';&lt;/p&gt;

&lt;p&gt;const app = express();&lt;/p&gt;

&lt;p&gt;// Verify signatures against the RAW body, so use express.raw here&lt;br&gt;
app.post('/webhooks/sports', express.raw({ type: 'application/json' }), (req, res) =&amp;gt; {&lt;br&gt;
  const sig = req.get('X-Signature') || '';   // header name: confirm in the webhooks docs&lt;br&gt;
  const expected = crypto&lt;br&gt;
    .createHmac('sha256', process.env.WEBHOOK_SECRET)&lt;br&gt;
    .update(req.body)&lt;br&gt;
    .digest('hex');&lt;/p&gt;

&lt;p&gt;const ok =&lt;br&gt;
    sig.length === expected.length &amp;amp;&amp;amp;&lt;br&gt;
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));&lt;br&gt;
  if (!ok) return res.sendStatus(401);&lt;/p&gt;

&lt;p&gt;const event = JSON.parse(req.body.toString('utf8'));&lt;br&gt;
  client.cache.set(&lt;code&gt;live:${event.match_id}&lt;/code&gt;, event, 60_000, 300_000);&lt;/p&gt;

&lt;p&gt;res.sendStatus(200);  // acknowledge fast, do heavy work elsewhere&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Pushed events do not count as polling calls, so your live reserve stays untouched. Verify the real signature scheme in the webhook docs before trusting this code.&lt;/p&gt;

&lt;p&gt;If you only need a score box on a page, a drop-in widget can remove your live-data work entirely.&lt;/p&gt;

&lt;p&gt;Step 12: Measure your hit rate&lt;/p&gt;

&lt;p&gt;You cannot protect a quota you do not measure. Expose three numbers:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
app.get('/internal/metrics', (_req, res) =&amp;gt; {&lt;br&gt;
  res.json({&lt;br&gt;
    hitRate: client.hitRate(),          // target: above 0.985&lt;br&gt;
    callsRemaining: client.budget.remaining(),&lt;br&gt;
    ...client.stats,&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Check them like this:&lt;/p&gt;

&lt;p&gt;Hit rate below 95%? A TTL is too short, or a cache key is unstable (unsorted params, timestamps in the URL).&lt;br&gt;
callsRemaining falling fast in the morning? A warmer is running too often.&lt;br&gt;
Many stale-if-error responses? Check the upstream status page, then your network.&lt;/p&gt;

&lt;p&gt;Step 13: Test without spending a single call&lt;/p&gt;

&lt;p&gt;Here is the most useful habit on a small quota: never spend real calls on testing your cache. Stub fetch and assert on call counts.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import assert from 'node:assert';&lt;br&gt;
import { SportsClient } from './client.js';&lt;/p&gt;

&lt;p&gt;const calls = [];&lt;br&gt;
globalThis.fetch = async (url) =&amp;gt; {&lt;br&gt;
  calls.push(String(url));&lt;br&gt;
  return new Response(JSON.stringify({ table: [] }), { status: 200 });&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const client = new SportsClient({ apiKey: 'test' });&lt;/p&gt;

&lt;p&gt;// 200 simultaneous users, one standings table&lt;br&gt;
await Promise.all(&lt;br&gt;
  Array.from({ length: 200 }, () =&amp;gt;&lt;br&gt;
    client.get('/football/standings', { ttlMs: 60_000 })&lt;br&gt;
  )&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;assert.equal(calls.length, 1, 'single-flight should collapse 200 calls into 1');&lt;br&gt;
console.log('OK, network calls:', calls.length);&lt;/p&gt;

&lt;p&gt;One practical tip: Orbistats access works in time windows. The Free plan is one hour with no card, and the timer starts when you open your activation link. Starter is 24 hours and Growth is five days, with every sport and endpoint on every plan. Current details are on the pricing page.&lt;/p&gt;

&lt;p&gt;So build and test everything above locally, with stubbed responses, before you start the clock. Steps 1 to 12 need no live data. Then spend your window verifying real payloads and field names, using the sandbox and SDKs, and let one real match prove the pipeline.&lt;/p&gt;

&lt;p&gt;Step 14: Production hardening checklist&lt;/p&gt;

&lt;p&gt;The tutorial version is correct. A production version is also operable:&lt;/p&gt;

&lt;p&gt;Persist the budget in Redis. An in-memory counter resets on every deploy. Use an atomic increment:&lt;br&gt;
js&lt;br&gt;
const key = &lt;code&gt;budget:${new Date().toISOString().slice(0, 10)}&lt;/code&gt;;&lt;br&gt;
const used = await redis.incr(key);&lt;br&gt;
if (used === 1) await redis.expire(key, 129_600); // 36 hours&lt;br&gt;
if (used &amp;gt; ceiling) { await redis.decr(key); return false; }&lt;br&gt;
Move the cache to Redis too if you run more than one server. Otherwise each instance warms its own copy and multiplies your calls.&lt;br&gt;
Run warmers in a single process. Two warmers means double spend. Use one worker or a distributed lock.&lt;br&gt;
Add jitter to TTLs. If 13 sports all expire at the same second, you create your own stampede. Add 5-10% random variation.&lt;br&gt;
Alert at 80% quota. Page yourself before the limit, not after.&lt;br&gt;
Cache errors briefly. If an endpoint returns 404 or 5xx, remember that for 30-60 seconds so retries do not burn calls.&lt;br&gt;
Log the source of every response (cache, stale, network). It makes debugging trivial.&lt;br&gt;
Watch the changelog. Field changes can silently break cached payload shapes. Follow the changelog and version your cache keys (v1:).&lt;br&gt;
Know when to upgrade. If your hit rate is above 98% and you still run out, you have outgrown the free tier. That is a good problem, and the comparison pages show how providers stack up.&lt;/p&gt;

&lt;p&gt;Wrapping up&lt;/p&gt;

&lt;p&gt;Surviving a tiny quota is not about clever tricks. It is about never asking the same question twice:&lt;/p&gt;

&lt;p&gt;Give each data type its own TTL&lt;br&gt;
Collapse duplicate requests with single-flight&lt;br&gt;
Serve stale data on purpose&lt;br&gt;
Guard the budget with a live reserve&lt;br&gt;
Store immutable data forever&lt;br&gt;
Warm caches from schedules, not users&lt;br&gt;
Push, don’t poll, for live data&lt;/p&gt;

&lt;p&gt;Do these and 150 calls a day can comfortably serve thousands of users.&lt;/p&gt;

&lt;p&gt;Want to try it on real data? Request access, and keep the glossary and odds converter tools handy if you plan to add the Odds API later.&lt;/p&gt;

&lt;p&gt;What caching trick saved your quota? Tell me in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>redis</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Live Score Widget in React: Embed or Build It in 10 Minutes</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:26:47 +0000</pubDate>
      <link>https://dev.to/orbistats/live-score-widget-in-react-embed-or-build-it-in-10-minutes-2dn4</link>
      <guid>https://dev.to/orbistats/live-score-widget-in-react-embed-or-build-it-in-10-minutes-2dn4</guid>
      <description>&lt;p&gt;Liquid syntax error: Variable '{{% raw %}' was not properly terminated with regexp: /\}\}/&lt;/p&gt;
</description>
      <category>react</category>
      <category>javascript</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Odds Converter in JS &amp; Python: Decimal, American, Fractional</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:18:52 +0000</pubDate>
      <link>https://dev.to/orbistats/odds-converter-in-js-python-decimal-american-fractional-2gc7</link>
      <guid>https://dev.to/orbistats/odds-converter-in-js-python-decimal-american-fractional-2gc7</guid>
      <description>&lt;p&gt;Open any two sports betting sites and you will probably see the same price written in two different ways. One shows 2.50, another shows +150, and a third shows 3/2. A British horse racing site, a US NFL app and a European football portal can all be describing the exact same bet, and your code has to make sense of all three.&lt;/p&gt;

&lt;p&gt;If you are building a sports app, an odds comparison page, a trading dashboard or a betting model, an odds converter is one of the first utilities you will write. It looks trivial. It is not. Rounding, ambiguous inputs, the "even money" edge case, floating point errors and bookmaker margin all show up the moment you ship it to real users.&lt;/p&gt;

&lt;p&gt;In this guide we will build a production-minded odds converter in both JavaScript and Python. Along the way you will learn:&lt;/p&gt;

&lt;p&gt;how decimal, American and fractional odds relate to each other (with the exact formulas)&lt;br&gt;
how to calculate implied probability from any format&lt;br&gt;
how to measure the bookmaker's margin (the overround or vig) and strip it out to get fair probabilities&lt;br&gt;
the edge cases that break naive converters&lt;br&gt;
how to wire the converter to a real sports odds API so users see prices in the format they prefer&lt;/p&gt;

&lt;p&gt;Everything below was run and tested: 23 pytest cases for Python and 8 node:test cases for JavaScript, including a Python/JS parity check so both implementations return identical results. If you just want to try the maths in a browser first, Orbistats has a free odds converter you can poke at while you read.&lt;/p&gt;

&lt;p&gt;A quick note. This article is about data handling and engineering. It is not betting advice, and betting is for adults (18+, or the legal age where you live) only. Please gamble responsibly.&lt;/p&gt;

&lt;p&gt;TL;DR: the formulas&lt;/p&gt;

&lt;p&gt;Keep this table handy. Everything else in the article is just these six lines done carefully.&lt;/p&gt;

&lt;p&gt;From    To  Formula&lt;br&gt;
American &amp;gt; 0    Decimal 1 + american / 100&lt;br&gt;
American &amp;lt; 0    Decimal 1 + 100 / abs(american)&lt;br&gt;
Fractional n/d  Decimal 1 + n / d&lt;br&gt;
Decimal &amp;gt;= 2.0  American    (decimal - 1) * 100&lt;br&gt;
Decimal &amp;lt; 2.0   American    -100 / (decimal - 1)&lt;br&gt;
Decimal Implied probability 1 / decimal&lt;/p&gt;

&lt;p&gt;The big idea is to pick one internal format and convert only at the edges. We will use decimal odds internally, because every other format converts to and from decimal with one line of maths, and because probabilities and payouts are trivial in decimal.&lt;/p&gt;

&lt;p&gt;Why three odds formats exist in the first place&lt;/p&gt;

&lt;p&gt;Odds are a price: they tell you how much you win relative to what you stake. The three common formats are simply three conventions for writing that ratio.&lt;/p&gt;

&lt;p&gt;Decimal odds (also called European odds) are the total return per 1 unit staked, including your stake. At 2.50, a 10 unit stake returns 25 units, which is 15 profit plus your 10 back. This is the default in continental Europe, Australia, Canada and most modern odds feeds, and it is the format football data for 1X2 markets is usually published in.&lt;/p&gt;

&lt;p&gt;American odds (moneyline odds) are anchored to 100 units. A positive number such as +150 is the profit on a 100 unit stake. A negative number such as -110 is the stake you need to risk to win 100. Because US sportsbooks think in these terms, they dominate American football, basketball and baseball pricing.&lt;/p&gt;

&lt;p&gt;Fractional odds are the traditional UK and Ireland format: 5/2 means you win 5 for every 2 staked. They are still the standard on horse racing cards, and you will also see them in futures markets and in many UK bookmaker apps.&lt;/p&gt;

&lt;p&gt;There are other formats (Hong Kong, Malay, Indonesian), but if you can handle the big three and probabilities, the rest are small variations. If any of the terms in this article are new to you, the odds and implied probability glossary explains them in plain English.&lt;/p&gt;

&lt;p&gt;Step 1: Understand the maths with one worked example&lt;/p&gt;

&lt;p&gt;Let's use a real-looking football price: decimal 3.40 for an away win.&lt;/p&gt;

&lt;p&gt;Decimal: 3.40. A 10 stake returns 34.&lt;br&gt;
American: since 3.40 is above 2.0, (3.40 - 1) * 100 = +240.&lt;br&gt;
Fractional: 3.40 - 1 = 2.40, and 2.40 as a fraction is 12/5.&lt;br&gt;
Implied probability: 1 / 3.40 = 0.2941, or 29.41%.&lt;/p&gt;

&lt;p&gt;Now go the other way. If someone hands you +240, you compute 1 + 240/100 = 3.40. If they hand you 12/5, you compute 1 + 12/5 = 3.40. Same price, three costumes.&lt;/p&gt;

&lt;p&gt;Here is the small reference table the code below produces. Use it as a sanity check for your own implementation:&lt;/p&gt;

&lt;p&gt;Decimal American    Fractional  Implied probability&lt;br&gt;
1.20    -500    1/5 83.33%&lt;br&gt;
1.50    -200    1/2 66.67%&lt;br&gt;
1.91    -110    10/11   52.36%&lt;br&gt;
2.50    +150    3/2 40.00%&lt;br&gt;
3.40    +240    12/5    29.41%&lt;br&gt;
4.20    +320    16/5    23.81%&lt;br&gt;
11.00   +1000   10/1    9.09%&lt;/p&gt;

&lt;p&gt;Notice that 1.91 is the famous -110 line. It is the standard price on a two-way US market, and it is not a coincidence that the implied probability of 52.36% is over 50%. We will come back to that when we talk about the margin.&lt;/p&gt;

&lt;p&gt;Step 2: The Python converter&lt;/p&gt;

&lt;p&gt;Create a file called odds.py. We use Python's built-in fractions.Fraction to turn a decimal into the nicest small fraction, and we validate every input so that bad data fails loudly instead of silently producing nonsense.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
"""odds.py - convert between decimal, American and fractional odds."""&lt;br&gt;
from &lt;strong&gt;future&lt;/strong&gt; import annotations&lt;/p&gt;

&lt;p&gt;import math&lt;br&gt;
from fractions import Fraction&lt;br&gt;
from typing import Sequence&lt;/p&gt;

&lt;p&gt;class OddsError(ValueError):&lt;br&gt;
    """Raised when an odds value is invalid."""&lt;/p&gt;

&lt;p&gt;def _check_decimal(decimal: float) -&amp;gt; float:&lt;br&gt;
    if isinstance(decimal, bool) or not isinstance(decimal, (int, float)):&lt;br&gt;
        raise OddsError(f"decimal odds must be a number, got {decimal!r}")&lt;br&gt;
    if not math.isfinite(decimal) or decimal &amp;lt;= 1.0:&lt;br&gt;
        raise OddsError(f"decimal odds must be a finite number &amp;gt; 1.0, got {decimal!r}")&lt;br&gt;
    return float(decimal)&lt;/p&gt;

&lt;p&gt;def american_to_decimal(american: float) -&amp;gt; float:&lt;br&gt;
    if not math.isfinite(american) or -100 &amp;lt; american &amp;lt; 100:&lt;br&gt;
        raise OddsError(f"American odds must be &amp;lt;= -100 or &amp;gt;= +100, got {american!r}")&lt;br&gt;
    if american &amp;gt; 0:&lt;br&gt;
        return 1 + american / 100&lt;br&gt;
    return 1 + 100 / abs(american)&lt;/p&gt;

&lt;p&gt;def decimal_to_american(decimal: float) -&amp;gt; int:&lt;br&gt;
    d = _check_decimal(decimal)&lt;br&gt;
    if d &amp;gt;= 2.0:&lt;br&gt;
        return round((d - 1) * 100)          # underdog: +150, +250 ...&lt;br&gt;
    return round(-100 / (d - 1))             # favourite: -110, -200 ...&lt;/p&gt;

&lt;p&gt;def fractional_to_decimal(fractional: str) -&amp;gt; float:&lt;br&gt;
    text = fractional.strip().lower()&lt;br&gt;
    if text in {"evens", "evs", "even"}:&lt;br&gt;
        return 2.0&lt;br&gt;
    try:&lt;br&gt;
        num_s, den_s = text.split("/")&lt;br&gt;
        num, den = int(num_s), int(den_s)&lt;br&gt;
    except ValueError as exc:&lt;br&gt;
        raise OddsError(f"fractional odds must look like '5/2', got {fractional!r}") from exc&lt;br&gt;
    if num &amp;lt;= 0 or den &amp;lt;= 0:&lt;br&gt;
        raise OddsError(f"numerator and denominator must be positive, got {fractional!r}")&lt;br&gt;
    return 1 + num / den&lt;/p&gt;

&lt;p&gt;def decimal_to_fractional(decimal: float, max_denominator: int = 20) -&amp;gt; str:&lt;br&gt;
    d = _check_decimal(decimal)&lt;br&gt;
    frac = Fraction(d - 1).limit_denominator(max_denominator)&lt;br&gt;
    if frac == 0:                             # very short prices such as 1.01&lt;br&gt;
        frac = Fraction(d - 1).limit_denominator(1000)&lt;br&gt;
    return f"{frac.numerator}/{frac.denominator}"&lt;/p&gt;

&lt;p&gt;A few design decisions worth explaining:&lt;/p&gt;

&lt;p&gt;Decimal is the pivot. There is no american_to_fractional(). To convert between any two formats you go through decimal. That keeps the number of functions linear instead of quadratic, and it means there is exactly one place where each format's rules live.&lt;/p&gt;

&lt;p&gt;max_denominator=20 is deliberate. The decimal 1.91 is really 91/100 as a fraction, but no bookmaker prints that. They print 10/11. Limiting the denominator gives you the "bookmaker-looking" fraction. If you need an exact fraction, raise the limit.&lt;/p&gt;

&lt;p&gt;The 1.01 fallback matters. At very short prices such as 1.01, limit_denominator(20) rounds the fraction to zero, which would print 0/1. Falling back to a denominator of 1000 returns the correct 1/100.&lt;/p&gt;

&lt;p&gt;Americans between -100 and +100 are invalid. There is no such thing as +50 or -50 in American odds. Those values almost always mean the data is corrupted or in a different format, so we reject them.&lt;/p&gt;

&lt;p&gt;Now the probability helpers, which are the bit most tutorials skip:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def implied_probability(decimal: float) -&amp;gt; float:&lt;br&gt;
    return 1 / _check_decimal(decimal)&lt;/p&gt;

&lt;p&gt;def probability_to_decimal(probability: float) -&amp;gt; float:&lt;br&gt;
    if not 0 &amp;lt; probability &amp;lt; 1:&lt;br&gt;
        raise OddsError(f"probability must be between 0 and 1 (exclusive), got {probability!r}")&lt;br&gt;
    return 1 / probability&lt;/p&gt;

&lt;p&gt;def overround(decimals: Sequence[float]) -&amp;gt; float:&lt;br&gt;
    """Bookmaker margin: sum of implied probabilities minus 1."""&lt;br&gt;
    return sum(implied_probability(d) for d in decimals) - 1&lt;/p&gt;

&lt;p&gt;def remove_vig(decimals: Sequence[float]) -&amp;gt; list[float]:&lt;br&gt;
    """Proportional (multiplicative) de-vig: fair probabilities that sum to 1."""&lt;br&gt;
    probs = [implied_probability(d) for d in decimals]&lt;br&gt;
    total = sum(probs)&lt;br&gt;
    return [p / total for p in probs]&lt;/p&gt;

&lt;p&gt;def to_decimal(value, fmt: str) -&amp;gt; float:&lt;br&gt;
    if fmt == "decimal":&lt;br&gt;
        return _check_decimal(float(value))&lt;br&gt;
    if fmt == "american":&lt;br&gt;
        return american_to_decimal(float(value))&lt;br&gt;
    if fmt == "fractional":&lt;br&gt;
        return fractional_to_decimal(str(value))&lt;br&gt;
    raise OddsError(f"unknown format {fmt!r}")&lt;/p&gt;

&lt;p&gt;def convert_all(decimal: float) -&amp;gt; dict:&lt;br&gt;
    """Every representation of one price - handy for UIs and debugging."""&lt;br&gt;
    return {&lt;br&gt;
        "decimal": round(decimal, 3),&lt;br&gt;
        "american": decimal_to_american(decimal),&lt;br&gt;
        "fractional": decimal_to_fractional(decimal),&lt;br&gt;
        "implied_probability": round(implied_probability(decimal), 4),&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;Try it in a REPL:&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;

&lt;blockquote&gt;
&lt;blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;from odds import convert_all, to_decimal&lt;br&gt;
convert_all(3.40)&lt;br&gt;
{'decimal': 3.4, 'american': 240, 'fractional': '12/5', 'implied_probability': 0.2941}&lt;br&gt;
convert_all(to_decimal("+150", "american"))&lt;br&gt;
{'decimal': 2.5, 'american': 150, 'fractional': '3/2', 'implied_probability': 0.4}&lt;br&gt;
to_decimal("evens", "fractional")&lt;br&gt;
2.0&lt;br&gt;
Step 3: The JavaScript converter&lt;/p&gt;
&lt;/blockquote&gt;


&lt;/blockquote&gt;
&lt;br&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same logic in modern ES modules, with no dependencies. JavaScript has no built-in Fraction, so decimalToFractional searches denominators from 1 up to the limit and keeps the closest match, preferring the smaller denominator on ties. That gives exactly the same answers as Python's limit_denominator.&lt;/p&gt;

&lt;p&gt;Create odds.mjs:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// odds.mjs - convert between decimal, American and fractional odds.&lt;/p&gt;

&lt;p&gt;export class OddsError extends Error {&lt;br&gt;
  constructor(message) {&lt;br&gt;
    super(message);&lt;br&gt;
    this.name = "OddsError";&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const assertDecimal = (d) =&amp;gt; {&lt;br&gt;
  if (typeof d !== "number" || !Number.isFinite(d) || d &amp;lt;= 1) {&lt;br&gt;
    throw new OddsError(&lt;code&gt;decimal odds must be a finite number &amp;gt; 1, got ${d}&lt;/code&gt;);&lt;br&gt;
  }&lt;br&gt;
  return d;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const americanToDecimal = (a) =&amp;gt; {&lt;br&gt;
  if (typeof a !== "number" || !Number.isFinite(a) || (a &amp;gt; -100 &amp;amp;&amp;amp; a &amp;lt; 100)) {&lt;br&gt;
    throw new OddsError(&lt;code&gt;American odds must be &amp;lt;= -100 or &amp;gt;= +100, got ${a}&lt;/code&gt;);&lt;br&gt;
  }&lt;br&gt;
  return a &amp;gt; 0 ? 1 + a / 100 : 1 + 100 / Math.abs(a);&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const decimalToAmerican = (d) =&amp;gt; {&lt;br&gt;
  assertDecimal(d);&lt;br&gt;
  return d &amp;gt;= 2 ? Math.round((d - 1) * 100) : Math.round(-100 / (d - 1));&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const fractionalToDecimal = (text) =&amp;gt; {&lt;br&gt;
  const t = String(text).trim().toLowerCase();&lt;br&gt;
  if (["evens", "evs", "even"].includes(t)) return 2;&lt;br&gt;
  const m = /^(\d+)\s*\/\s*(\d+)$/.exec(t);&lt;br&gt;
  if (!m) throw new OddsError(&lt;code&gt;fractional odds must look like "5/2", got "${text}"&lt;/code&gt;);&lt;br&gt;
  const [num, den] = [Number(m[1]), Number(m[2])];&lt;br&gt;
  if (num &amp;lt;= 0 || den &amp;lt;= 0) throw new OddsError(&lt;code&gt;numerator and denominator must be positive: "${text}"&lt;/code&gt;);&lt;br&gt;
  return 1 + num / den;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;// Best fraction with a denominator &amp;lt;= maxDen (smallest denominator wins ties).&lt;br&gt;
export const decimalToFractional = (d, maxDen = 20) =&amp;gt; {&lt;br&gt;
  assertDecimal(d);&lt;br&gt;
  const target = d - 1;&lt;br&gt;
  const search = (limit) =&amp;gt; {&lt;br&gt;
    let best = { num: 1, den: 1, err: Infinity };&lt;br&gt;
    for (let den = 1; den &amp;lt;= limit; den++) {&lt;br&gt;
      const num = Math.round(target * den);&lt;br&gt;
      if (num &amp;lt;= 0) continue;&lt;br&gt;
      const err = Math.abs(target - num / den);&lt;br&gt;
      if (err &amp;lt; best.err - 1e-12) best = { num, den, err };&lt;br&gt;
    }&lt;br&gt;
    return best;&lt;br&gt;
  };&lt;br&gt;
  const best = Number.isFinite(search(maxDen).err) ? search(maxDen) : search(1000);&lt;br&gt;
  return &lt;code&gt;${best.num}/${best.den}&lt;/code&gt;;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const impliedProbability = (d) =&amp;gt; 1 / assertDecimal(d);&lt;/p&gt;

&lt;p&gt;export const overround = (decimals) =&amp;gt;&lt;br&gt;
  decimals.reduce((sum, d) =&amp;gt; sum + impliedProbability(d), 0) - 1;&lt;/p&gt;

&lt;p&gt;export const removeVig = (decimals) =&amp;gt; {&lt;br&gt;
  const probs = decimals.map(impliedProbability);&lt;br&gt;
  const total = probs.reduce((a, b) =&amp;gt; a + b, 0);&lt;br&gt;
  return probs.map((p) =&amp;gt; p / total);&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;// Guess the format of a user-typed string. Bare integers are ambiguous on purpose.&lt;br&gt;
export const detectFormat = (raw) =&amp;gt; {&lt;br&gt;
  const s = String(raw).trim();&lt;br&gt;
  if (s.includes("/") || /^(evens|evs|even)$/i.test(s)) return "fractional";&lt;br&gt;
  if (/^[+-]\d+$/.test(s)) return "american";&lt;br&gt;
  if (/^\d+([.,]\d+)?$/.test(s) &amp;amp;&amp;amp; /[.,]/.test(s)) return "decimal";&lt;br&gt;
  throw new OddsError(&lt;code&gt;cannot tell the format of "${raw}" - pass it explicitly&lt;/code&gt;);&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const toDecimal = (value, format = detectFormat(value)) =&amp;gt; {&lt;br&gt;
  switch (format) {&lt;br&gt;
    case "decimal": return assertDecimal(Number(String(value).replace(",", ".")));&lt;br&gt;
    case "american": return americanToDecimal(Number(value));&lt;br&gt;
    case "fractional": return fractionalToDecimal(value);&lt;br&gt;
    default: throw new OddsError(&lt;code&gt;unknown format "${format}"&lt;/code&gt;);&lt;br&gt;
  }&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const convertAll = (d) =&amp;gt; ({&lt;br&gt;
  decimal: Number(d.toFixed(3)),&lt;br&gt;
  american: decimalToAmerican(d),&lt;br&gt;
  fractional: decimalToFractional(d),&lt;br&gt;
  impliedProbability: Number(impliedProbability(d).toFixed(4)),&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;And in Node:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import { convertAll, toDecimal } from "./odds.mjs";&lt;/p&gt;

&lt;p&gt;console.log(convertAll(3.4));&lt;br&gt;
// { decimal: 3.4, american: 240, fractional: '12/5', impliedProbability: 0.2941 }&lt;/p&gt;

&lt;p&gt;console.log(toDecimal("+150"));   // 2.5   (format auto-detected: American)&lt;br&gt;
console.log(toDecimal("5/2"));    // 3.5   (fractional)&lt;br&gt;
console.log(toDecimal("2,50"));   // 2.5   (European decimal comma)&lt;br&gt;
Why detectFormat refuses to guess "150"&lt;/p&gt;

&lt;p&gt;Look at the detectFormat function again. A string like "+150" is clearly American, and "5/2" is clearly fractional. But what is a bare "150"? It could be American +150 typed without the sign, or an absurdly long decimal price. We throw an error instead of guessing. In a betting context a silent wrong guess is far worse than a loud failure: if you read 150 as decimal you have just priced a 1% outcome as a near-certainty. When you control the UI, always have the user (or the data source) tell you the format explicitly.&lt;/p&gt;

&lt;p&gt;Step 4: Test it properly&lt;/p&gt;

&lt;p&gt;Odds code is exactly the sort of thing that looks right and is subtly wrong. Write tests before you trust it. The most valuable tests are round trips and known reference values.&lt;/p&gt;

&lt;p&gt;Python (test_odds.py, run with pytest):&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import pytest&lt;br&gt;
from odds import *&lt;/p&gt;

&lt;p&gt;@pytest.mark.parametrize("american,decimal", [&lt;br&gt;
    (150, 2.5), (-110, 1.9091), (100, 2.0), (-100, 2.0), (-200, 1.5), (250, 3.5),&lt;br&gt;
])&lt;br&gt;
def test_american_to_decimal(american, decimal):&lt;br&gt;
    assert american_to_decimal(american) == pytest.approx(decimal, abs=1e-4)&lt;/p&gt;

&lt;p&gt;@pytest.mark.parametrize("decimal,frac", [&lt;br&gt;
    (3.5, "5/2"), (1.91, "10/11"), (2.0, "1/1"), (1.25, "1/4"), (1.01, "1/100"),&lt;br&gt;
])&lt;br&gt;
def test_decimal_to_fractional(decimal, frac):&lt;br&gt;
    assert decimal_to_fractional(decimal) == frac&lt;/p&gt;

&lt;p&gt;def test_round_trip_american():&lt;br&gt;
    for a in [-1000, -250, -110, -101, 100, 105, 150, 400, 1200]:&lt;br&gt;
        assert decimal_to_american(american_to_decimal(a)) == a&lt;/p&gt;

&lt;p&gt;def test_invalid_inputs():&lt;br&gt;
    for bad in [0, 50, -50]:&lt;br&gt;
        with pytest.raises(OddsError):&lt;br&gt;
            american_to_decimal(bad)&lt;br&gt;
    for bad in [1.0, 0.5, -3, float("nan")]:&lt;br&gt;
        with pytest.raises(OddsError):&lt;br&gt;
            decimal_to_american(bad)&lt;br&gt;
    with pytest.raises(OddsError):&lt;br&gt;
        fractional_to_decimal("2.5")   # that's decimal, not fractional&lt;/p&gt;

&lt;p&gt;JavaScript (odds.test.mjs, run with node --test):&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import test from "node:test";&lt;br&gt;
import assert from "node:assert/strict";&lt;br&gt;
import * as o from "./odds.mjs";&lt;/p&gt;

&lt;p&gt;test("round trip american", () =&amp;gt; {&lt;br&gt;
  for (const a of [-1000, -250, -110, -101, 100, 105, 150, 400, 1200]) {&lt;br&gt;
    assert.equal(o.decimalToAmerican(o.americanToDecimal(a)), a);&lt;br&gt;
  }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("python parity", () =&amp;gt; {&lt;br&gt;
  assert.deepEqual(o.convertAll(3.4), {&lt;br&gt;
    decimal: 3.4, american: 240, fractional: "12/5", impliedProbability: 0.2941,&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("invalid input", () =&amp;gt; {&lt;br&gt;
  assert.throws(() =&amp;gt; o.americanToDecimal(50), o.OddsError);&lt;br&gt;
  assert.throws(() =&amp;gt; o.detectFormat("150"), o.OddsError);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;The "parity" test is a nice trick if you maintain both a backend (Python) and a frontend (JavaScript). Pin the same few known outputs in both test suites and you will catch drift the day somebody "improves" one implementation.&lt;/p&gt;

&lt;p&gt;Step 5: Implied probability, the overround and fair odds&lt;/p&gt;

&lt;p&gt;Converting formats is the easy half. The half that makes your app genuinely useful is understanding what a price is telling you about probability.&lt;/p&gt;

&lt;p&gt;For decimal odds, implied probability = 1 / decimal. A price of 2.00 implies 50%. A price of 4.00 implies 25%.&lt;/p&gt;

&lt;p&gt;Now take a three-way football market (home / draw / away) with these decimal prices:&lt;/p&gt;

&lt;p&gt;Outcome Decimal Implied probability&lt;br&gt;
Home    1.91    52.36%&lt;br&gt;
Draw    3.40    29.41%&lt;br&gt;
Away    4.20    23.81%&lt;br&gt;
Total       105.58%&lt;/p&gt;

&lt;p&gt;A real market's outcomes are mutually exclusive and exhaustive, so true probabilities must add up to exactly 100%. These add up to 105.58%. The extra 5.58% is the bookmaker's built-in margin, called the overround (or vig or juice in the US). It is how bookmakers get paid regardless of the result.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from odds import overround, remove_vig, probability_to_decimal&lt;/p&gt;

&lt;p&gt;market = [1.91, 3.40, 4.20]&lt;/p&gt;

&lt;p&gt;print(f"overround: {overround(market):.2%}")           # overround: 5.58%&lt;/p&gt;

&lt;p&gt;fair = remove_vig(market)&lt;br&gt;
print([f"{p:.2%}" for p in fair])                      # ['49.59%', '27.86%', '22.55%']&lt;br&gt;
print([round(probability_to_decimal(p), 2) for p in fair])   # [2.02, 3.59, 4.43]&lt;/p&gt;

&lt;p&gt;The method used here is the proportional (multiplicative) de-vig: divide each implied probability by the total. It is simple, fast and a perfectly good default. Be aware that it spreads the margin evenly across outcomes. Real bookmakers often load more margin onto longshots, so more advanced methods (power method, Shin's method, additive) exist and can give slightly different fair probabilities. For a first version, proportional is the right call. Just don't present the output as "the truth", present it as "the market's margin-free estimate".&lt;/p&gt;

&lt;p&gt;The same maths in JavaScript:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import { overround, removeVig } from "./odds.mjs";&lt;/p&gt;

&lt;p&gt;const market = [1.91, 3.4, 4.2];&lt;br&gt;
console.log((overround(market) * 100).toFixed(2) + "%");              // 5.58%&lt;br&gt;
console.log(removeVig(market).map((p) =&amp;gt; (p * 100).toFixed(2) + "%")); // [ '49.59%', '27.86%', '22.55%' ]&lt;/p&gt;

&lt;p&gt;Why does this matter for your product? A few examples:&lt;/p&gt;

&lt;p&gt;Odds comparison pages can show "best price" and the margin, so users see which bookmaker is genuinely cheapest.&lt;br&gt;
Trading dashboards compare each book's fair probabilities against a sharp reference line.&lt;br&gt;
Models and backtests need fair probabilities, not margin-inflated ones, to measure calibration. We touch on that near the end.&lt;br&gt;
Step 6: Edge cases that break naive converters&lt;/p&gt;

&lt;p&gt;Here is the checklist I wish I had before shipping my first converter.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Even money has three faces. 2.00, +100, -100 and 1/1 ("evens") are all the same price. Make sure -100 and +100 both convert to 2.0, and decide which one you print on the way out. In this article 2.0 prints as +100 and 1/1. Both fractions and Americans round-trip fine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don't use floats for money. Our converter returns floats, which is fine for prices and probabilities. The moment you calculate a payout or stake, switch to Decimal in Python, or integer minor units (cents) in JavaScript:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;python&lt;br&gt;
from decimal import Decimal, ROUND_HALF_UP&lt;/p&gt;

&lt;p&gt;def payout(stake: Decimal, decimal_odds: Decimal) -&amp;gt; Decimal:&lt;br&gt;
    return (stake * decimal_odds).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)&lt;/p&gt;

&lt;p&gt;payout(Decimal("10.00"), Decimal("2.50"))   # Decimal('25.00')&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Rounding is a product decision. American odds are integers, so 1.91 becomes -110 (really -109.89). Converting back gives 1.9091, not 1.91. Never compare a converted-and-back value with ==; compare within a tolerance, as in our tests. Also note that Python's round() uses banker's rounding on exact halves, while JavaScript's Math.round() rounds .5 toward positive infinity. For real prices the difference almost never appears, but now you know where to look if a value is off by one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Locale decimal commas. In much of Europe 2,50 is the decimal price. Parse input with care (our toDecimal swaps the comma for a dot) and never let parseFloat("2,50") quietly return 2.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Minimum prices. Decimal odds must be greater than 1.0. A decimal of exactly 1.00 means you win nothing. Some feeds send 0 or null for suspended markets. Reject them or you will end up dividing by zero.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Suspended and missing markets. When odds disappear mid-match (a goal was scored, the market is suspended), your UI should show "suspended", not a stale or converted zero. Treat null/undefined as a state, not as a number.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cache the formatted value, not the format. Store and cache decimal prices. Format at render time based on the user's preference. If you cache "-110" strings you will have to invalidate them whenever a user flips their format setting.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 7: Plug the converter into a real odds API&lt;/p&gt;

&lt;p&gt;A converter is most useful when it sits between a live odds feed and the user's screen. The feed gives you one canonical format (usually decimal), and your converter shows each user the format they expect: American for a US visitor, fractional for UK racing fans, decimal for everyone else.&lt;/p&gt;

&lt;p&gt;For a data source we will use the Orbistats Odds API, which returns pre-match and live odds normalized into one format across sportsbooks. That is the point of an odds API: you do not write a parser per bookmaker. Per the odds endpoint reference, the endpoint is:&lt;/p&gt;

&lt;p&gt;GET &lt;a href="https://api.orbistats.com/v1/%7Bsport%7D/odds?match_id=%7Bfixture_id%7D&amp;amp;market=1x2" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/{sport}/odds?match_id={fixture_id}&amp;amp;market=1x2&lt;/a&gt;&lt;br&gt;
Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;and a 1X2 response looks like this (all Orbistats responses share a data / meta / errors envelope):&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "data": { "market": "1X2", "home": 1.91, "draw": 3.40, "away": 4.20 }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The odds endpoint is listed on the Growth+ plan in the API reference, so check the pricing page for the current plan details. The converter itself works fully offline, so you can follow every earlier step without a key. If you want to explore responses before writing any code, the API sandbox lets you try endpoints against sample data, and the quickstart guide walks you from signup to first request.&lt;/p&gt;

&lt;p&gt;Python client&lt;br&gt;
python&lt;br&gt;
import os&lt;br&gt;
import requests&lt;br&gt;
from odds import convert_all, overround&lt;/p&gt;

&lt;p&gt;BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;def get_odds(sport: str, match_id: str, market: str = "1x2") -&amp;gt; dict:&lt;br&gt;
    resp = requests.get(&lt;br&gt;
        f"{BASE}/{sport}/odds",&lt;br&gt;
        params={"match_id": match_id, "market": market},&lt;br&gt;
        headers={"Authorization": f"Bearer {os.environ['ORBISTATS_API_KEY']}"},&lt;br&gt;
        timeout=10,&lt;br&gt;
    )&lt;br&gt;
    if resp.status_code == 429:&lt;br&gt;
        wait = resp.headers.get("Retry-After", "1")&lt;br&gt;
        raise RuntimeError(f"Rate limited, retry in {wait}s")&lt;br&gt;
    resp.raise_for_status()           # 401 bad key, 403 plan, 404 unknown match ...&lt;br&gt;
    return resp.json()["data"]&lt;/p&gt;

&lt;p&gt;def price_table(odds: dict, style: str = "american") -&amp;gt; dict:&lt;br&gt;
    """Return home/draw/away formatted in the user's preferred style."""&lt;br&gt;
    return {side: convert_all(odds[side])[style] for side in ("home", "draw", "away")}&lt;/p&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    data = get_odds("football", "fx_884213")&lt;br&gt;
    print(price_table(data, "american"))                     # {'home': -110, 'draw': 240, 'away': 320}&lt;br&gt;
    print(price_table(data, "fractional"))                   # {'home': '10/11', 'draw': '12/5', 'away': '16/5'}&lt;br&gt;
    print(f"margin: {overround([data['home'], data['draw'], data['away']]):.2%}")   # margin: 5.58%&lt;br&gt;
JavaScript client&lt;br&gt;
js&lt;br&gt;
import { convertAll, overround } from "./odds.mjs";&lt;/p&gt;

&lt;p&gt;const BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;";&lt;/p&gt;

&lt;p&gt;export async function getOdds(sport, matchId, market = "1x2") {&lt;br&gt;
  const url = new URL(&lt;code&gt;${BASE}/${sport}/odds&lt;/code&gt;);&lt;br&gt;
  url.search = new URLSearchParams({ match_id: matchId, market });&lt;/p&gt;

&lt;p&gt;const res = await fetch(url, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_API_KEY}&lt;/code&gt; },&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;if (res.status === 429) {&lt;br&gt;
    const wait = Number(res.headers.get("Retry-After") ?? 1);&lt;br&gt;
    throw Object.assign(new Error("Rate limited"), { retryAfter: wait });&lt;br&gt;
  }&lt;br&gt;
  if (!res.ok) throw new Error(&lt;code&gt;Orbistats ${res.status}: ${await res.text()}&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;return (await res.json()).data;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export const priceTable = (odds, style = "american") =&amp;gt;&lt;br&gt;
  Object.fromEntries(["home", "draw", "away"].map((s) =&amp;gt; [s, convertAll(odds[s])[style]]));&lt;/p&gt;

&lt;p&gt;// usage&lt;br&gt;
const data = await getOdds("football", "fx_884213");&lt;br&gt;
console.log(priceTable(data, "fractional"));            // { home: '10/11', draw: '12/5', away: '16/5' }&lt;br&gt;
console.log((overround([data.home, data.draw, data.away]) * 100).toFixed(2) + "%");   // 5.58%&lt;/p&gt;

&lt;p&gt;I verified the conversion and margin logic above against the example response using a mocked HTTP layer. Treat the field names as defined by the live API reference, because feeds differ per plan and per market (player props, totals and so on have different shapes). If you use an official client, the SDKs cover Python, JavaScript, TypeScript, PHP, Java, C#, Go and Ruby with the same method names as the REST endpoints.&lt;/p&gt;

&lt;p&gt;Don't poll live odds, subscribe&lt;/p&gt;

&lt;p&gt;Prices move every few seconds during a live match. Polling the REST endpoint in a loop wastes your request quota and still shows stale prices. For live boards, a push connection is the better fit. Orbistats offers a WebSocket API for a persistent low-latency stream and webhooks (including an odds.changed event) that push updates to your server the moment something changes.&lt;/p&gt;

&lt;p&gt;The converter slots straight in, because conversion is O(1) and cheap enough to run on every tick:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import WebSocket from "ws";&lt;br&gt;
import { convertAll } from "./odds.mjs";&lt;/p&gt;

&lt;p&gt;const ws = new WebSocket(&lt;code&gt;wss://stream.orbistats.com/v1?token=${process.env.ORBISTATS_API_KEY}&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;ws.on("open", () =&amp;gt; ws.send(JSON.stringify({ subscribe: "football.live" })));&lt;/p&gt;

&lt;p&gt;ws.on("message", (raw) =&amp;gt; {&lt;br&gt;
  const msg = JSON.parse(raw);&lt;br&gt;
  // Illustrative: adapt these field names to the message shape in the WebSocket docs.&lt;br&gt;
  if (typeof msg.odds?.home === "number") {&lt;br&gt;
    console.log(msg.fixture_id, convertAll(msg.odds.home).american);&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
Where this goes next: backtesting and models&lt;/p&gt;

&lt;p&gt;Once you can turn any price into a probability, a lot of doors open. With multi-season closing odds, you can convert each closing price to a fair probability (remove the vig first), compare it to what actually happened, and build a calibration curve: of all the matches where the market said 60%, did the home team really win about 60% of the time? That is the foundation of most betting-model backtests, and it is exactly the use case the Historical Sports Data API is built for. The loop is always the same: fetch history, convert to probabilities, strip the margin, compare with outcomes.&lt;/p&gt;

&lt;p&gt;Production checklist&lt;/p&gt;

&lt;p&gt;Before you ship your converter, run through this list:&lt;/p&gt;

&lt;p&gt;One internal format (decimal), convert only at the UI edge&lt;br&gt;
 Every function validates input and throws a clear error&lt;br&gt;
 -100 and +100 both map to 2.0&lt;br&gt;
 Bare numbers are never auto-detected as a format&lt;br&gt;
 Money uses Decimal or integer cents, never floats&lt;br&gt;
 Round trips are tested with a tolerance, not ==&lt;br&gt;
 Suspended or null markets render as "suspended", not 0&lt;br&gt;
 Python and JS implementations share a parity test&lt;br&gt;
 The user's preferred format is stored as a setting, not baked into cached data&lt;br&gt;
 You show the overround where it helps users judge value&lt;br&gt;
FAQ&lt;/p&gt;

&lt;p&gt;How do I convert decimal odds to American odds? If the decimal price is 2.00 or higher, subtract 1 and multiply by 100 (2.50 becomes +150). If it is below 2.00, divide -100 by the decimal minus 1 (1.91 becomes -110).&lt;/p&gt;

&lt;p&gt;How do I convert fractional odds to decimal? Divide the numerator by the denominator and add 1. So 5/2 is 1 + 5/2 = 3.50, and "evens" (1/1) is 2.00.&lt;/p&gt;

&lt;p&gt;What is implied probability in betting? It is the chance of an outcome that is suggested by the odds. For decimal odds it is 1 divided by the price, so 4.00 implies 25%. When you add up the implied probabilities for every outcome in a market, the total is usually above 100%, and the surplus is the bookmaker's margin.&lt;/p&gt;

&lt;p&gt;What is the overround (vig)? The overround is the sum of the implied probabilities of all outcomes minus 1. In our football example it is 5.58%. A lower overround means a better price for the bettor.&lt;/p&gt;

&lt;p&gt;Should I store odds as decimal, American or fractional? Store decimal (or the probability) and convert for display. Decimal converts to the others with one formula and works directly in payout and probability maths.&lt;/p&gt;

&lt;p&gt;Which sports does Orbistats cover? Thirteen sports at the time of writing: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing. Depth varies by sport and by endpoint, so check the coverage details before you build.&lt;/p&gt;

&lt;p&gt;Wrapping up&lt;/p&gt;

&lt;p&gt;An odds converter is a small piece of code with a surprising number of ways to go wrong. If you remember only four things, make them these:&lt;/p&gt;

&lt;p&gt;Keep decimal odds as your internal format and convert at the edges.&lt;br&gt;
Validate everything. Reject 0, 1.0, +50 and bare ambiguous numbers.&lt;br&gt;
Convert prices to implied probabilities, and strip the overround before treating them as fair.&lt;br&gt;
Test with round trips and reference values, and share a parity test between languages.&lt;/p&gt;

&lt;p&gt;If you want live prices to feed the converter, you can get a free API key from Orbistats and try the odds endpoints in the sandbox. And if you build something with it, such as an odds comparison page, a margin tracker or a live ticker, I would love to see it in the comments.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Orbistats. Betting involves risk and is for adults only. This article is for educational and engineering purposes and is not betting advice.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a Webhook Receiver for Live Sports Events in Node.js (Retries, Idempotency, Signature Checks)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:11:01 +0000</pubDate>
      <link>https://dev.to/orbistats/build-a-webhook-receiver-for-live-sports-events-in-nodejs-retries-idempotency-signature-checks-301g</link>
      <guid>https://dev.to/orbistats/build-a-webhook-receiver-for-live-sports-events-in-nodejs-retries-idempotency-signature-checks-301g</guid>
      <description>&lt;p&gt;Last month we published a minimal webhook receiver in Go. It ended with a question: how do you handle retries and idempotency? This post is the full answer, in Node.js.&lt;/p&gt;

&lt;p&gt;By the end you’ll have a receiver that:&lt;/p&gt;

&lt;p&gt;Verifies signatures on the raw request bytes, with a constant-time compare and secret rotation&lt;br&gt;
Acknowledges in milliseconds and never does slow work on the request path&lt;br&gt;
Deduplicates retried deliveries, so a goal is never counted twice&lt;br&gt;
Retries failed processing with exponential backoff and jitter, then parks poison messages in a dead-letter state&lt;br&gt;
Reconciles against the REST API, so a missed webhook doesn’t become a permanently wrong score&lt;br&gt;
Ships with tests and a script that simulates signed deliveries locally&lt;/p&gt;

&lt;p&gt;Everything runs on Node 20+ with three dependencies.&lt;/p&gt;

&lt;p&gt;Why webhooks (and when to use something else)&lt;/p&gt;

&lt;p&gt;A webhook means the provider calls you when something happens, so you never poll. That’s ideal for server-side triggers: notifications, score updates in your database, downstream jobs.&lt;/p&gt;

&lt;p&gt;If you need a continuous live stream for a UI, a WebSocket is the better transport, and our 50-line WebSocket scoreboard tutorial shows that side. A common pattern is both: WebSocket for the browser, webhooks for backend logic. Live odds can also arrive over webhooks, as described on the Odds API page.&lt;/p&gt;

&lt;p&gt;Read this before you copy the code&lt;/p&gt;

&lt;p&gt;I want to be upfront about what is verified and what isn’t, because webhook bugs are expensive.&lt;/p&gt;

&lt;p&gt;Topic   Status in this tutorial&lt;br&gt;
Webhooks exist and push events to your endpoint Documented&lt;br&gt;
Registering an endpoint needs an API key    Documented, see the API reference&lt;br&gt;
Event payload field names (type, match_id, minute, team)    Assumed, modeled on the Go tutorial and the documented WebSocket event. Check real payloads in the sandbox&lt;br&gt;
Signature header name, algorithm, encoding  Not assumed. All three are environment variables below. Confirm them in the webhooks docs&lt;br&gt;
Retry schedule and delivery guarantees  Not assumed. The code is built for the safe worst case: at-least-once delivery, duplicates, and out-of-order events&lt;/p&gt;

&lt;p&gt;That last row is the design principle. If your receiver is correct under at-least-once delivery, it’s correct under every stricter guarantee too.&lt;/p&gt;

&lt;p&gt;The architecture&lt;br&gt;
text&lt;br&gt;
  Provider ──POST──▶ /webhooks/orbistats&lt;br&gt;
                          │&lt;br&gt;
              1. verify signature (raw bytes)&lt;br&gt;
              2. parse + validate (zod)&lt;br&gt;
              3. INSERT into inbox  ← idempotency key = PRIMARY KEY&lt;br&gt;
              4. respond 200 immediately&lt;br&gt;
                          │&lt;br&gt;
                          ▼&lt;br&gt;
                  inbox table (durable)&lt;br&gt;
                          │&lt;br&gt;
                 worker: claim → handle → done&lt;br&gt;
                          │ failure&lt;br&gt;
                          ├──▶ retry with backoff&lt;br&gt;
                          └──▶ dead after N attempts&lt;/p&gt;

&lt;p&gt;Safety net:  REST poll ──▶ compare with local state ──▶ log drift&lt;/p&gt;

&lt;p&gt;This is the transactional inbox pattern. The request handler does exactly two things: prove the sender is authentic, and durably write the event. Everything else happens later, off the request path. That one decision removes most webhook failure modes: timeouts, lost events on crash, and double processing.&lt;/p&gt;

&lt;p&gt;Step 1: Project setup&lt;br&gt;
bash&lt;br&gt;
mkdir webhook-receiver &amp;amp;&amp;amp; cd webhook-receiver&lt;br&gt;
npm init -y&lt;br&gt;
npm install express zod better-sqlite3&lt;br&gt;
mkdir src scripts test&lt;/p&gt;

&lt;p&gt;Edit package.json:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "name": "orbistats-webhook-receiver",&lt;br&gt;
  "private": true,&lt;br&gt;
  "type": "module",&lt;br&gt;
  "engines": { "node": "&amp;gt;=20.6" },&lt;br&gt;
  "scripts": {&lt;br&gt;
    "start": "node --env-file=.env src/index.js",&lt;br&gt;
    "send": "node --env-file=.env scripts/send-test-event.js",&lt;br&gt;
    "test": "node --test"&lt;br&gt;
  },&lt;br&gt;
  "dependencies": {&lt;br&gt;
    "better-sqlite3": "^11.0.0",&lt;br&gt;
    "express": "^4.21.0",&lt;br&gt;
    "zod": "^3.23.0"&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;(Versions are those current at writing; use the latest compatible releases.) Node 20.6+ supports --env-file, so we don’t need dotenv.&lt;/p&gt;

&lt;p&gt;Create .env.example and copy it to .env:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
PORT=8080&lt;br&gt;
DB_PATH=webhooks.db&lt;/p&gt;

&lt;h1&gt;
  
  
  Comma-separated: lets you rotate secrets without downtime
&lt;/h1&gt;

&lt;p&gt;WEBHOOK_SECRET=change-me&lt;/p&gt;

&lt;h1&gt;
  
  
  CONFIRM THESE THREE in the webhooks docs. Do not assume.
&lt;/h1&gt;

&lt;p&gt;WEBHOOK_SIGNATURE_HEADER=x-signature&lt;br&gt;
WEBHOOK_SIGNATURE_ENCODING=hex&lt;br&gt;
WEBHOOK_SIGNATURE_PREFIX=&lt;/p&gt;

&lt;h1&gt;
  
  
  Optional: header carrying a unique delivery/event ID, if the docs define one
&lt;/h1&gt;

&lt;p&gt;WEBHOOK_DELIVERY_ID_HEADER=&lt;/p&gt;

&lt;p&gt;MAX_ATTEMPTS=6&lt;br&gt;
ORBISTATS_API_KEY=&lt;br&gt;
Step 2: Configuration&lt;/p&gt;

&lt;p&gt;src/config.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
const required = (name) =&amp;gt; {&lt;br&gt;
  const v = process.env[name];&lt;br&gt;
  if (!v) throw new Error(&lt;code&gt;Missing env var ${name}&lt;/code&gt;);&lt;br&gt;
  return v;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const config = {&lt;br&gt;
  port: Number(process.env.PORT ?? 8080),&lt;br&gt;
  dbPath: process.env.DB_PATH ?? "webhooks.db",&lt;/p&gt;

&lt;p&gt;secrets: required("WEBHOOK_SECRET").split(",").map((s) =&amp;gt; s.trim()),&lt;br&gt;
  signatureHeader: (process.env.WEBHOOK_SIGNATURE_HEADER ?? "x-signature").toLowerCase(),&lt;br&gt;
  signatureEncoding: process.env.WEBHOOK_SIGNATURE_ENCODING ?? "hex", // "hex" | "base64"&lt;br&gt;
  signaturePrefix: process.env.WEBHOOK_SIGNATURE_PREFIX ?? "",        // e.g. "sha256="&lt;br&gt;
  deliveryIdHeader: (process.env.WEBHOOK_DELIVERY_ID_HEADER ?? "").toLowerCase(),&lt;/p&gt;

&lt;p&gt;maxAttempts: Number(process.env.MAX_ATTEMPTS ?? 6),&lt;br&gt;
  apiKey: process.env.ORBISTATS_API_KEY ?? "",&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;Failing at startup on a missing secret is deliberate. A receiver that silently runs without verification is worse than one that refuses to boot.&lt;/p&gt;

&lt;p&gt;Step 3: The inbox table&lt;/p&gt;

&lt;p&gt;src/db.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import Database from "better-sqlite3";&lt;br&gt;
import { config } from "./config.js";&lt;/p&gt;

&lt;p&gt;export const db = new Database(config.dbPath);&lt;br&gt;
db.pragma("journal_mode = WAL");&lt;/p&gt;

&lt;p&gt;db.exec(`&lt;br&gt;
  CREATE TABLE IF NOT EXISTS inbox (&lt;br&gt;
    idempotency_key TEXT PRIMARY KEY,&lt;br&gt;
    received_at     INTEGER NOT NULL,&lt;br&gt;
    event_type      TEXT,&lt;br&gt;
    match_id        TEXT,&lt;br&gt;
    payload         TEXT NOT NULL,&lt;br&gt;
    status          TEXT NOT NULL DEFAULT 'pending',  -- pending | done | dead&lt;br&gt;
    attempts        INTEGER NOT NULL DEFAULT 0,&lt;br&gt;
    next_attempt_at INTEGER NOT NULL,&lt;br&gt;
    last_error      TEXT&lt;br&gt;
  );&lt;br&gt;
  CREATE INDEX IF NOT EXISTS idx_inbox_due ON inbox (status, next_attempt_at);&lt;/p&gt;

&lt;p&gt;-- Domain table: one row per goal event, keyed so duplicates are harmless&lt;br&gt;
  CREATE TABLE IF NOT EXISTS goals (&lt;br&gt;
    idempotency_key TEXT PRIMARY KEY,&lt;br&gt;
    match_id        TEXT NOT NULL,&lt;br&gt;
    team            TEXT,&lt;br&gt;
    minute          INTEGER&lt;br&gt;
  );&lt;br&gt;
`);&lt;/p&gt;

&lt;p&gt;Notice the idempotency key is a primary key. The database, not your application code, guarantees uniqueness, so two concurrent retries can’t both win a race.&lt;/p&gt;

&lt;p&gt;SQLite keeps this tutorial self-contained. The same schema works in Postgres, and I show the multi-worker version later.&lt;/p&gt;

&lt;p&gt;Step 4: Signature verification (on the raw bytes)&lt;/p&gt;

&lt;p&gt;This is where most receivers go wrong, so slow down.&lt;/p&gt;

&lt;p&gt;An HMAC signature is computed over the exact bytes the sender transmitted. If you let a JSON middleware parse the body first and then re-serialize it, key order and whitespace can change and every signature will fail (or, worse, you’ll “fix” it by skipping verification). We capture the raw Buffer and verify that.&lt;/p&gt;

&lt;p&gt;src/signature.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import crypto from "node:crypto";&lt;br&gt;
import { config } from "./config.js";&lt;/p&gt;

&lt;p&gt;export function computeSignature(rawBody, secret, encoding = config.signatureEncoding) {&lt;br&gt;
  return crypto.createHmac("sha256", secret).update(rawBody).digest(encoding);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function verifySignature(rawBody, headerValue) {&lt;br&gt;
  if (!headerValue) return false;&lt;/p&gt;

&lt;p&gt;const received =&lt;br&gt;
    config.signaturePrefix &amp;amp;&amp;amp; headerValue.startsWith(config.signaturePrefix)&lt;br&gt;
      ? headerValue.slice(config.signaturePrefix.length)&lt;br&gt;
      : headerValue;&lt;br&gt;
  const receivedBuf = Buffer.from(received.trim());&lt;/p&gt;

&lt;p&gt;// Try every configured secret so rotation never drops events&lt;br&gt;
  return config.secrets.some((secret) =&amp;gt; {&lt;br&gt;
    const expected = Buffer.from(computeSignature(rawBody, secret));&lt;br&gt;
    return (&lt;br&gt;
      expected.length === receivedBuf.length &amp;amp;&amp;amp;&lt;br&gt;
      crypto.timingSafeEqual(expected, receivedBuf)&lt;br&gt;
    );&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Three details matter here:&lt;/p&gt;

&lt;p&gt;timingSafeEqual, not ===. Plain string comparison returns early at the first mismatch, which leaks timing information. timingSafeEqual throws if lengths differ, so we check length first.&lt;br&gt;
Raw bytes in, always. The function takes a Buffer, never a parsed object.&lt;br&gt;
Multiple secrets. During rotation you deploy WEBHOOK_SECRET=new,old, switch the sender, then drop the old one. Zero downtime.&lt;/p&gt;

&lt;p&gt;If the docs say the signature covers a timestamp plus the body (many providers do this for replay protection), build the signed string accordingly and reject deliveries older than a few minutes:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
// Only if the docs define a timestamp header. Illustrative.&lt;br&gt;
const age = Math.abs(Date.now() / 1000 - Number(timestampHeader));&lt;br&gt;
if (!Number.isFinite(age) || age &amp;gt; 300) return false;&lt;br&gt;
Step 5: Tolerant payload validation&lt;/p&gt;

&lt;p&gt;src/schema.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { z } from "zod";&lt;/p&gt;

&lt;p&gt;export const eventSchema = z&lt;br&gt;
  .object({&lt;br&gt;
    id: z.union([z.string(), z.number()]).optional(),&lt;br&gt;
    type: z.string().optional(),&lt;br&gt;
    event: z.string().optional(), // some payloads name it &lt;code&gt;event&lt;/code&gt;&lt;br&gt;
    match_id: z.union([z.string(), z.number()]).transform(String),&lt;br&gt;
    minute: z.number().int().optional(),&lt;br&gt;
    team: z.string().optional(),&lt;br&gt;
    timestamp: z.string().optional(),&lt;br&gt;
  })&lt;br&gt;
  .passthrough() // keep unknown fields; providers add them over time&lt;br&gt;
  .refine((e) =&amp;gt; e.type || e.event, { message: "payload needs &lt;code&gt;type&lt;/code&gt; or &lt;code&gt;event&lt;/code&gt;" });&lt;/p&gt;

&lt;p&gt;export const eventTypeOf = (e) =&amp;gt; e.type ?? e.event;&lt;/p&gt;

&lt;p&gt;Two choices worth defending:&lt;/p&gt;

&lt;p&gt;.passthrough(): APIs add fields without notice. A strict schema turns a harmless addition into an outage.&lt;br&gt;
Accepting type or event: until you’ve inspected real payloads, being liberal in what you accept costs nothing.&lt;br&gt;
Step 6: The HTTP server and the ACK rules&lt;/p&gt;

&lt;p&gt;src/server.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import express from "express";&lt;br&gt;
import crypto from "node:crypto";&lt;br&gt;
import { config } from "./config.js";&lt;br&gt;
import { db } from "./db.js";&lt;br&gt;
import { verifySignature } from "./signature.js";&lt;br&gt;
import { eventSchema, eventTypeOf } from "./schema.js";&lt;/p&gt;

&lt;p&gt;const insertInbox = db.prepare(&lt;code&gt;&lt;br&gt;
  INSERT INTO inbox (idempotency_key, received_at, event_type, match_id, payload, next_attempt_at)&lt;br&gt;
  VALUES (@key, @now, @type, @matchId, @payload, @now)&lt;br&gt;
  ON CONFLICT(idempotency_key) DO NOTHING&lt;br&gt;
&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;const insertDead = db.prepare(&lt;code&gt;&lt;br&gt;
  INSERT INTO inbox (idempotency_key, received_at, payload, status, next_attempt_at, last_error)&lt;br&gt;
  VALUES (@key, @now, @payload, 'dead', @now, @err)&lt;br&gt;
  ON CONFLICT(idempotency_key) DO NOTHING&lt;br&gt;
&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;export function idempotencyKey(req, rawBody, parsed) {&lt;br&gt;
  // 1. A delivery/event ID header, if the docs define one&lt;br&gt;
  if (config.deliveryIdHeader) {&lt;br&gt;
    const h = req.get(config.deliveryIdHeader);&lt;br&gt;
    if (h) return &lt;code&gt;hdr:${h}&lt;/code&gt;;&lt;br&gt;
  }&lt;br&gt;
  // 2. An ID inside the payload&lt;br&gt;
  if (parsed?.id !== undefined) return &lt;code&gt;id:${parsed.id}&lt;/code&gt;;&lt;br&gt;
  // 3. Fallback: hash of the exact bytes (retries resend identical bodies)&lt;br&gt;
  return &lt;code&gt;sha:${crypto.createHash("sha256").update(rawBody).digest("hex")}&lt;/code&gt;;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function createApp() {&lt;br&gt;
  const app = express();&lt;br&gt;
  app.disable("x-powered-by");&lt;/p&gt;

&lt;p&gt;app.get("/healthz", (_req, res) =&amp;gt; res.json({ ok: true }));&lt;/p&gt;

&lt;p&gt;app.post(&lt;br&gt;
    "/webhooks/orbistats",&lt;br&gt;
    express.raw({ type: "&lt;em&gt;/&lt;/em&gt;", limit: "256kb" }), // raw Buffer, not parsed JSON&lt;br&gt;
    (req, res) =&amp;gt; {&lt;br&gt;
      const rawBody = req.body;&lt;br&gt;
      if (!Buffer.isBuffer(rawBody) || rawBody.length === 0) {&lt;br&gt;
        return res.status(400).json({ error: "empty body" });&lt;br&gt;
      }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  // 1. Authenticity first. Nothing below runs for forged requests.
  if (!verifySignature(rawBody, req.get(config.signatureHeader))) {
    return res.status(401).json({ error: "invalid signature" });
  }

  // 2. Parse and validate
  const text = rawBody.toString("utf8");
  let parsed;
  let parseError;
  try {
    const result = eventSchema.safeParse(JSON.parse(text));
    if (result.success) parsed = result.data;
    else parseError = result.error.message;
  } catch (e) {
    parseError = `invalid JSON: ${e.message}`;
  }

  const key = idempotencyKey(req, rawBody, parsed);
  const now = Date.now();

  try {
    // 3. Authentic but unusable: keep it for inspection, but still ACK
    if (!parsed) {
      insertDead.run({ key, now, payload: text, err: parseError });
      return res.status(200).json({ status: "stored_unparsed" });
    }

    // 4. Durable write. `changes` is 0 when the key already exists.
    const info = insertInbox.run({
      key,
      now,
      type: eventTypeOf(parsed),
      matchId: parsed.match_id,
      payload: text,
    });
    return res.status(200).json({ status: info.changes === 1 ? "accepted" : "duplicate" });
  } catch (err) {
    // We could not persist: tell the sender to retry later
    console.error(JSON.stringify({ level: "error", msg: "inbox write failed", err: err.message }));
    return res.status(500).json({ error: "temporary failure" });
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;);&lt;/p&gt;

&lt;p&gt;return app;&lt;br&gt;
}&lt;br&gt;
The status-code rules (this is the heart of reliable webhooks)&lt;br&gt;
Situation   Respond Why&lt;br&gt;
Bad or missing signature    401 Not from the provider. Retrying won’t help and shouldn’t&lt;br&gt;
Authentic event, written to inbox   200 You own it now. Process later&lt;br&gt;
Authentic duplicate 200 The sender retried because it missed your last ACK. Acknowledge so retries stop&lt;br&gt;
Authentic event you can’t parse or don’t recognize  200, store it   Rejecting authentic traffic invites pointless retries and can get your endpoint disabled&lt;br&gt;
You failed to write to the database 500 Genuinely temporary. You want a retry&lt;/p&gt;

&lt;p&gt;The most common mistake is returning an error for a duplicate. The sender is retrying precisely because it didn’t see your 200. Answering the retry with an error just extends the loop.&lt;/p&gt;

&lt;p&gt;Also notice what’s absent: no business logic in the handler. Slow handlers cause timeouts, timeouts cause retries, and retries cause duplicates. Keeping the request path to “verify, write, ACK” breaks that chain.&lt;/p&gt;

&lt;p&gt;Step 7: Idempotent handlers&lt;/p&gt;

&lt;p&gt;src/handlers.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { db } from "./db.js";&lt;/p&gt;

&lt;p&gt;const insertGoal = db.prepare(&lt;code&gt;&lt;br&gt;
  INSERT INTO goals (idempotency_key, match_id, team, minute)&lt;br&gt;
  VALUES (?, ?, ?, ?)&lt;br&gt;
  ON CONFLICT(idempotency_key) DO NOTHING&lt;br&gt;
&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;export const scoreboard = db.prepare(&lt;code&gt;&lt;br&gt;
  SELECT team, COUNT(*) AS goals FROM goals WHERE match_id = ? GROUP BY team&lt;br&gt;
&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;// event type -&amp;gt; handler. Add more as you inspect real payloads.&lt;br&gt;
const handlers = {&lt;br&gt;
  "match.goal": (event, key) =&amp;gt; {&lt;br&gt;
    insertGoal.run(key, event.match_id, event.team ?? null, event.minute ?? null);&lt;br&gt;
  },&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export async function handleEvent(event, key) {&lt;br&gt;
  const handler = handlers[event.type];&lt;br&gt;
  if (!handler) {&lt;br&gt;
    console.log(JSON.stringify({ level: "info", msg: "no handler", type: event.type }));&lt;br&gt;
    return; // unknown types are not errors&lt;br&gt;
  }&lt;br&gt;
  await handler(event, key);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Two principles at work:&lt;/p&gt;

&lt;p&gt;The handler is idempotent too. Even if the inbox dedupe somehow failed, ON CONFLICT DO NOTHING on the goal row makes a second run harmless. Defense in depth: assume each layer can fail.&lt;br&gt;
Store events, derive state. We don’t keep a mutable “score” counter that we += 1. We store goal rows and compute the score with a query. If a correction ever arrives (a goal disallowed after review), you delete or void one row and the score fixes itself. Patching counters is how scores drift permanently.&lt;br&gt;
Step 8: The worker, with retries and a dead-letter state&lt;/p&gt;

&lt;p&gt;src/worker.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { db } from "./db.js";&lt;br&gt;
import { config } from "./config.js";&lt;br&gt;
import { eventSchema, eventTypeOf } from "./schema.js";&lt;br&gt;
import { handleEvent } from "./handlers.js";&lt;/p&gt;

&lt;p&gt;const due = db.prepare(&lt;code&gt;&lt;br&gt;
  SELECT idempotency_key, payload, attempts FROM inbox&lt;br&gt;
  WHERE status = 'pending' AND next_attempt_at &amp;lt;= ?&lt;br&gt;
  ORDER BY received_at&lt;br&gt;
  LIMIT 20&lt;br&gt;
&lt;/code&gt;);&lt;br&gt;
const markDone  = db.prepare(&lt;code&gt;UPDATE inbox SET status = 'done', last_error = NULL WHERE idempotency_key = ?&lt;/code&gt;);&lt;br&gt;
const markRetry = db.prepare(&lt;code&gt;UPDATE inbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE idempotency_key = ?&lt;/code&gt;);&lt;br&gt;
const markDead  = db.prepare(&lt;code&gt;UPDATE inbox SET status = 'dead', attempts = ?, last_error = ? WHERE idempotency_key = ?&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;// 2s, 4s, 8s ... capped at 5 min, with jitter so retries don't synchronize&lt;br&gt;
export function backoffMs(attempt) {&lt;br&gt;
  const base = Math.min(2 ** attempt * 1000, 5 * 60_000);&lt;br&gt;
  return base / 2 + Math.random() * (base / 2);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export async function drainOnce(now = Date.now()) {&lt;br&gt;
  const rows = due.all(now);&lt;br&gt;
  for (const row of rows) {&lt;br&gt;
    try {&lt;br&gt;
      const event = eventSchema.parse(JSON.parse(row.payload));&lt;br&gt;
      await handleEvent({ ...event, type: eventTypeOf(event) }, row.idempotency_key);&lt;br&gt;
      markDone.run(row.idempotency_key);&lt;br&gt;
    } catch (err) {&lt;br&gt;
      const attempts = row.attempts + 1;&lt;br&gt;
      const msg = String(err.message).slice(0, 500);&lt;br&gt;
      if (attempts &amp;gt;= config.maxAttempts) {&lt;br&gt;
        markDead.run(attempts, msg, row.idempotency_key);&lt;br&gt;
        console.error(JSON.stringify({ level: "error", msg: "event dead-lettered", key: row.idempotency_key, err: msg }));&lt;br&gt;
      } else {&lt;br&gt;
        markRetry.run(attempts, now + backoffMs(attempts), msg, row.idempotency_key);&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
  return rows.length;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;let running = false;&lt;br&gt;
export function startWorker(intervalMs = 500) {&lt;br&gt;
  const timer = setInterval(async () =&amp;gt; {&lt;br&gt;
    if (running) return; // never overlap runs&lt;br&gt;
    running = true;&lt;br&gt;
    try {&lt;br&gt;
      while ((await drainOnce()) &amp;gt; 0) { /* keep draining while there's work */ }&lt;br&gt;
    } catch (e) {&lt;br&gt;
      console.error("worker error", e);&lt;br&gt;
    } finally {&lt;br&gt;
      running = false;&lt;br&gt;
    }&lt;br&gt;
  }, intervalMs);&lt;br&gt;
  return () =&amp;gt; clearInterval(timer);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;What this gives you:&lt;/p&gt;

&lt;p&gt;Exponential backoff with jitter: failures wait 1 to 2s, then 2 to 4s, then longer. Jitter prevents a thundering herd after an outage.&lt;br&gt;
A bounded number of attempts: after MAX_ATTEMPTS, the event becomes dead. A poison message can’t loop forever or block the queue behind it.&lt;br&gt;
Visibility: last_error and attempts tell you exactly what went wrong and how many times.&lt;/p&gt;

&lt;p&gt;Dead letters are not a trash can. Alert on them. A growing dead count means your handler or your assumptions about payloads are broken, and you’ll want to fix the bug and replay those rows by setting them back to pending.&lt;/p&gt;

&lt;p&gt;Step 9: Reconciliation, the safety net&lt;/p&gt;

&lt;p&gt;Here’s an uncomfortable truth about webhooks: any push system can miss an event. Your server was down past the retry window, a network partition ate a delivery, a deploy dropped a connection. If a goal is missed and you never check, your scoreboard stays wrong forever.&lt;/p&gt;

&lt;p&gt;The fix is cheap: periodically compare your local view against the REST API. We use the live-matches endpoint from the quickstart pattern (GET /v1/{sport}/matches/live).&lt;/p&gt;

&lt;p&gt;src/reconcile.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { config } from "./config.js";&lt;br&gt;
import { scoreboard } from "./handlers.js";&lt;/p&gt;

&lt;p&gt;const BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;";&lt;/p&gt;

&lt;p&gt;export async function reconcileLiveMatches(sport = "football") {&lt;br&gt;
  const res = await fetch(&lt;code&gt;${BASE}/${sport}/matches/live&lt;/code&gt;, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${config.apiKey}&lt;/code&gt; },&lt;br&gt;
    signal: AbortSignal.timeout(10_000),&lt;br&gt;
  });&lt;br&gt;
  if (!res.ok) throw new Error(&lt;code&gt;live endpoint returned ${res.status}&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;const body = await res.json();&lt;br&gt;
  const matches = Array.isArray(body) ? body : body.data ?? [];&lt;br&gt;
  const drift = [];&lt;/p&gt;

&lt;p&gt;for (const m of matches) {&lt;br&gt;
    const local = Object.fromEntries(&lt;br&gt;
      scoreboard.all(String(m.match_id)).map((r) =&amp;gt; [r.team, r.goals])&lt;br&gt;
    );&lt;br&gt;
    const gotHome = local[m.home?.name] ?? 0;&lt;br&gt;
    const gotAway = local[m.away?.name] ?? 0;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (gotHome !== m.home?.score || gotAway !== m.away?.score) {
  drift.push({
    match_id: m.match_id,
    api: { home: m.home?.score, away: m.away?.score },
    webhook_view: { home: gotHome, away: gotAway },
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
  return drift;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A few honest caveats:&lt;/p&gt;

&lt;p&gt;The response shape here follows the example on the Orbistats homepage (match_id, home.score, away.score). Confirm it in the API reference, because the code accepts either a bare array or { data: [...] }.&lt;br&gt;
If you start your receiver mid-match, drift is expected, since you missed earlier goals. Reconciliation is for detecting trouble.&lt;br&gt;
Own goals can attribute differently (“team that scored” vs “team credited”), so the first time drift appears, check whether it’s real before panicking.&lt;br&gt;
In production, don’t just log drift. Repair it by re-fetching the match’s event list and rebuilding rows.&lt;/p&gt;

&lt;p&gt;Reconciliation also covers a failure mode no amount of signature checking can: the provider’s webhook for an event simply never arriving. The Sports Data API and Live Scores API are the pull-based sources you reconcile against.&lt;/p&gt;

&lt;p&gt;Step 10: Wire it together&lt;/p&gt;

&lt;p&gt;src/index.js:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { createApp } from "./server.js";&lt;br&gt;
import { startWorker } from "./worker.js";&lt;br&gt;
import { reconcileLiveMatches } from "./reconcile.js";&lt;br&gt;
import { config } from "./config.js";&lt;/p&gt;

&lt;p&gt;const app = createApp();&lt;br&gt;
const server = app.listen(config.port, () =&amp;gt;&lt;br&gt;
  console.log(JSON.stringify({ level: "info", msg: "listening", port: config.port }))&lt;br&gt;
);&lt;br&gt;
const stopWorker = startWorker();&lt;/p&gt;

&lt;p&gt;// Safety net: only runs if you provided an API key&lt;br&gt;
if (config.apiKey) {&lt;br&gt;
  setInterval(async () =&amp;gt; {&lt;br&gt;
    try {&lt;br&gt;
      const drift = await reconcileLiveMatches();&lt;br&gt;
      if (drift.length) console.warn(JSON.stringify({ level: "warn", msg: "score drift", drift }));&lt;br&gt;
    } catch (e) {&lt;br&gt;
      console.error("reconcile failed:", e.message);&lt;br&gt;
    }&lt;br&gt;
  }, 60_000);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;for (const sig of ["SIGINT", "SIGTERM"]) {&lt;br&gt;
  process.on(sig, () =&amp;gt; {&lt;br&gt;
    stopWorker();&lt;br&gt;
    server.close(() =&amp;gt; process.exit(0)); // finish in-flight requests, then exit&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
npm start&lt;br&gt;
curl localhost:8080/healthz&lt;br&gt;
Step 11: Simulate signed deliveries locally&lt;/p&gt;

&lt;p&gt;You shouldn’t need a live match (or your provider) to test this. scripts/send-test-event.js signs a payload exactly the way your receiver expects and sends it twice:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import crypto from "node:crypto";&lt;/p&gt;

&lt;p&gt;const secret = process.env.WEBHOOK_SECRET;&lt;br&gt;
const url = process.env.TARGET_URL ?? "&lt;a href="http://localhost:8080/webhooks/orbistats" rel="noopener noreferrer"&gt;http://localhost:8080/webhooks/orbistats&lt;/a&gt;";&lt;br&gt;
const header = (process.env.WEBHOOK_SIGNATURE_HEADER ?? "x-signature").toLowerCase();&lt;br&gt;
const encoding = process.env.WEBHOOK_SIGNATURE_ENCODING ?? "hex";&lt;br&gt;
const prefix = process.env.WEBHOOK_SIGNATURE_PREFIX ?? "";&lt;/p&gt;

&lt;p&gt;const event = {&lt;br&gt;
  id: process.argv[2] ?? &lt;code&gt;evt_${Date.now()}&lt;/code&gt;,&lt;br&gt;
  type: "match.goal",&lt;br&gt;
  match_id: "48213",&lt;br&gt;
  minute: 72,&lt;br&gt;
  team: "Manchester City",&lt;br&gt;
  timestamp: new Date().toISOString(),&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const body = JSON.stringify(event);&lt;br&gt;
const signature = prefix + crypto.createHmac("sha256", secret).update(body).digest(encoding);&lt;/p&gt;

&lt;p&gt;async function send(label) {&lt;br&gt;
  const res = await fetch(url, {&lt;br&gt;
    method: "POST",&lt;br&gt;
    headers: { "content-type": "application/json", [header]: signature },&lt;br&gt;
    body,&lt;br&gt;
  });&lt;br&gt;
  console.log(label, res.status, await res.text());&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;await send("first ");&lt;br&gt;
await send("replay"); // same id, so this must come back as a duplicate&lt;br&gt;
bash&lt;br&gt;
npm run send -- evt_1&lt;/p&gt;

&lt;h1&gt;
  
  
  first  200 {"status":"accepted"}
&lt;/h1&gt;

&lt;h1&gt;
  
  
  replay 200 {"status":"duplicate"}
&lt;/h1&gt;

&lt;p&gt;Then check the database:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
sqlite3 webhooks.db "SELECT status, COUNT(*) FROM inbox GROUP BY status;"&lt;br&gt;
sqlite3 webhooks.db "SELECT * FROM goals;"&lt;/p&gt;

&lt;p&gt;You should see one inbox row and one goal row, even though you sent two deliveries.&lt;/p&gt;

&lt;p&gt;Step 12: Automated tests&lt;/p&gt;

&lt;p&gt;test/webhook.test.js (uses Node’s built-in test runner, no extra dependencies):&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
import { test, before, after } from "node:test";&lt;br&gt;
import assert from "node:assert/strict";&lt;br&gt;
import crypto from "node:crypto";&lt;/p&gt;

&lt;p&gt;// Env must be set BEFORE the modules load&lt;br&gt;
process.env.WEBHOOK_SECRET = "test-secret";&lt;br&gt;
process.env.DB_PATH = ":memory:";&lt;/p&gt;

&lt;p&gt;const { createApp } = await import("../src/server.js");&lt;br&gt;
const { drainOnce, backoffMs } = await import("../src/worker.js");&lt;br&gt;
const { db } = await import("../src/db.js");&lt;/p&gt;

&lt;p&gt;let server;&lt;br&gt;
let base;&lt;/p&gt;

&lt;p&gt;before(async () =&amp;gt; {&lt;br&gt;
  server = createApp().listen(0);&lt;br&gt;
  await new Promise((r) =&amp;gt; server.once("listening", r));&lt;br&gt;
  base = &lt;code&gt;http://127.0.0.1:${server.address().port}&lt;/code&gt;;&lt;br&gt;
});&lt;br&gt;
after(() =&amp;gt; server.close());&lt;/p&gt;

&lt;p&gt;const sign = (body) =&amp;gt; crypto.createHmac("sha256", "test-secret").update(body).digest("hex");&lt;br&gt;
const post = (body, sig = sign(body)) =&amp;gt;&lt;br&gt;
  fetch(&lt;code&gt;${base}/webhooks/orbistats&lt;/code&gt;, {&lt;br&gt;
    method: "POST",&lt;br&gt;
    headers: { "content-type": "application/json", "x-signature": sig },&lt;br&gt;
    body,&lt;br&gt;
  });&lt;br&gt;
const evt = (id, type = "match.goal") =&amp;gt;&lt;br&gt;
  JSON.stringify({ id, type, match_id: "1", minute: 10, team: "A" });&lt;br&gt;
const count = (sql) =&amp;gt; db.prepare(sql).get().n;&lt;/p&gt;

&lt;p&gt;test("rejects a bad signature", async () =&amp;gt; {&lt;br&gt;
  const res = await post(evt("e1"), "deadbeef");&lt;br&gt;
  assert.equal(res.status, 401);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("accepts a valid event once and ACKs duplicates", async () =&amp;gt; {&lt;br&gt;
  const body = evt("e2");&lt;br&gt;
  assert.equal((await (await post(body)).json()).status, "accepted");&lt;br&gt;
  assert.equal((await (await post(body)).json()).status, "duplicate");&lt;br&gt;
  assert.equal(count("SELECT COUNT(*) AS n FROM inbox WHERE idempotency_key = 'id:e2'"), 1);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("processing is idempotent", async () =&amp;gt; {&lt;br&gt;
  await post(evt("e3"));&lt;br&gt;
  await post(evt("e3"));&lt;br&gt;
  await drainOnce();&lt;br&gt;
  assert.equal(count("SELECT COUNT(*) AS n FROM goals WHERE idempotency_key = 'id:e3'"), 1);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("unknown event types are ACKed and completed, not errored", async () =&amp;gt; {&lt;br&gt;
  const res = await post(evt("e4", "match.something_new"));&lt;br&gt;
  assert.equal(res.status, 200);&lt;br&gt;
  await drainOnce();&lt;br&gt;
  assert.equal(count("SELECT COUNT(*) AS n FROM inbox WHERE idempotency_key = 'id:e4' AND status = 'done'"), 1);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("authentic but malformed payloads are stored, not rejected", async () =&amp;gt; {&lt;br&gt;
  const body = "not json at all";&lt;br&gt;
  const res = await post(body);&lt;br&gt;
  assert.equal(res.status, 200);&lt;br&gt;
  assert.equal(count("SELECT COUNT(*) AS n FROM inbox WHERE status = 'dead'"), 1);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;test("backoff grows with attempts", () =&amp;gt; {&lt;br&gt;
  assert.ok(backoffMs(1) &amp;lt; backoffMs(5));&lt;br&gt;
});&lt;br&gt;
bash&lt;br&gt;
npm test&lt;/p&gt;

&lt;p&gt;These six tests encode the rules from Step 6. If anyone later “simplifies” the handler into returning 409 for duplicates, a test fails.&lt;/p&gt;

&lt;p&gt;Step 13: Going live without wasting your access window&lt;/p&gt;

&lt;p&gt;When you’re ready to receive real events:&lt;/p&gt;

&lt;p&gt;Expose your local server over HTTPS with a tunnel (ngrok or Cloudflare Tunnel), or deploy it.&lt;br&gt;
Get an API key. Request access and follow the activation steps in the quickstart.&lt;br&gt;
Inspect a real payload in the sandbox, then adjust schema.js and your handlers to match.&lt;br&gt;
Confirm the three signature settings (header, encoding, prefix) in the webhooks docs, and put them in .env.&lt;br&gt;
Register your public URL (&lt;a href="https://your-domain/webhooks/orbistats" rel="noopener noreferrer"&gt;https://your-domain/webhooks/orbistats&lt;/a&gt;) using the registration steps in the API reference.&lt;/p&gt;

&lt;p&gt;One practical tip: Orbistats access works in time windows. The Free plan is one hour with no card, and the timer starts when you open your activation link. Starter is 24 hours and Growth is five days, with every sport and endpoint on every plan. Current details are on the pricing page.&lt;/p&gt;

&lt;p&gt;So build and test everything above locally with simulated, signed events before you start the clock. Steps 1 to 12 need no live data. Then spend the window verifying real payloads and signatures, and let a live match prove the pipeline.&lt;/p&gt;

&lt;p&gt;Also bookmark the status page. When events stop arriving, it’s the fastest way to learn whether the problem is yours or the provider’s.&lt;/p&gt;

&lt;p&gt;Step 14: Production hardening checklist&lt;/p&gt;

&lt;p&gt;The tutorial version is correct. A production version is also operable:&lt;/p&gt;

&lt;p&gt;Move the worker to its own process so slow handlers can’t affect request latency. better-sqlite3 is synchronous, so heavy handlers block the event loop in a single process.&lt;br&gt;
Use Postgres for multiple workers. Replace SQLite’s claim step with a row-locking claim:&lt;br&gt;
sql&lt;br&gt;
UPDATE inbox&lt;br&gt;
SET status = 'processing', locked_at = now()&lt;br&gt;
WHERE idempotency_key IN (&lt;br&gt;
  SELECT idempotency_key FROM inbox&lt;br&gt;
  WHERE status = 'pending' AND next_attempt_at &amp;lt;= now()&lt;br&gt;
  ORDER BY received_at&lt;br&gt;
  LIMIT 20&lt;br&gt;
  FOR UPDATE SKIP LOCKED&lt;br&gt;
)&lt;br&gt;
RETURNING *;&lt;/p&gt;

&lt;p&gt;Add a lease timeout so rows stuck in processing after a crash return to pending.&lt;/p&gt;

&lt;p&gt;Monitor three numbers: pending depth, age of the oldest pending row, and dead-letter count.&lt;br&gt;
sql&lt;br&gt;
SELECT status, COUNT(*) FROM inbox GROUP BY status;&lt;br&gt;
SELECT (strftime('%s','now')*1000 - MIN(received_at)) / 1000 AS oldest_pending_seconds&lt;br&gt;
FROM inbox WHERE status = 'pending';&lt;br&gt;
Alert on silence. If a match is live and zero events have arrived for several minutes, something is wrong even if no errors are firing. Reconciliation catches this.&lt;br&gt;
Never log secrets or full signatures. Log the idempotency key and event type instead.&lt;br&gt;
Terminate TLS properly, apply rate limiting at your proxy, and keep the body size limit small.&lt;br&gt;
Retain the inbox for a while (days, not forever). It’s your audit trail and your replay source.&lt;br&gt;
Handle match-day spikes with a queue and caching in front of your readers. Our post on scaling a sports data consumer for match-day spikes covers that side.&lt;br&gt;
Common pitfalls (each one has bitten someone)&lt;br&gt;
Symptom Likely cause    Fix&lt;br&gt;
Every signature fails   A JSON parser ran before verification   Use express.raw on this route and verify the Buffer&lt;br&gt;
Signature fails only sometimes  You re-serialized parsed JSON   Verify the original bytes, never JSON.stringify(req.body)&lt;br&gt;
Same goal counted twice Dedupe in memory, or only in the handler    Unique key in the database, enforced on insert&lt;br&gt;
Provider keeps retrying forever You return non-2xx for duplicates or unknown types  ACK anything authentic that you’ve stored&lt;br&gt;
Slow responses, then more duplicates    Business logic on the request path  Write to the inbox, ACK, process in the worker&lt;br&gt;
Scores drift over a week    Mutable counters and no reconciliation  Store events, derive state, reconcile periodically&lt;br&gt;
Endpoint suddenly disabled by sender    Sustained 4xx/5xx or timeouts   Fix the cause, monitor, and see the retry/disable policy in the docs&lt;br&gt;
Duplicates after a deploy   In-memory dedupe lost on restart    Persist idempotency keys&lt;br&gt;
Wrapping up&lt;/p&gt;

&lt;p&gt;Reliable webhook handling comes down to a handful of habits:&lt;/p&gt;

&lt;p&gt;Verify authenticity on the raw bytes, with a constant-time compare.&lt;br&gt;
Acknowledge fast and keep the request path to verify, write, ACK.&lt;br&gt;
Make duplicates harmless with a database-enforced idempotency key, in the inbox and in the handler.&lt;br&gt;
Retry with backoff and jitter, cap the attempts, and alert on dead letters.&lt;br&gt;
Store events, derive state, and reconcile against a pull API so a missed push can’t become a permanent error.&lt;/p&gt;

&lt;p&gt;If you want the wider picture of how REST, WebSocket and webhooks fit together, our sports odds API guide walks through delivery methods and a production checklist.&lt;/p&gt;

&lt;p&gt;Question for the comments: for your idempotency key, do you trust the provider’s event ID, or do you hash the body as a fallback? And have you ever been bitten by a webhook that never arrived? I’d like to hear how you caught it.&lt;/p&gt;

</description>
      <category>node</category>
      <category>webhooks</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Scaling a Sports Data Consumer for Match-Day Spikes: Queues, Caching and Rate Limits</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Wed, 07 Oct 2026 16:08:10 +0000</pubDate>
      <link>https://dev.to/orbistats/scaling-a-sports-data-consumer-for-match-day-spikes-queues-caching-and-rate-limits-432a</link>
      <guid>https://dev.to/orbistats/scaling-a-sports-data-consumer-for-match-day-spikes-queues-caching-and-rate-limits-432a</guid>
      <description>&lt;p&gt;Your sports app works perfectly on a quiet Tuesday.&lt;/p&gt;

&lt;p&gt;Then Saturday, 3:00 PM arrives. Dozens of matches kick off within the same minute. Goals land in parallel across several leagues. Ten minutes later, your users are all refreshing at once, and your single-threaded poller is hammering an API that just returned its first 429.&lt;/p&gt;

&lt;p&gt;Everything that was "fine in testing" turns out to depend on traffic being polite.&lt;/p&gt;

&lt;p&gt;This tutorial builds a sports data consumer that stays up when traffic isn't polite. We'll build it in Python with Redis, and every piece is something you can reuse for any real-time feed, not just sports.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;p&gt;A webhook receiver that verifies signatures and acknowledges in milliseconds&lt;br&gt;
A durable queue (Redis Streams) that absorbs bursts instead of dropping them&lt;br&gt;
Idempotent workers that survive duplicates, retries and out-of-order events&lt;br&gt;
A rate-limited API client with shared budgets, backoff, jitter and Retry-After handling&lt;br&gt;
Cache-aside with single-flight and stale-while-revalidate, so a thousand users cost one API call&lt;br&gt;
A reconciliation poller that heals anything a webhook missed&lt;br&gt;
A spike simulator that proves all of the above actually works&lt;/p&gt;

&lt;p&gt;Let's build it.&lt;/p&gt;

&lt;p&gt;Why Match Days Break Naive Consumers&lt;/p&gt;

&lt;p&gt;Sports traffic isn't smooth. It's synchronized, and synchronization is what kills systems.&lt;/p&gt;

&lt;p&gt;Kickoffs cluster. A Premier League Saturday puts a whole slate of matches on the same clock. Fixtures that were quiet for a week become live all at once.&lt;br&gt;
Events cluster. A burst of goals, cards and substitutions across many matches arrives in the same few seconds, then nothing for a minute.&lt;br&gt;
Final whistles cluster. Matches that kicked off together end together, so a wave of match.finished events lands at nearly the same instant.&lt;br&gt;
Your users cluster too. A goal in a big match sends tens of thousands of people to refresh simultaneously.&lt;br&gt;
Sports overlap. A cricket T20 final, a tennis tournament day, and a football slate can all peak together, so the spike isn't confined to one sport.&lt;/p&gt;

&lt;p&gt;A naive consumer fails in predictable ways:&lt;/p&gt;

&lt;p&gt;Naive approach  What breaks on match day&lt;br&gt;
One API call per user request   Your users become your rate-limit problem&lt;br&gt;
Poll every match every few seconds  Request count scales with matches, not with value&lt;br&gt;
Process events inline in the HTTP handler   A slow database write times out the sender&lt;br&gt;
Trust arrival order A retried "goal" lands after the "final score" and flips the match back to live&lt;br&gt;
Retry immediately on 429    You turn a rate limit into a thundering herd&lt;br&gt;
No dedupe   Retries double-count goals&lt;/p&gt;

&lt;p&gt;Each of those has a boring, well-understood fix. We'll apply them one at a time.&lt;/p&gt;

&lt;p&gt;The Architecture&lt;br&gt;
text&lt;br&gt;
                  ┌────────────────────────────┐&lt;br&gt;
                  │      Orbistats feed        │&lt;br&gt;
                  └──────┬───────────────┬─────┘&lt;br&gt;
          webhooks (push)│               │ REST (pull, safety net)&lt;br&gt;
                         ▼               ▼&lt;br&gt;
              ┌───────────────┐   ┌───────────────┐&lt;br&gt;
              │ FastAPI       │   │ Reconciliation│&lt;br&gt;
              │ receiver      │   │ poller        │&lt;br&gt;
              │ verify+dedupe │   └───────┬───────┘&lt;br&gt;
              └───────┬───────┘           │&lt;br&gt;
                      ▼                   │&lt;br&gt;
              ┌───────────────┐           │&lt;br&gt;
              │ Redis Stream  │           │&lt;br&gt;
              │ (durable queue│           │&lt;br&gt;
              └───────┬───────┘           │&lt;br&gt;
                      ▼                   ▼&lt;br&gt;
              ┌───────────────────────────────┐&lt;br&gt;
              │ Workers (N) → atomic Lua      │&lt;br&gt;
              │ state: match:{id} + live:ids  │──► pub/sub "updates"&lt;br&gt;
              └───────────────┬───────────────┘&lt;br&gt;
                              ▼&lt;br&gt;
              ┌───────────────────────────────┐&lt;br&gt;
              │ Read API + SSE (your users)   │&lt;br&gt;
              │ serves from cache, never from │&lt;br&gt;
              │ the upstream API              │&lt;br&gt;
              └───────────────────────────────┘&lt;/p&gt;

&lt;p&gt;The golden rule behind the whole design: your users should never cause a call to the upstream API. Users read from your cache. The upstream API is fed by your workers, at a rate you control.&lt;/p&gt;

&lt;p&gt;Choosing How to Receive Data&lt;/p&gt;

&lt;p&gt;Before writing code, decide how data gets into your system. The Live Scores API delivers the same events three ways:&lt;/p&gt;

&lt;p&gt;Method  Best for    Trade-off&lt;br&gt;
REST polling    Simple jobs, reconciliation Cost scales with poll frequency; always a little late&lt;br&gt;
WebSocket   Latency-sensitive live UIs  You own reconnect logic and connection limits&lt;br&gt;
Webhooks    Event-driven backends   You need a public HTTPS endpoint that answers quickly&lt;/p&gt;

&lt;p&gt;For match-day scale I recommend webhooks as the primary path with REST as a safety net:&lt;/p&gt;

&lt;p&gt;Webhooks are push-based, so your request count doesn't grow with the number of live matches.&lt;br&gt;
They come with the properties a robust consumer needs: signed payloads, automatic retries with backoff, an event_id for dedupe, and a per-match sequence for ordering.&lt;br&gt;
If your endpoint is down for a while, deliveries can be listed and replayed afterwards.&lt;/p&gt;

&lt;p&gt;We'll also show the WebSocket variant, since many teams prefer it.&lt;/p&gt;

&lt;p&gt;Capacity Math Before You Write Code&lt;/p&gt;

&lt;p&gt;Do this on paper first. These numbers are illustrative assumptions, not Orbistats limits:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Naive: poll each live match every 10 s&lt;br&gt;
  300 live matches ÷ 10 s = 30 requests/second = 1,800 requests/minute&lt;/p&gt;

&lt;p&gt;Better: one bulk "live" call per sport every 10 s&lt;br&gt;
  13 sports ÷ 10 s ≈ 78 requests/minute&lt;/p&gt;

&lt;p&gt;Best: webhooks as primary, bulk poll every 30 s as a safety net&lt;br&gt;
  13 sports ÷ 30 s ≈ 26 requests/minute&lt;/p&gt;

&lt;p&gt;Your users:&lt;br&gt;
  50,000 users refreshing every 10 s = 5,000 reads/second&lt;br&gt;
  → that must hit YOUR cache, not the upstream API&lt;/p&gt;

&lt;p&gt;The same data costs 1,800 requests per minute done badly and 26 done well. Plan limits differ by tier, so check the pricing page and the rate-limit headers on real responses, then size your BUDGET_PER_MIN accordingly.&lt;/p&gt;

&lt;p&gt;Step 1: Setup&lt;br&gt;
Request an API key on the sign-up page.&lt;br&gt;
Read the documentation for auth and conventions, then try the endpoints in the sandbox so you see real payloads before coding.&lt;br&gt;
Keep the API reference open. It documents the X-RateLimit-* headers, the 429 and Retry-After behaviour, and the standard response envelope (data, meta, errors).&lt;/p&gt;

&lt;p&gt;Project layout:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
matchday/&lt;br&gt;
├── .env&lt;br&gt;
├── docker-compose.yml&lt;br&gt;
├── config.py&lt;br&gt;
├── app.py            # webhook receiver + read API + SSE&lt;br&gt;
├── worker.py         # stream consumer, idempotent state updates&lt;br&gt;
├── client.py         # rate-limited, cached REST client&lt;br&gt;
├── poller.py         # reconciliation safety net&lt;br&gt;
├── ws_consumer.py    # optional WebSocket ingestion&lt;br&gt;
├── register.py       # register the webhook&lt;br&gt;
└── simulate.py       # spike simulator + verifier&lt;/p&gt;

&lt;p&gt;Install:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate&lt;br&gt;
pip install fastapi uvicorn "redis&amp;gt;=5" requests httpx websockets python-dotenv&lt;/p&gt;

&lt;p&gt;docker-compose.yml (Redis 7 is needed for the stream commands we use):&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
services:&lt;br&gt;
  redis:&lt;br&gt;
    image: redis:7-alpine&lt;br&gt;
    command: ["redis-server", "--appendonly", "yes"]&lt;br&gt;
    ports: ["6379:6379"]&lt;br&gt;
    volumes: ["redis-data:/data"]&lt;/p&gt;

&lt;p&gt;volumes:&lt;br&gt;
  redis-data:&lt;/p&gt;

&lt;p&gt;.env:&lt;/p&gt;

&lt;p&gt;env&lt;br&gt;
ORBISTATS_API_KEY=your_key_here&lt;br&gt;
WEBHOOK_SECRET=generate_a_long_random_string&lt;br&gt;
REDIS_URL=redis://localhost:6379/0&lt;/p&gt;

&lt;h1&gt;
  
  
  Stay below your plan's real limit (aim for ~70-80%)
&lt;/h1&gt;

&lt;p&gt;BUDGET_PER_MIN=60&lt;/p&gt;

&lt;h1&gt;
  
  
  Docs show two live routes; confirm in the sandbox and set one:
&lt;/h1&gt;

&lt;h1&gt;
  
  
  {sport}/matches/live   (API reference)
&lt;/h1&gt;

&lt;h1&gt;
  
  
  {sport}/live           (Live Scores page)
&lt;/h1&gt;

&lt;p&gt;LIVE_PATH={sport}/matches/live&lt;/p&gt;

&lt;p&gt;Generate a strong webhook secret:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python -c "import secrets; print(secrets.token_urlsafe(32))"&lt;/p&gt;

&lt;p&gt;Start Redis:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
docker compose up -d&lt;br&gt;
Step 2: Shared Configuration&lt;/p&gt;

&lt;p&gt;config.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import os&lt;br&gt;
from dotenv import load_dotenv&lt;/p&gt;

&lt;p&gt;load_dotenv()&lt;/p&gt;

&lt;p&gt;API_KEY = os.environ["ORBISTATS_API_KEY"]&lt;br&gt;
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]&lt;br&gt;
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")&lt;br&gt;
BASE_URL = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;BUDGET_PER_MIN = int(os.getenv("BUDGET_PER_MIN", "60"))&lt;br&gt;
LIVE_PATH = os.getenv("LIVE_PATH", "{sport}/matches/live")&lt;/p&gt;

&lt;p&gt;STREAM = "events"        # durable queue of incoming events&lt;br&gt;
GROUP = "workers"        # consumer group&lt;br&gt;
DLQ = "events:dead"      # events that failed repeatedly&lt;br&gt;
Step 3: The Webhook Receiver (Acknowledge Fast, Do Nothing Else)&lt;/p&gt;

&lt;p&gt;The webhook docs set the rule: respond within 5 seconds or the delivery counts as failed and is retried. The lesson is simple: do the minimum in the HTTP handler. Verify, dedupe, enqueue, return 200. Everything slow happens later, in a worker.&lt;/p&gt;

&lt;p&gt;Each delivery is a signed POST. The signature is an HMAC-SHA256 of the raw request body using your secret, sent in the X-Orbistats-Signature header. Here's the shape of a payload from the docs:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "event": "match.finished",&lt;br&gt;
  "event_id": "evt_9f2a1c4b",&lt;br&gt;
  "delivery_id": "dlv_7c3e08a1",&lt;br&gt;
  "sequence": 214,&lt;br&gt;
  "match_id": 48213,&lt;br&gt;
  "sport": "football",&lt;br&gt;
  "final_score": "2-1",&lt;br&gt;
  "timestamp": "2026-09-14T16:52:11Z"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Two fields matter most for scaling:&lt;/p&gt;

&lt;p&gt;event_id is stable across retries, so dedupe on it.&lt;br&gt;
sequence is monotonic per match_id, so order by it, not by arrival time.&lt;/p&gt;

&lt;p&gt;app.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import hashlib&lt;br&gt;
import hmac&lt;br&gt;
import json&lt;/p&gt;

&lt;p&gt;import redis.asyncio as aioredis&lt;br&gt;
from fastapi import FastAPI, HTTPException, Request, Response&lt;br&gt;
from fastapi.responses import StreamingResponse&lt;/p&gt;

&lt;p&gt;from config import DLQ, GROUP, REDIS_URL, STREAM, WEBHOOK_SECRET&lt;/p&gt;

&lt;p&gt;app = FastAPI()&lt;br&gt;
r = aioredis.from_url(REDIS_URL, decode_responses=True)&lt;/p&gt;

&lt;p&gt;def valid_signature(raw: bytes, header: str) -&amp;gt; bool:&lt;br&gt;
    if not header.startswith("sha256="):&lt;br&gt;
        return False&lt;br&gt;
    digest = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()&lt;br&gt;
    # constant-time comparison avoids timing attacks&lt;br&gt;
    return hmac.compare_digest(f"sha256={digest}", header)&lt;/p&gt;

&lt;p&gt;@app.post("/webhook")&lt;br&gt;
async def webhook(request: Request):&lt;br&gt;
    raw = await request.body()          # sign the RAW bytes, never re-serialized JSON&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if not valid_signature(raw, request.headers.get("X-Orbistats-Signature", "")):
    raise HTTPException(status_code=401, detail="bad signature")

try:
    event = json.loads(raw)
    event_id = event["event_id"]
except (ValueError, KeyError):
    raise HTTPException(status_code=400, detail="malformed event")

# First writer wins. The key outlives the longest retry window (~1h) by a lot.
key = f"seen:{event_id}"
first_time = await r.set(key, 1, nx=True, ex=86400)

if first_time:
    try:
        await r.xadd(STREAM, {"payload": raw.decode()},
                     maxlen=500_000, approximate=True)
    except Exception:
        # If we failed to enqueue, forget we saw it so the sender's retry works.
        await r.delete(key)
        raise HTTPException(status_code=503, detail="queue unavailable")

return Response(status_code=200)     # duplicates are acknowledged too
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Three details worth stealing:&lt;/p&gt;

&lt;p&gt;We sign-check before parsing. Never trust a payload you haven't authenticated.&lt;br&gt;
Duplicates get a 200. The sender should stop retrying, even though we ignored the event.&lt;br&gt;
The "forget on failure" branch. Without it, a failed enqueue would mark the event as seen and the retry would be silently discarded. That's data loss disguised as dedupe.&lt;/p&gt;

&lt;p&gt;The handler does one SET and one XADD. That's a few milliseconds, comfortably inside the 5-second limit even under heavy load.&lt;/p&gt;

&lt;p&gt;Step 4: Idempotent Workers&lt;/p&gt;

&lt;p&gt;Webhooks are at-least-once, not exactly-once. That means your worker will see duplicates and out-of-order events, and it must produce the correct state anyway. This property is called idempotency, and it's what makes retries safe.&lt;/p&gt;

&lt;p&gt;The classic bug: the event for "goal #3" is retried and arrives after "match finished". A naive worker overwrites the final state and the match looks live again.&lt;/p&gt;

&lt;p&gt;The fix is sequence gating: only apply an event if its sequence is higher than the last one applied for that match. And the check-and-write has to be atomic, otherwise two workers can race. A Redis Lua script gives us that:&lt;/p&gt;

&lt;p&gt;worker.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import json&lt;br&gt;
import logging&lt;br&gt;
import sys&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;import redis&lt;/p&gt;

&lt;p&gt;from config import DLQ, GROUP, REDIS_URL, STREAM&lt;/p&gt;

&lt;p&gt;logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")&lt;br&gt;
log = logging.getLogger("worker")&lt;/p&gt;

&lt;p&gt;r = redis.from_url(REDIS_URL, decode_responses=True)&lt;/p&gt;

&lt;p&gt;MAX_ATTEMPTS = 5&lt;/p&gt;

&lt;h1&gt;
  
  
  One atomic script: gate on sequence, write state, update the live set, publish.
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Because it's a single script, state and side effects can never diverge,
&lt;/h1&gt;

&lt;h1&gt;
  
  
  even if the worker crashes mid-way.
&lt;/h1&gt;

&lt;p&gt;APPLY_LUA = """&lt;br&gt;
local current  = tonumber(redis.call('HGET', KEYS[1], 'seq') or '-1')&lt;br&gt;
local incoming = tonumber(ARGV[1])&lt;br&gt;
if incoming &amp;lt;= current then return 0 end&lt;/p&gt;

&lt;p&gt;redis.call('HSET', KEYS[1], 'seq', incoming, unpack(ARGV, 5))&lt;/p&gt;

&lt;p&gt;if ARGV[3] == 'finished' then&lt;br&gt;
  redis.call('SREM', KEYS[2], ARGV[2])&lt;br&gt;
  redis.call('EXPIRE', KEYS[1], 21600)&lt;br&gt;
elseif ARGV[3] == 'in_play' then&lt;br&gt;
  redis.call('SADD', KEYS[2], ARGV[2])&lt;br&gt;
end&lt;/p&gt;

&lt;p&gt;redis.call('PUBLISH', 'updates', ARGV[4])&lt;br&gt;
return 1&lt;br&gt;
"""&lt;br&gt;
apply_event = r.register_script(APPLY_LUA)&lt;/p&gt;

&lt;p&gt;def to_fields(ev: dict) -&amp;gt; dict:&lt;br&gt;
    kind = ev["event"]&lt;br&gt;
    fields = {&lt;br&gt;
        "match_id": ev["match_id"],&lt;br&gt;
        "sport": ev.get("sport"),&lt;br&gt;
        "last_event": kind,&lt;br&gt;
        "updated_at": ev.get("timestamp"),&lt;br&gt;
    }&lt;br&gt;
    if kind == "match.started":&lt;br&gt;
        fields["status"] = "in_play"&lt;br&gt;
    elif kind == "match.goal":&lt;br&gt;
        fields.update(&lt;br&gt;
            status="in_play",&lt;br&gt;
            last_goal_team=ev.get("team"),&lt;br&gt;
            last_goal_minute=ev.get("minute"),&lt;br&gt;
            score=ev.get("score"),&lt;br&gt;
        )&lt;br&gt;
    elif kind == "match.finished":&lt;br&gt;
        fields.update(status="finished", final_score=ev.get("final_score"))&lt;br&gt;
    # Unknown event types still update last_event, so new types don't crash us.&lt;br&gt;
    return {k: v for k, v in fields.items() if v is not None}&lt;/p&gt;

&lt;p&gt;def handle(ev: dict) -&amp;gt; bool:&lt;br&gt;
    fields = to_fields(ev)&lt;br&gt;
    flat = [x for pair in fields.items() for x in pair]&lt;br&gt;
    payload = json.dumps({"match_id": ev["match_id"], **fields})&lt;br&gt;
    applied = apply_event(&lt;br&gt;
        keys=[f"match:{ev['match_id']}", "live:ids"],&lt;br&gt;
        args=[int(ev["sequence"]), ev["match_id"], fields.get("status", ""), payload, *flat],&lt;br&gt;
    )&lt;br&gt;
    return bool(applied)    # False = stale or duplicate, safely ignored&lt;/p&gt;

&lt;p&gt;def process(msg_id: str, data: dict) -&amp;gt; None:&lt;br&gt;
    try:&lt;br&gt;
        handle(json.loads(data["payload"]))&lt;br&gt;
        r.xack(STREAM, GROUP, msg_id)&lt;br&gt;
    except Exception:&lt;br&gt;
        log.exception("failed processing %s", msg_id)&lt;br&gt;
        # Leave it pending; reclaim() retries it. After MAX_ATTEMPTS, park it.&lt;br&gt;
        pending = r.xpending_range(STREAM, GROUP, min=msg_id, max=msg_id, count=1)&lt;br&gt;
        if pending and pending[0]["times_delivered"] &amp;gt;= MAX_ATTEMPTS:&lt;br&gt;
            r.xadd(DLQ, data)&lt;br&gt;
            r.xack(STREAM, GROUP, msg_id)&lt;br&gt;
            log.error("moved %s to dead-letter stream", msg_id)&lt;/p&gt;

&lt;p&gt;def reclaim(consumer: str) -&amp;gt; None:&lt;br&gt;
    """Pick up messages a crashed worker never acknowledged."""&lt;br&gt;
    start = "0-0"&lt;br&gt;
    while True:&lt;br&gt;
        start, claimed, *_ = r.xautoclaim(&lt;br&gt;
            STREAM, GROUP, consumer, min_idle_time=30_000, start_id=start, count=100&lt;br&gt;
        )&lt;br&gt;
        for msg_id, data in claimed:&lt;br&gt;
            process(msg_id, data)&lt;br&gt;
        if start == "0-0":&lt;br&gt;
            break&lt;/p&gt;

&lt;p&gt;def main(consumer: str) -&amp;gt; None:&lt;br&gt;
    try:&lt;br&gt;
        r.xgroup_create(STREAM, GROUP, id="0", mkstream=True)&lt;br&gt;
    except redis.ResponseError as exc:&lt;br&gt;
        if "BUSYGROUP" not in str(exc):&lt;br&gt;
            raise&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log.info("worker %s started", consumer)
last_reclaim = 0.0
while True:
    if time.time() - last_reclaim &amp;gt; 15:
        reclaim(consumer)
        last_reclaim = time.time()

    resp = r.xreadgroup(GROUP, consumer, {STREAM: "&amp;gt;"}, count=100, block=2000)
    for _, messages in resp or []:
        for msg_id, data in messages:
            process(msg_id, data)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    main(sys.argv[1] if len(sys.argv) &amp;gt; 1 else "worker-1")&lt;/p&gt;

&lt;p&gt;Why this design holds up:&lt;/p&gt;

&lt;p&gt;Consumer groups split the stream across workers, so scaling out is just launching another process with a different name.&lt;br&gt;
Ack-after-success means a worker crash leaves the message pending, and reclaim() retries it later. Nothing is lost.&lt;br&gt;
A dead-letter stream catches poison messages, so one bad event can't block the queue forever.&lt;br&gt;
The Lua script makes "check sequence, then write" a single atomic step, so concurrency can't corrupt a match.&lt;/p&gt;

&lt;p&gt;Scaling workers on match day is literally:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python worker.py w1 &amp;amp;&lt;br&gt;
python worker.py w2 &amp;amp;&lt;br&gt;
python worker.py w3 &amp;amp;&lt;br&gt;
Step 5: A Rate-Limit-Aware, Cached REST Client&lt;/p&gt;

&lt;p&gt;Even with webhooks as the primary path, you'll still call the REST API: for fixtures, standings, team data, and the safety-net poller. That client needs to be a good citizen.&lt;/p&gt;

&lt;p&gt;Most rate-limit failures come from four mistakes: ignoring the headers, retrying instantly, retrying in lockstep, and letting many processes each think they own the whole budget. We'll fix all four.&lt;/p&gt;

&lt;p&gt;The API reference documents these building blocks:&lt;/p&gt;

&lt;p&gt;X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response&lt;br&gt;
429 with a Retry-After header&lt;br&gt;
Safe-to-retry 500 and 503 responses&lt;br&gt;
403 for "your plan doesn't include this" (do not retry that)&lt;/p&gt;

&lt;p&gt;client.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import json&lt;br&gt;
import logging&lt;br&gt;
import random&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;import redis&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;from config import API_KEY, BASE_URL, BUDGET_PER_MIN, REDIS_URL&lt;/p&gt;

&lt;p&gt;log = logging.getLogger("client")&lt;br&gt;
r = redis.from_url(REDIS_URL, decode_responses=True)&lt;/p&gt;

&lt;p&gt;session = requests.Session()&lt;br&gt;
session.headers.update({"Authorization": f"Bearer {API_KEY}"})&lt;/p&gt;

&lt;p&gt;class ApiError(Exception):&lt;br&gt;
    pass&lt;/p&gt;

&lt;p&gt;def backoff(attempt: int, cap: float = 30.0) -&amp;gt; float:&lt;br&gt;
    """Exponential backoff with FULL jitter, so clients don't retry in lockstep."""&lt;br&gt;
    return random.uniform(0, min(cap, 2 ** attempt))&lt;/p&gt;

&lt;p&gt;def acquire() -&amp;gt; float:&lt;br&gt;
    """Shared budget across ALL worker processes. Returns seconds to wait (0 = go)."""&lt;br&gt;
    now = time.time()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# A global pause (set after a 429 or a nearly-empty budget) beats everything.
pause_until = r.get("rl:pause_until")
if pause_until and float(pause_until) &amp;gt; now:
    return float(pause_until) - now

key = f"rl:{int(now // 60)}"
used = r.incr(key)
if used == 1:
    r.expire(key, 120)
return 0.0 if used &amp;lt;= BUDGET_PER_MIN else 60 - (now % 60)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def pause_everyone(seconds: float) -&amp;gt; None:&lt;br&gt;
    until = time.time() + min(seconds, 60)&lt;br&gt;
    r.set("rl:pause_until", until, ex=int(seconds) + 2)&lt;/p&gt;

&lt;p&gt;def api_get(path: str, params: dict | None = None, retries: int = 4) -&amp;gt; dict:&lt;br&gt;
    url = f"{BASE_URL}/{path.lstrip('/')}"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for attempt in range(retries + 1):
    while wait := acquire():
        time.sleep(wait + random.uniform(0, 0.5))

    try:
        resp = session.get(url, params=params, timeout=(3, 10))
    except requests.RequestException as exc:
        log.warning("network error on %s: %s", path, exc)
        time.sleep(backoff(attempt))
        continue

    # Back off *before* we hit the wall, for every worker at once.
    remaining = resp.headers.get("X-RateLimit-Remaining")
    reset = resp.headers.get("X-RateLimit-Reset")
    if remaining is not None and reset and int(remaining) &amp;lt;= 2:
        pause_everyone(max(0, float(reset) - time.time()))

    if resp.status_code == 429:
        delay = float(resp.headers.get("Retry-After", backoff(attempt, cap=60)))
        log.warning("429 on %s, pausing all workers for %.1fs", path, delay)
        pause_everyone(delay)
        time.sleep(delay + random.uniform(0, 1))
        continue

    if resp.status_code &amp;gt;= 500:
        time.sleep(backoff(attempt))
        continue

    if resp.status_code in (400, 401, 403, 404):
        # Retrying won't fix a bad key, a plan limit, or a typo.
        raise ApiError(f"{resp.status_code} on {path}: {resp.text[:200]}")

    resp.raise_for_status()
    return resp.json()

raise ApiError(f"gave up on {path} after {retries + 1} attempts")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The two ideas that matter most:&lt;/p&gt;

&lt;p&gt;pause_everyone. When one worker sees a 429, all workers stop. Otherwise ten workers each discover the limit independently and each one retries into it.&lt;br&gt;
Full jitter. random.uniform(0, 2**attempt) spreads retries out. Plain exponential backoff makes every client retry at the same instant, which recreates the spike you were avoiding.&lt;br&gt;
Cache-Aside, Single-Flight, Stale-While-Revalidate&lt;/p&gt;

&lt;p&gt;Now the part that protects you from your own users. Add this to client.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def cached_get(path: str, params: dict | None = None, ttl: int = 10, stale_ttl: int = 600):&lt;br&gt;
    """&lt;br&gt;
    - Fresh hit: return immediately (no upstream call).&lt;br&gt;
    - Miss: exactly ONE caller fetches; others get stale data or wait briefly.&lt;br&gt;
    - Upstream failing: serve stale data instead of an error.&lt;br&gt;
    """&lt;br&gt;
    key = f"http:{path}:{json.dumps(params or {}, sort_keys=True)}"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fresh = r.get(key)
if fresh:
    r.incr("stats:cache_hit")
    return json.loads(fresh)
r.incr("stats:cache_miss")

owns_lock = bool(r.set(f"lock:{key}", 1, nx=True, ex=10))

if not owns_lock:
    stale = r.get(f"{key}:stale")
    if stale:
        return json.loads(stale)            # serve old data, don't pile on
    for _ in range(20):                      # wait up to ~2s for the leader
        time.sleep(0.1)
        fresh = r.get(key)
        if fresh:
            return json.loads(fresh)
    # Leader never delivered; fall through and fetch ourselves.

try:
    body = api_get(path, params)
    payload = json.dumps(body)
    pipe = r.pipeline()
    pipe.set(key, payload, ex=ttl)
    pipe.set(f"{key}:stale", payload, ex=ttl + stale_ttl)
    pipe.execute()
    return body
except Exception:
    stale = r.get(f"{key}:stale")
    if stale:
        log.warning("upstream failed for %s, serving stale", path)
        return json.loads(stale)
    raise
finally:
    if owns_lock:
        r.delete(f"lock:{key}")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;What each part buys you:&lt;/p&gt;

&lt;p&gt;Single-flight (the lock): if 5,000 requests miss the cache in the same millisecond, one goes upstream. This prevents the "cache stampede" that takes down systems right when the cache expires.&lt;br&gt;
Stale-while-revalidate: callers get slightly old data instantly while one caller refreshes it. For sports scores, "8 seconds old" is better than "an error".&lt;br&gt;
Stale-on-error: if the upstream is rate-limiting or down, you keep serving the last good answer.&lt;br&gt;
Pick TTLs by how fast the data actually changes&lt;/p&gt;

&lt;p&gt;The API reference marks fixtures as cacheable and the live endpoint as not cached. Use that to set your own policy:&lt;/p&gt;

&lt;p&gt;Data    Suggested TTL   Stale window    Why&lt;br&gt;
Fixtures / schedules    5 min   1 hour  Rarely change once published&lt;br&gt;
Standings   60 s (on match day) 10 min  Change only after a result is confirmed&lt;br&gt;
Live matches (bulk) 8 s 2 min   Fast-moving, but one call serves everyone&lt;br&gt;
Teams, competitions 24 h    24 h    Effectively static&lt;/p&gt;

&lt;p&gt;These are starting points. Measure your cache hit ratio (we expose it later) and tune.&lt;/p&gt;

&lt;p&gt;Step 6: The Reconciliation Poller (Your Safety Net)&lt;/p&gt;

&lt;p&gt;Webhooks are excellent, but no push system is perfect. Your endpoint might be down during a deployment. A network blip might swallow a delivery. A bug on your side might drop an event.&lt;/p&gt;

&lt;p&gt;A reconciliation poller quietly compares reality with your state and repairs gaps. It runs slowly because it's a safety net, not the primary path.&lt;/p&gt;

&lt;p&gt;poller.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import logging&lt;br&gt;
import time&lt;br&gt;
from datetime import datetime&lt;/p&gt;

&lt;p&gt;from client import cached_get, r&lt;br&gt;
from config import LIVE_PATH&lt;/p&gt;

&lt;p&gt;logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")&lt;br&gt;
log = logging.getLogger("poller")&lt;/p&gt;

&lt;h1&gt;
  
  
  The 13 sports. Confirm exact slugs in the documentation.
&lt;/h1&gt;

&lt;p&gt;SPORTS = [&lt;br&gt;
    "football", "basketball", "american-football", "cricket", "tennis",&lt;br&gt;
    "baseball", "esports", "combat-sports", "volleyball", "handball",&lt;br&gt;
    "ice-hockey", "golf", "horse-racing",&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;def parse_ts(value):&lt;br&gt;
    return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None&lt;/p&gt;

&lt;p&gt;def reconcile(sport: str) -&amp;gt; int:&lt;br&gt;
    body = cached_get(LIVE_PATH.format(sport=sport), ttl=8, stale_ttl=120)&lt;br&gt;
    items = body.get("data", body) if isinstance(body, dict) else body&lt;br&gt;
    if isinstance(items, dict):         # some responses return a single object&lt;br&gt;
        items = [items]&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repaired = 0
for m in items or []:
    match_id = m.get("fixture_id") or m.get("match_id")
    if not match_id:
        continue

    key = f"match:{match_id}"
    ours = r.hgetall(key)
    api_ts, our_ts = parse_ts(m.get("updated_at")), parse_ts(ours.get("updated_at"))

    missing = not ours
    behind = bool(api_ts and our_ts and api_ts &amp;gt; our_ts)
    if not (missing or behind):
        continue

    score = m.get("score") or {}
    fields = {
        "match_id": match_id,
        "sport": sport,
        "status": m.get("status", "in_play"),
        "minute": m.get("minute"),
        "score": f"{score.get('home')}-{score.get('away')}" if score else None,
        "updated_at": m.get("updated_at"),
        "source": "poll",
    }
    # We deliberately never touch 'seq', so a later webhook still wins.
    r.hset(key, mapping={k: v for k, v in fields.items() if v is not None})
    r.sadd("live:ids", match_id)
    repaired += 1

return repaired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def main(interval: int = 30) -&amp;gt; None:&lt;br&gt;
    while True:&lt;br&gt;
        started = time.time()&lt;br&gt;
        for sport in SPORTS:&lt;br&gt;
            try:&lt;br&gt;
                n = reconcile(sport)&lt;br&gt;
                if n:&lt;br&gt;
                    log.warning("repaired %d %s matches (webhooks missed something)", n, sport)&lt;br&gt;
            except Exception:&lt;br&gt;
                log.exception("reconcile failed for %s", sport)&lt;br&gt;
        time.sleep(max(0, interval - (time.time() - started)))&lt;/p&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    main()&lt;/p&gt;

&lt;p&gt;Notice the log line. If the poller repairs things often, your webhook path has a problem. The poller is both a safety net and an early-warning system.&lt;/p&gt;

&lt;p&gt;One improvement worth making later: skip sports with no matches today (check fixtures once, cached for five minutes), so quiet sports cost you zero requests.&lt;/p&gt;

&lt;p&gt;Also remember that the poller shares the same rate budget as everything else, via acquire(). It can't starve the rest of your system.&lt;/p&gt;

&lt;p&gt;Step 7: Serve Your Own Users From Cache&lt;/p&gt;

&lt;p&gt;Now the payoff. Add two endpoints to app.py that read only from Redis:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
@app.get("/matches/live")&lt;br&gt;
async def live_matches():&lt;br&gt;
    ids = await r.smembers("live:ids")&lt;br&gt;
    pipe = r.pipeline()&lt;br&gt;
    for match_id in ids:&lt;br&gt;
        pipe.hgetall(f"match:{match_id}")&lt;br&gt;
    rows = await pipe.execute()&lt;br&gt;
    return [row for row in rows if row]&lt;/p&gt;

&lt;p&gt;@app.get("/stream")&lt;br&gt;
async def stream():&lt;br&gt;
    """Server-Sent Events: one Redis subscription per client, zero upstream calls."""&lt;br&gt;
    async def events():&lt;br&gt;
        pubsub = r.pubsub()&lt;br&gt;
        await pubsub.subscribe("updates")&lt;br&gt;
        try:&lt;br&gt;
            async for msg in pubsub.listen():&lt;br&gt;
                if msg["type"] == "message":&lt;br&gt;
                    yield f"data: {msg['data']}\n\n"&lt;br&gt;
        finally:&lt;br&gt;
            await pubsub.unsubscribe("updates")&lt;br&gt;
            await pubsub.aclose()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return StreamingResponse(events(), media_type="text/event-stream")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;@app.get("/healthz")&lt;br&gt;
async def healthz():&lt;br&gt;
    try:&lt;br&gt;
        groups = await r.xinfo_groups(STREAM)&lt;br&gt;
        group = next((g for g in groups if g["name"] == GROUP), {})&lt;br&gt;
    except Exception:&lt;br&gt;
        group = {}&lt;br&gt;
    return {&lt;br&gt;
        "stream_length": await r.xlen(STREAM),&lt;br&gt;
        "pending": group.get("pending"),&lt;br&gt;
        "lag": group.get("lag"),&lt;br&gt;
        "dead_letters": await r.xlen(DLQ),&lt;br&gt;
        "live_matches": await r.scard("live:ids"),&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;Browsers can consume the stream with three lines:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
const es = new EventSource("/stream");&lt;br&gt;
es.onmessage = (e) =&amp;gt; updateScoreboard(JSON.parse(e.data));&lt;/p&gt;

&lt;p&gt;Whether you have 50 users or 50,000, your upstream API usage is identical. Scaling users now only costs Redis reads, which are cheap and scale horizontally.&lt;/p&gt;

&lt;p&gt;Run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
uvicorn app:app --workers 2 --port 8000&lt;br&gt;
Can't be bothered to build the fan-out yourself?&lt;/p&gt;

&lt;p&gt;If you only need to display scores and don't need custom logic, you can skip this whole layer. The platform's Widgets embed a live scoreboard, match center or odds board with a script tag, powered by the same live feed. Building fan-out infrastructure is only worth it when your product needs control over data, UI or logic that a widget can't give you.&lt;/p&gt;

&lt;p&gt;Step 8: Register the Webhook (and Replay After Outages)&lt;/p&gt;

&lt;p&gt;Your endpoint must be reachable over HTTPS. During development, use any tunnelling tool to expose localhost:8000. Then register it:&lt;/p&gt;

&lt;p&gt;register.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import requests&lt;br&gt;
from config import API_KEY, BASE_URL, WEBHOOK_SECRET&lt;/p&gt;

&lt;p&gt;resp = requests.post(&lt;br&gt;
    f"{BASE_URL}/webhooks",&lt;br&gt;
    headers={"Authorization": f"Bearer {API_KEY}"},&lt;br&gt;
    json={&lt;br&gt;
        "url": "&lt;a href="https://your-domain.example/webhook" rel="noopener noreferrer"&gt;https://your-domain.example/webhook&lt;/a&gt;",&lt;br&gt;
        "events": ["match.started", "match.goal", "match.finished"],&lt;br&gt;
        "secret": WEBHOOK_SECRET,       # optional: auto-generated if omitted&lt;br&gt;
        # "sport": "football",           # optional: limit to one sport&lt;br&gt;
    },&lt;br&gt;
    timeout=15,&lt;br&gt;
)&lt;br&gt;
resp.raise_for_status()&lt;br&gt;
print(resp.json())&lt;/p&gt;

&lt;p&gt;Subscribe only to the events you actually use. Every unused event type is traffic you pay to receive and process.&lt;/p&gt;

&lt;p&gt;What happens when you're down?&lt;/p&gt;

&lt;p&gt;The docs describe an automatic retry schedule: an immediate attempt, then roughly 30 seconds, 2 minutes, 10 minutes, and 1 hour, after which the delivery is marked failed. Any non-2xx response or a timeout triggers a retry.&lt;/p&gt;

&lt;p&gt;That covers short blips. For longer outages, nothing is lost: missed deliveries can be listed and replayed.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def replay_missed(webhook_id: str) -&amp;gt; None:&lt;br&gt;
    headers = {"Authorization": f"Bearer {API_KEY}"}&lt;br&gt;
    base = f"{BASE_URL}/webhooks/{webhook_id}"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;print(requests.get(f"{base}/deliveries", headers=headers, timeout=15).json())
requests.post(f"{base}/replay", headers=headers, timeout=15).raise_for_status()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Check the docs for any filtering parameters. The important point: replay is only safe because your workers are idempotent. Replayed events carry the same event_id and sequence, so already-applied events are simply ignored. That's the payoff for Step 4.&lt;/p&gt;

&lt;p&gt;A good outage runbook:&lt;/p&gt;

&lt;p&gt;Fix and redeploy your receiver.&lt;br&gt;
Trigger a replay for the window you missed.&lt;br&gt;
Watch the poller's "repaired N matches" log line fall back to zero.&lt;br&gt;
Step 9: The WebSocket Alternative&lt;/p&gt;

&lt;p&gt;If you prefer streaming, the WebSocket API is a drop-in replacement for the ingestion step. Everything downstream (queue, workers, cache, read API) stays exactly the same. That's the benefit of putting a queue between ingestion and processing.&lt;/p&gt;

&lt;p&gt;The docs show these behaviours:&lt;/p&gt;

&lt;p&gt;Authenticate on connect, then send {"action": "subscribe", "channel": "football.live"}&lt;br&gt;
Optional league and match_id filters per subscription&lt;br&gt;
Heartbeats via ping/pong frames&lt;br&gt;
Close codes: 1000 normal, 4001 invalid or missing key, 4008 rate limit exceeded, 1006 abnormal closure&lt;/p&gt;

&lt;p&gt;One catch: the documented WebSocket message has no event_id or sequence. We derive both so the same worker can process it:&lt;/p&gt;

&lt;p&gt;ws_consumer.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import asyncio&lt;br&gt;
import hashlib&lt;br&gt;
import json&lt;br&gt;
import logging&lt;br&gt;
import random&lt;br&gt;
from datetime import datetime&lt;/p&gt;

&lt;p&gt;import redis.asyncio as aioredis&lt;br&gt;
import websockets&lt;/p&gt;

&lt;p&gt;from config import API_KEY, REDIS_URL, STREAM&lt;/p&gt;

&lt;p&gt;log = logging.getLogger("ws")&lt;br&gt;
r = aioredis.from_url(REDIS_URL, decode_responses=True)&lt;/p&gt;

&lt;h1&gt;
  
  
  Confirm the exact auth parameter (api_key vs token) in the docs/sandbox.
&lt;/h1&gt;

&lt;p&gt;URL = f"wss://stream.orbistats.com/v1?api_key={API_KEY}"&lt;br&gt;
CHANNELS = ["football.live", "cricket.live"]&lt;/p&gt;

&lt;p&gt;def normalise(msg: dict) -&amp;gt; dict:&lt;br&gt;
    """Give a WebSocket message the same shape the worker expects."""&lt;br&gt;
    ts = msg["timestamp"]&lt;br&gt;
    fingerprint = f'{msg["match_id"]}|{msg["event"]}|{msg.get("minute")}|{msg.get("team")}|{ts}'&lt;br&gt;
    dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))&lt;br&gt;
    return {&lt;br&gt;
        **msg,&lt;br&gt;
        "event_id": "ws_" + hashlib.sha1(fingerprint.encode()).hexdigest()[:16],&lt;br&gt;
        "sequence": int(dt.timestamp() * 1000),     # ordering by event time&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;async def run() -&amp;gt; None:&lt;br&gt;
    attempt = 0&lt;br&gt;
    while True:&lt;br&gt;
        forced_delay = None&lt;br&gt;
        try:&lt;br&gt;
            async with websockets.connect(URL, ping_interval=20, ping_timeout=20) as ws:&lt;br&gt;
                attempt = 0&lt;br&gt;
                for channel in CHANNELS:&lt;br&gt;
                    await ws.send(json.dumps({"action": "subscribe", "channel": channel}))&lt;br&gt;
                log.info("connected, subscribed to %s", CHANNELS)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            async for raw in ws:
                msg = json.loads(raw)
                if "match_id" not in msg or "event" not in msg:
                    continue                    # skip acks and housekeeping frames
                await r.xadd(STREAM, {"payload": json.dumps(normalise(msg))},
                             maxlen=500_000, approximate=True)

    except websockets.ConnectionClosed as exc:
        code = exc.rcvd.code if exc.rcvd else 1006
        if code == 4001:
            raise SystemExit("API key rejected (close code 4001)")
        forced_delay = 60 if code == 4008 else None   # rate limited: back off hard
        log.warning("connection closed (%s)", code)
    except OSError as exc:
        log.warning("network error: %s", exc)

    attempt += 1
    await asyncio.sleep(forced_delay or (min(30, 2 ** attempt) + random.random()))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    asyncio.run(run())&lt;/p&gt;

&lt;p&gt;Two cautions:&lt;/p&gt;

&lt;p&gt;Don't use webhooks and WebSockets as co-equal primary sources for the same match. Their ordering fields come from different schemes (a server counter versus a timestamp), so mixing them breaks sequence gating. Pick one primary source and keep REST as the safety net.&lt;br&gt;
This path skips the receiver's dedupe. That's fine here, because the worker's sequence gate already ignores duplicates (same timestamp means same sequence, which is not greater than the stored one).&lt;/p&gt;

&lt;p&gt;Also read the close-code handling carefully. 4001 is fatal (retrying will never help), 4008 means you're being rate limited (back off for a long time), and 1006 is an ordinary drop (reconnect with jittered exponential backoff).&lt;/p&gt;

&lt;p&gt;Step 10: Prove It With a Spike Simulator&lt;/p&gt;

&lt;p&gt;Everything above is theory until you test it. This simulator generates a worst-case match day:&lt;/p&gt;

&lt;p&gt;300 matches with 6 events each&lt;br&gt;
20% duplicate deliveries (retries)&lt;br&gt;
Fully shuffled arrival order (so "finished" often arrives before "goal")&lt;br&gt;
Hundreds of concurrent, properly signed requests&lt;/p&gt;

&lt;p&gt;simulate.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import asyncio&lt;br&gt;
import hashlib&lt;br&gt;
import hmac&lt;br&gt;
import json&lt;br&gt;
import random&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;import httpx&lt;br&gt;
import redis&lt;/p&gt;

&lt;p&gt;from config import REDIS_URL, STREAM, WEBHOOK_SECRET&lt;/p&gt;

&lt;p&gt;URL = "&lt;a href="http://localhost:8000/webhook" rel="noopener noreferrer"&gt;http://localhost:8000/webhook&lt;/a&gt;"&lt;br&gt;
MATCHES, PER_MATCH = 300, 6&lt;br&gt;
r = redis.from_url(REDIS_URL, decode_responses=True)&lt;/p&gt;

&lt;p&gt;def sign(raw: bytes) -&amp;gt; str:&lt;br&gt;
    return "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()&lt;/p&gt;

&lt;p&gt;def build_batch() -&amp;gt; list[dict]:&lt;br&gt;
    events = []&lt;br&gt;
    for m in range(MATCHES):&lt;br&gt;
        match_id = 60000 + m&lt;br&gt;
        for seq in range(1, PER_MATCH + 1):&lt;br&gt;
            last = seq == PER_MATCH&lt;br&gt;
            events.append({&lt;br&gt;
                "event": "match.finished" if last else "match.goal",&lt;br&gt;
                "event_id": f"evt_{match_id}&lt;em&gt;{seq}",&lt;br&gt;
                "delivery_id": f"dlv&lt;/em&gt;{match_id}_{seq}",&lt;br&gt;
                "sequence": seq,&lt;br&gt;
                "match_id": match_id,&lt;br&gt;
                "sport": "football",&lt;br&gt;
                **({"final_score": "3-2"} if last else {"team": "Home", "minute": seq * 10}),&lt;br&gt;
                "timestamp": "2026-09-14T16:52:11Z",&lt;br&gt;
            })&lt;br&gt;
    retries = random.sample(events, len(events) // 5)    # duplicate deliveries&lt;br&gt;
    batch = events + retries&lt;br&gt;
    random.shuffle(batch)                                 # out-of-order arrival&lt;br&gt;
    return batch&lt;/p&gt;

&lt;p&gt;async def blast(batch: list[dict], concurrency: int = 100) -&amp;gt; None:&lt;br&gt;
    sem = asyncio.Semaphore(concurrency)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async with httpx.AsyncClient(timeout=5) as client:
    async def send(ev: dict) -&amp;gt; int:
        raw = json.dumps(ev).encode()
        async with sem:
            resp = await client.post(
                URL, content=raw,
                headers={"X-Orbistats-Signature": sign(raw),
                         "Content-Type": "application/json"},
            )
        return resp.status_code

    t0 = time.time()
    codes = await asyncio.gather(*(send(e) for e in batch))
    elapsed = time.time() - t0

ok = sum(c == 200 for c in codes)
print(f"sent {len(batch)} deliveries in {elapsed:.1f}s "
      f"({len(batch) / elapsed:.0f}/s), {ok} acknowledged with 200")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def verify(timeout: int = 30) -&amp;gt; None:&lt;br&gt;
    deadline = time.time() + timeout&lt;br&gt;
    while time.time() &amp;lt; deadline:&lt;br&gt;
        good = sum(&lt;br&gt;
            1 for m in range(MATCHES)&lt;br&gt;
            if (h := r.hgetall(f"match:{60000 + m}")).get("status") == "finished"&lt;br&gt;
            and int(h.get("seq", 0)) == PER_MATCH&lt;br&gt;
        )&lt;br&gt;
        if good == MATCHES:&lt;br&gt;
            break&lt;br&gt;
        time.sleep(1)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;print(f"matches in correct final state: {good}/{MATCHES}")
print(f"unique events enqueued: {r.xlen(STREAM)} (expected {MATCHES * PER_MATCH})")
print(f"live set size: {r.scard('live:ids')} (expected 0)")
print(f"dead letters: {r.xlen('events:dead')} (expected 0)")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    asyncio.run(blast(build_batch()))&lt;br&gt;
    verify()&lt;/p&gt;

&lt;p&gt;Run it (with Redis, the receiver, and at least one worker already running):&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python simulate.py&lt;/p&gt;

&lt;p&gt;What a healthy run looks like:&lt;/p&gt;

&lt;p&gt;All 300 matches end in the correct final state, even though events arrived shuffled&lt;br&gt;
Unique events enqueued equals 1,800, which proves the 20% duplicates were dropped at the door&lt;br&gt;
The live set is empty, because every match ended&lt;br&gt;
Dead letters is zero&lt;/p&gt;

&lt;p&gt;If you want to re-run, clear state first so the "seen" keys don't suppress your events:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
redis-cli FLUSHDB&lt;/p&gt;

&lt;p&gt;Try breaking things on purpose. Kill a worker mid-run and watch reclaim() finish its work. Stop all workers, send the batch, then start them and watch the backlog drain. That's the queue doing its job.&lt;/p&gt;

&lt;p&gt;Observability: What to Watch on Match Day&lt;/p&gt;

&lt;p&gt;You can't fix what you can't see, and on match day you won't have time to dig. Watch these:&lt;/p&gt;

&lt;p&gt;Signal  Where it comes from Why it matters&lt;br&gt;
Queue lag / pending /healthz (lag, pending) Rising lag means workers can't keep up; add workers&lt;br&gt;
Dead letters    /healthz (dead_letters) Anything above zero is a bug to investigate&lt;br&gt;
Webhook ack time (p95)  Receiver logs / APM Must stay well under the 5-second limit&lt;br&gt;
429 count   Log line from api_get   Your budget is too high or something is polling wildly&lt;br&gt;
Cache hit ratio stats:cache_hit vs stats:cache_miss Low ratio means TTLs too short or keys too varied&lt;br&gt;
Poller repairs  Poller warning log  Frequent repairs mean webhooks are being lost&lt;br&gt;
Redis memory    Redis INFO memory   The stream and caches grow during peaks&lt;/p&gt;

&lt;p&gt;A tiny cache-ratio helper:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
hits = int(r.get("stats:cache_hit") or 0)&lt;br&gt;
misses = int(r.get("stats:cache_miss") or 0)&lt;br&gt;
print(f"cache hit ratio: {hits / max(1, hits + misses):.1%}")&lt;/p&gt;

&lt;p&gt;Sensible starting alerts: consumer lag staying above a few hundred for more than a minute, any dead letters, and any sustained run of 429 responses. When your own error rates climb unexpectedly, check the Orbistats status page before you start debugging your own stack. Knowing it's upstream saves a lot of wasted panic.&lt;/p&gt;

&lt;p&gt;Failure Modes and What Actually Happens&lt;br&gt;
Failure Behaviour in this design&lt;br&gt;
Worker crashes mid-event    Message stays pending; reclaim() retries it; the atomic script prevents half-applied state&lt;br&gt;
Duplicate delivery (retry)  Dropped at the receiver by event_id, or ignored by the sequence gate&lt;br&gt;
Out-of-order delivery   Sequence gate keeps the newest state&lt;br&gt;
Upstream returns 429    All workers pause; Retry-After honoured; stale cache served meanwhile&lt;br&gt;
Upstream returns 5xx    Jittered exponential backoff; stale cache served&lt;br&gt;
Your endpoint down for hours    Retries exhaust; use replay; poller repairs the gap&lt;br&gt;
Redis restarts  AOF persistence preserves the stream; consumers resume from their group position&lt;br&gt;
Poison event    Retried 5 times, then moved to the dead-letter stream&lt;br&gt;
Sudden user surge   Reads hit Redis only; upstream load is unchanged&lt;br&gt;
Bad signature   Rejected with 401, never enqueued&lt;br&gt;
Match-Day Checklist&lt;/p&gt;

&lt;p&gt;Run through this the day before a big fixture list:&lt;/p&gt;

&lt;p&gt;Budget set below your real plan limit, using the headers you observe&lt;br&gt;
Webhook subscribed only to events you use&lt;br&gt;
Receiver deployed behind HTTPS with more than one process&lt;br&gt;
At least two workers running, with a way to add more quickly&lt;br&gt;
Poller running on a relaxed interval&lt;br&gt;
Dead-letter alert configured&lt;br&gt;
Redis persistence on and memory headroom confirmed&lt;br&gt;
Spike simulator run against staging in the last week&lt;br&gt;
Replay procedure written down, so nobody improvises during an outage&lt;br&gt;
Stream maxlen large enough that a long outage can't trim unprocessed events&lt;br&gt;
Secrets (API key, webhook secret) kept out of git&lt;br&gt;
Ideas to Extend This Project&lt;/p&gt;

&lt;p&gt;Once the foundation holds, there's a lot you can build on top of it:&lt;/p&gt;

&lt;p&gt;Odds-movement pipeline. Subscribe to odds.moved and feed it through the same queue and workers. The Odds API uses the same delivery model, so the architecture carries over almost unchanged.&lt;br&gt;
Low-latency pricing tools. If sub-second reaction matters, the pattern here is the same one Trading Desks rely on: a persistent stream, an atomic state layer, and no slow work in the hot path.&lt;br&gt;
Live match centers for newsrooms. Publishers get the most from the cache-first read API and SSE stream. The Media &amp;amp; Publishers page describes that use case.&lt;br&gt;
Per-sport tuning. Different sports spike differently. Cricket has long quiet stretches and sudden bursts, tennis has many parallel matches, and football clusters at fixed kickoff times. Give each its own TTLs and worker pool.&lt;br&gt;
Backpressure and load shedding. When lag grows past a threshold, drop low-value events (like minute ticks) and keep goals and final results.&lt;br&gt;
Autoscaling workers based on consumer lag rather than CPU.&lt;br&gt;
Multi-region read replicas for the read API if your audience is global.&lt;br&gt;
Wrapping Up&lt;/p&gt;

&lt;p&gt;We took a consumer that works on a quiet Tuesday and made it survive a synchronized Saturday:&lt;/p&gt;

&lt;p&gt;Acknowledge fast. The receiver verifies, dedupes and enqueues. Nothing slow happens inline.&lt;br&gt;
Queue everything. Redis Streams absorb bursts, and consumer groups scale workers horizontally.&lt;br&gt;
Make processing idempotent. Sequence gating and an atomic script make duplicates and reordering harmless.&lt;br&gt;
Share the rate budget. One pause signal across all workers, jittered backoff, and Retry-After respected.&lt;br&gt;
Cache like you mean it. Single-flight, stale-while-revalidate, and stale-on-error mean users never cost an upstream call.&lt;br&gt;
Keep a safety net. A slow reconciliation poller and webhook replay mean "missed" doesn't mean "lost".&lt;br&gt;
Test the ugly case. The simulator proves it with duplicates, shuffled order and concurrency.&lt;/p&gt;

&lt;p&gt;If you remember one principle, make it this: decouple the rate at which data arrives from the rate at which you process it, and the rate at which users read from the rate at which you fetch. Queues and caches are just that idea applied twice.&lt;/p&gt;

&lt;p&gt;If you build on this (an autoscaler, a metrics dashboard, a multi-sport version), share it in the comments. And if the sandbox returns a payload shape that differs from the examples here, paste it below and I'll help adapt to_fields().&lt;/p&gt;

&lt;p&gt;Happy scaling! ⚡&lt;/p&gt;

</description>
      <category>python</category>
      <category>redis</category>
      <category>architecture</category>
      <category>api</category>
    </item>
    <item>
      <title>Backtesting a Betting Strategy with Historical Odds Data in Python</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Wed, 07 Oct 2026 16:02:36 +0000</pubDate>
      <link>https://dev.to/orbistats/backtesting-a-betting-strategy-with-historical-odds-data-in-python-ppe</link>
      <guid>https://dev.to/orbistats/backtesting-a-betting-strategy-with-historical-odds-data-in-python-ppe</guid>
      <description>&lt;p&gt;Every betting strategy sounds brilliant until you test it.&lt;/p&gt;

&lt;p&gt;"Always back the home favourite." "Bet the draw when the match looks tight." "Fade the longshots." You can hear people argue about rules like these in any group chat. Almost nobody checks them against thousands of past matches.&lt;/p&gt;

&lt;p&gt;In this tutorial we'll do exactly that. We'll build a complete backtesting pipeline in Python that:&lt;/p&gt;

&lt;p&gt;Pulls multi-season historical results and closing odds from an API&lt;br&gt;
Converts odds into implied probabilities and strips out the bookmaker's margin&lt;br&gt;
Tests several strategies and reports ROI, hit rate and drawdown&lt;br&gt;
Checks whether the results are statistically meaningful (or just luck)&lt;br&gt;
Splits data by time, so you don't fool yourself with overfitting&lt;/p&gt;

&lt;p&gt;I'll be honest up front: most simple strategies lose money once you test them properly. That isn't a failure of the tutorial. It's the main lesson. A backtest that tells you "don't bet this" has saved you real money.&lt;/p&gt;

&lt;p&gt;Why Backtesting Matters&lt;/p&gt;

&lt;p&gt;A backtest replays a rule against history and asks, "What would have happened if I had followed this rule mechanically?"&lt;/p&gt;

&lt;p&gt;It's valuable because human memory is terrible at statistics. We remember the three times the underdog won and forget the forty times it didn't. A backtest forgets nothing.&lt;/p&gt;

&lt;p&gt;It also teaches you the core ideas behind pricing and modelling:&lt;/p&gt;

&lt;p&gt;Odds are prices with a margin built in. You pay that margin on every bet.&lt;br&gt;
Returns are noisy. A strategy can look profitable over 100 bets and be pure luck.&lt;br&gt;
The data you test on must not leak into the rule you design. This is called look-ahead bias, and it ruins more backtests than any bug.&lt;/p&gt;

&lt;p&gt;Teams doing this professionally (trading desks, analytics teams, researchers) rely on exactly this workflow, just at larger scale.&lt;/p&gt;

&lt;p&gt;What You'll Need&lt;br&gt;
Python 3.10+&lt;br&gt;
A free API key from Orbistats&lt;br&gt;
Basic familiarity with pandas&lt;br&gt;
About 45 minutes&lt;br&gt;
Choosing a data source&lt;/p&gt;

&lt;p&gt;Backtests are only as good as the history underneath them. The usual problems are shallow archives (two or three seasons), inconsistent team names, and a different format for history than for live data, so your model breaks the moment you deploy it.&lt;/p&gt;

&lt;p&gt;I'm using the Orbistats Historical Sports Data API because it addresses those problems directly. It provides multi-season archives (football goes back to 2000+, most other sports to 2005 or 2010), including results, statistics, lineups and closing odds, all in the same schema as the live feed. That last point matters. A model trained on 2018 data reads identically to a match happening today, with no mapping layer in between.&lt;/p&gt;

&lt;p&gt;Coverage differs by sport, so here's the depth the product page lists for all 13 sports:&lt;/p&gt;

&lt;p&gt;Sport   History from&lt;br&gt;
Football    2000+&lt;br&gt;
Basketball  2005+&lt;br&gt;
American Football   2005+&lt;br&gt;
Cricket 2005+&lt;br&gt;
Tennis  2005+&lt;br&gt;
Baseball    2010+&lt;br&gt;
Combat Sports   2010+&lt;br&gt;
Volleyball  2010+&lt;br&gt;
Handball    2010+&lt;br&gt;
Ice Hockey  2010+&lt;br&gt;
Golf    2010+&lt;br&gt;
Horse Racing    2010+&lt;br&gt;
Esports 2015+&lt;/p&gt;

&lt;p&gt;Treat that table as a ceiling, not a promise. Depth varies by league, and the coverage matrix in the API reference shows what's live per sport today. Check it before you pick a sport to test.&lt;/p&gt;

&lt;p&gt;For this walkthrough I'll use English Premier League football, since it has the deepest archive and the cleanest three-way (home/draw/away) market.&lt;/p&gt;

&lt;p&gt;Step 1: Get Your API Key and Explore&lt;br&gt;
Create an account on the sign-up page and copy your key.&lt;br&gt;
Read the documentation and the quickstart to see the auth and response conventions.&lt;br&gt;
Try a few calls in the sandbox before writing code, so you can see the real JSON.&lt;/p&gt;

&lt;p&gt;Authentication is a bearer token:&lt;/p&gt;

&lt;p&gt;http&lt;br&gt;
Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;Base URL: &lt;a href="https://api.orbistats.com/v1/" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every response uses a consistent envelope:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "data": [],&lt;br&gt;
  "meta": { "pagination": {}, "generated_at": "2026-08-28T10:00:00Z" },&lt;br&gt;
  "errors": []&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A free key lets you explore recent seasons. Deeper archives, bulk export and higher limits sit on paid plans, so check the pricing page for what your tier includes.&lt;/p&gt;

&lt;p&gt;Step 2: Project Setup&lt;br&gt;
bash&lt;br&gt;
mkdir odds-backtest &amp;amp;&amp;amp; cd odds-backtest&lt;br&gt;
python -m venv .venv&lt;br&gt;
source .venv/bin/activate          # Windows: .venv\Scripts\activate&lt;br&gt;
pip install requests pandas numpy matplotlib python-dotenv&lt;/p&gt;

&lt;p&gt;Create .env:&lt;/p&gt;

&lt;p&gt;env&lt;br&gt;
ORBISTATS_API_KEY=your_key_here&lt;/p&gt;

&lt;p&gt;And add it to .gitignore right now, before you forget:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
.env&lt;br&gt;
data_cache/&lt;br&gt;
Step 3: Fetch Historical Odds and Results&lt;/p&gt;

&lt;p&gt;Create backtest.py. First the configuration and the API client:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import os&lt;br&gt;
import json&lt;br&gt;
import time&lt;br&gt;
import logging&lt;br&gt;
from pathlib import Path&lt;/p&gt;

&lt;p&gt;import numpy as np&lt;br&gt;
import pandas as pd&lt;br&gt;
import requests&lt;br&gt;
import matplotlib.pyplot as plt&lt;br&gt;
from dotenv import load_dotenv&lt;/p&gt;

&lt;p&gt;load_dotenv()&lt;/p&gt;

&lt;p&gt;API_KEY = os.environ["ORBISTATS_API_KEY"]&lt;br&gt;
BASE_URL = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;SPORT = "football"&lt;br&gt;
LEAGUE = "premier-league"&lt;br&gt;
SEASON_FROM = "2019-2020"&lt;br&gt;
SEASON_TO = "2024-2025"&lt;/p&gt;

&lt;h1&gt;
  
  
  The docs show two historical routes. Confirm which one your key uses
&lt;/h1&gt;

&lt;h1&gt;
  
  
  in the sandbox, then set it here:
&lt;/h1&gt;

&lt;h1&gt;
  
  
  "history/matches"  (API reference)
&lt;/h1&gt;

&lt;h1&gt;
  
  
  "historical/results" (Historical Data page; season= instead of a range)
&lt;/h1&gt;

&lt;p&gt;HISTORY_PATH = "history/matches"&lt;/p&gt;

&lt;p&gt;CACHE = Path("data_cache") / f"{SPORT}&lt;em&gt;{LEAGUE}&lt;/em&gt;{SEASON_FROM}_{SEASON_TO}.json"&lt;br&gt;
CACHE.parent.mkdir(exist_ok=True)&lt;/p&gt;

&lt;p&gt;logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")&lt;br&gt;
log = logging.getLogger("backtest")&lt;/p&gt;

&lt;p&gt;session = requests.Session()&lt;br&gt;
session.headers.update({"Authorization": f"Bearer {API_KEY}"})&lt;/p&gt;

&lt;p&gt;Now the fetcher. Historical queries return a lot of rows, so it needs pagination, and it needs to behave when it hits a rate limit:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def fetch_history(sport, league, season_from, season_to, per_page=100):&lt;br&gt;
    url = f"{BASE_URL}/{sport}/{HISTORY_PATH}"&lt;br&gt;
    params = {&lt;br&gt;
        "league": league,&lt;br&gt;
        "season_from": season_from,&lt;br&gt;
        "season_to": season_to,&lt;br&gt;
        "per_page": per_page,&lt;br&gt;
    }&lt;br&gt;
    rows, page = [], 1&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;while True:
    resp = session.get(url, params=params, timeout=30)

    if resp.status_code == 429:
        wait = int(resp.headers.get("Retry-After", 30))
        log.warning("Rate limited, sleeping %ss", wait)
        time.sleep(wait)
        continue

    resp.raise_for_status()
    body = resp.json()
    batch = body.get("data") or []
    rows.extend(batch)
    log.info("Fetched %d rows (total %d)", len(batch), len(rows))

    pagination = (body.get("meta") or {}).get("pagination") or {}
    cursor = pagination.get("next_cursor")

    if cursor:                                   # cursor overrides page
        params["cursor"] = cursor
    elif batch and pagination.get("total") and len(rows) &amp;lt; pagination["total"]:
        page += 1
        params["page"] = page
    else:
        break

return rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The reference recommends cursor pagination for large historical pulls and caps per_page at 100. Five Premier League seasons is about 1,900 matches, so that's roughly 19 requests. That's tiny compared with a 100-requests-per-minute limit, but it adds up fast if you loop over many leagues or sports.&lt;/p&gt;

&lt;p&gt;Cache the raw response. You'll rerun your analysis dozens of times, and there's no reason to re-download history each time:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def load_raw():&lt;br&gt;
    if CACHE.exists():&lt;br&gt;
        log.info("Loading cached data from %s", CACHE)&lt;br&gt;
        return json.loads(CACHE.read_text())&lt;br&gt;
    raw = fetch_history(SPORT, LEAGUE, SEASON_FROM, SEASON_TO)&lt;br&gt;
    CACHE.write_text(json.dumps(raw))&lt;br&gt;
    return raw&lt;br&gt;
Step 4: Turn Raw JSON into a Clean DataFrame&lt;/p&gt;

&lt;p&gt;The API's sample match looks like this:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "fixture_id": 41207,&lt;br&gt;
  "home_team": "Arsenal",&lt;br&gt;
  "away_team": "Chelsea",&lt;br&gt;
  "final_score": { "home": 2, "away": 1 },&lt;br&gt;
  "closing_odds": { "home": 1.95, "draw": 3.60, "away": 3.80 },&lt;br&gt;
  "kickoff": "2024-10-27T15:30:00Z"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;I always isolate the "messy to tidy" step in one function, so if a field name changes, there's exactly one place to edit:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def flatten(m: dict) -&amp;gt; dict | None:&lt;br&gt;
    try:&lt;br&gt;
        odds = m["closing_odds"]&lt;br&gt;
        score = m["final_score"]&lt;br&gt;
        return {&lt;br&gt;
            "fixture_id": m["fixture_id"],&lt;br&gt;
            "kickoff": m["kickoff"],&lt;br&gt;
            "home_team": m["home_team"],&lt;br&gt;
            "away_team": m["away_team"],&lt;br&gt;
            "home_goals": score["home"],&lt;br&gt;
            "away_goals": score["away"],&lt;br&gt;
            "odds_home": odds.get("home"),&lt;br&gt;
            "odds_draw": odds.get("draw"),&lt;br&gt;
            "odds_away": odds.get("away"),&lt;br&gt;
        }&lt;br&gt;
    except (KeyError, TypeError):&lt;br&gt;
        return None   # skip matches with no odds or no final score&lt;/p&gt;

&lt;p&gt;def build_frame(raw: list[dict]) -&amp;gt; pd.DataFrame:&lt;br&gt;
    rows = [r for r in map(flatten, raw) if r]&lt;br&gt;
    df = pd.DataFrame(rows)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;df["kickoff"] = pd.to_datetime(df["kickoff"], utc=True)
for col in ("odds_home", "odds_draw", "odds_away"):
    df[col] = pd.to_numeric(df[col], errors="coerce")

df = (
    df.dropna(subset=["odds_home", "odds_away"])
      .drop_duplicates("fixture_id")
      .sort_values("kickoff")
      .reset_index(drop=True)
)

df["result"] = np.select(
    [df.home_goals &amp;gt; df.away_goals, df.home_goals &amp;lt; df.away_goals],
    ["home", "away"],
    default="draw",
)
return df
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Always sanity check before you trust anything:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
df = build_frame(load_raw())&lt;br&gt;
print(len(df), "matches")&lt;br&gt;
print(df["kickoff"].min(), "-&amp;gt;", df["kickoff"].max())&lt;br&gt;
print(df["result"].value_counts(normalize=True).round(3))&lt;/p&gt;

&lt;p&gt;For a Premier League sample you should see roughly 380 matches per season, and home wins should outnumber away wins. If you see 40 matches or wildly odd percentages, something is wrong with the fetch, not the strategy.&lt;/p&gt;

&lt;p&gt;Step 5: Understand Odds, Implied Probability and the Margin&lt;/p&gt;

&lt;p&gt;This is the most important concept in the whole tutorial, so let's slow down.&lt;/p&gt;

&lt;p&gt;Decimal odds convert to an implied probability:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
implied probability = 1 / odds&lt;/p&gt;

&lt;p&gt;Odds of 2.00 imply 50%. Odds of 4.00 imply 25%. (The glossary has plain-English definitions of these and related terms if you want a refresher.)&lt;/p&gt;

&lt;p&gt;Now take the sample match: home 1.95, draw 3.60, away 3.80.&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
1/1.95 = 0.513&lt;br&gt;
1/3.60 = 0.278&lt;br&gt;
1/3.80 = 0.263&lt;br&gt;
Total  = 1.054&lt;/p&gt;

&lt;p&gt;The three probabilities add up to 105.4%, not 100%. That extra 5.4% is the bookmaker's margin (the "overround" or "vig"). It's the fee you pay on every bet.&lt;/p&gt;

&lt;p&gt;To get margin-free "fair" probabilities, divide each implied probability by the total:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
fair home = 0.513 / 1.054 = 48.7%&lt;/p&gt;

&lt;p&gt;Here's the consequence for backtesting. If you blindly bet every outcome at a book with a 5% margin, your expected ROI isn't zero. It's about:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
1 / 1.05 - 1 = -4.8%&lt;/p&gt;

&lt;p&gt;That's your baseline. A strategy has to beat roughly minus the margin just to look average. Keep this number in your head when you read the results below.&lt;/p&gt;

&lt;p&gt;Step 6: Reshape into One Row per Selection&lt;/p&gt;

&lt;p&gt;Here's a design decision that pays off later. Instead of one row per match with three odds columns, build a long table with one row per selection (match plus outcome). Then every strategy becomes a simple filter, and the same code works for sports with two outcomes (tennis, basketball) or three.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
OUTCOMES = ("home", "draw", "away")&lt;/p&gt;

&lt;p&gt;def to_selections(df: pd.DataFrame) -&amp;gt; pd.DataFrame:&lt;br&gt;
    frames = []&lt;br&gt;
    for o in OUTCOMES:&lt;br&gt;
        col = f"odds_{o}"&lt;br&gt;
        part = df[["fixture_id", "kickoff", "home_team", "away_team", "result", col]]&lt;br&gt;
        part = part.rename(columns={col: "odds"}).assign(outcome=o)&lt;br&gt;
        frames.append(part)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sel = pd.concat(frames).dropna(subset=["odds"])

sel["implied"] = 1 / sel["odds"]
sel["overround"] = sel.groupby("fixture_id")["implied"].transform("sum")
sel["fair_p"] = sel["implied"] / sel["overround"]
sel["won"] = sel["outcome"] == sel["result"]
# rank 1 = shortest price = the favourite
sel["rank"] = sel.groupby("fixture_id")["odds"].rank(method="first")
sel["profit"] = np.where(sel["won"], sel["odds"] - 1, -1.0)   # 1-unit flat stake

return sel.sort_values(["kickoff", "fixture_id"]).reset_index(drop=True)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;sel = to_selections(df)&lt;br&gt;
print("Average margin: {:.2%}".format(sel.groupby("fixture_id")["overround"].first().mean() - 1))&lt;/p&gt;

&lt;p&gt;That last line tells you the typical margin in your dataset. Use it to compute your baseline ROI from Step 5.&lt;/p&gt;

&lt;p&gt;Step 7: Define Strategies as Simple Rules&lt;/p&gt;

&lt;p&gt;Each strategy is just a function that returns a boolean mask over the selections table:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def is_underdog(s):&lt;br&gt;
    return s["rank"] == s.groupby("fixture_id")["rank"].transform("max")&lt;/p&gt;

&lt;p&gt;STRATEGIES = {&lt;br&gt;
    "Always home":        lambda s: s["outcome"] == "home",&lt;br&gt;
    "Always draw":        lambda s: s["outcome"] == "draw",&lt;br&gt;
    "Always away":        lambda s: s["outcome"] == "away",&lt;br&gt;
    "Favourite":          lambda s: s["rank"] == 1,&lt;br&gt;
    "Underdog":           is_underdog,&lt;br&gt;
    "Longshots (p&amp;lt;12%)":  lambda s: s["fair_p"] &amp;lt; 0.12,&lt;br&gt;
    "Tight games: draw":  lambda s: (s["outcome"] == "draw") &amp;amp; (s["fair_p"] &amp;gt; 0.27),&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;These aren't meant to be winners. They're a spread of the kinds of rules people actually believe in, so you can see how the margin treats each one.&lt;/p&gt;

&lt;p&gt;Step 8: Run the Backtest and Measure Results&lt;br&gt;
python&lt;br&gt;
def run_strategy(sel: pd.DataFrame, mask) -&amp;gt; pd.DataFrame:&lt;br&gt;
    bets = sel[mask(sel)].copy().sort_values("kickoff")&lt;br&gt;
    bets["cum"] = bets["profit"].cumsum()&lt;br&gt;
    return bets&lt;/p&gt;

&lt;p&gt;def summarize(bets: pd.DataFrame) -&amp;gt; dict:&lt;br&gt;
    n = len(bets)&lt;br&gt;
    if n == 0:&lt;br&gt;
        return {"bets": 0}&lt;br&gt;
    peak = np.maximum(bets["cum"].cummax(), 0)&lt;br&gt;
    return {&lt;br&gt;
        "bets": n,&lt;br&gt;
        "hit_rate_%": round(100 * bets["won"].mean(), 1),&lt;br&gt;
        "avg_odds": round(bets["odds"].mean(), 2),&lt;br&gt;
        "profit_units": round(bets["profit"].sum(), 1),&lt;br&gt;
        "roi_%": round(100 * bets["profit"].mean(), 2),&lt;br&gt;
        "max_drawdown": round((bets["cum"] - peak).min(), 1),&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;results = {name: run_strategy(sel, rule) for name, rule in STRATEGIES.items()}&lt;br&gt;
report = pd.DataFrame({name: summarize(b) for name, b in results.items()}).T&lt;br&gt;
print(report.sort_values("roi_%", ascending=False))&lt;br&gt;
How to read the table&lt;br&gt;
bets: sample size. Be suspicious of any strategy with fewer than a few hundred.&lt;br&gt;
hit_rate_%: how often it wins. Meaningless without the average odds next to it.&lt;br&gt;
avg_odds: a 25% hit rate at average odds of 4.5 is a very different story from 25% at 2.5.&lt;br&gt;
roi_%: profit divided by total staked. The number everyone cares about.&lt;br&gt;
max_drawdown: the worst peak-to-trough fall in units. This is the pain you'd have to sit through, and it's what actually makes people abandon strategies.&lt;/p&gt;

&lt;p&gt;Expect most rows to land near your baseline (minus the margin) with some noise. That's the market being reasonably efficient. If a strategy shows a big positive ROI, don't celebrate yet. Move to the next step.&lt;/p&gt;

&lt;p&gt;Step 9: Is It Skill or Luck? Bootstrap the ROI&lt;/p&gt;

&lt;p&gt;A strategy that made +6% over 300 bets might simply have gotten lucky. A bootstrap estimates how much the ROI could wobble by resampling your bets with replacement:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def bootstrap_roi(bets: pd.DataFrame, n_boot: int = 2000, seed: int = 42):&lt;br&gt;
    rng = np.random.default_rng(seed)&lt;br&gt;
    p = bets["profit"].to_numpy()&lt;br&gt;
    means = rng.choice(p, size=(n_boot, len(p)), replace=True).mean(axis=1) * 100&lt;br&gt;
    low, high = np.percentile(means, [2.5, 97.5])&lt;br&gt;
    return round(low, 2), round(high, 2)&lt;/p&gt;

&lt;p&gt;for name, bets in results.items():&lt;br&gt;
    if len(bets) &amp;gt;= 100:&lt;br&gt;
        lo, hi = bootstrap_roi(bets)&lt;br&gt;
        print(f"{name:&amp;lt;22} 95% CI for ROI: [{lo:&amp;gt;6}%, {hi:&amp;gt;6}%]")&lt;/p&gt;

&lt;p&gt;The rule of thumb: if the 95% interval includes zero (or your baseline), you cannot claim the strategy has an edge. Nearly every simple strategy fails this test, and learning to see that is half of what makes a good analyst.&lt;/p&gt;

&lt;p&gt;Step 10: Check Calibration (Where Edges Actually Hide)&lt;/p&gt;

&lt;p&gt;Instead of testing random rules, ask the data a sharper question: are the market's probabilities well calibrated? If outcomes priced at 10% really win 10% of the time, there's no free lunch. If they win 12%, there might be.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def calibration(sel: pd.DataFrame) -&amp;gt; pd.DataFrame:&lt;br&gt;
    bins = [0, .05, .10, .15, .20, .30, .40, .50, .60, .80, 1.0]&lt;br&gt;
    s = sel.copy()&lt;br&gt;
    s["bin"] = pd.cut(s["fair_p"], bins=bins)&lt;br&gt;
    return (&lt;br&gt;
        s.groupby("bin", observed=True)&lt;br&gt;
         .agg(n=("won", "size"),&lt;br&gt;
              fair_p=("fair_p", "mean"),&lt;br&gt;
              actual=("won", "mean"),&lt;br&gt;
              roi_pct=("profit", lambda x: 100 * x.mean()))&lt;br&gt;
         .round(3)&lt;br&gt;
    )&lt;/p&gt;

&lt;p&gt;print(calibration(sel))&lt;/p&gt;

&lt;p&gt;Compare fair_p to actual in each row. A well-known pattern in betting markets is the favourite-longshot bias: very long shots are often overpriced and heavy favourites slightly underpriced. Whether it shows up in your sample, and whether it survives the margin, is exactly the kind of thing a backtest can tell you. It's also a much better starting point for a model than a gut feeling.&lt;/p&gt;

&lt;p&gt;Step 11: Avoid the #1 Backtesting Mistake (Overfitting)&lt;/p&gt;

&lt;p&gt;Here's how people fool themselves. They try 50 rules, pick the one with the best ROI, and report it. But if you test 50 rules on the same data, a few will look great by pure chance.&lt;/p&gt;

&lt;p&gt;The fix is simple: tune on the past, judge on the future.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
cut = sel["kickoff"].sort_values().iloc[int(len(sel) * 0.6)]&lt;br&gt;
train, test = sel[sel["kickoff"] &amp;lt;= cut], sel[sel["kickoff"] &amp;gt; cut]&lt;/p&gt;

&lt;p&gt;best_thr, best_roi = None, -999&lt;br&gt;
for thr in [0.06, 0.08, 0.10, 0.12, 0.15, 0.18]:&lt;br&gt;
    roi = 100 * train.loc[train["fair_p"] &amp;lt; thr, "profit"].mean()&lt;br&gt;
    print(f"train  fair_p &amp;lt; {thr:.2f}: ROI {roi:6.2f}%")&lt;br&gt;
    if roi &amp;gt; best_roi:&lt;br&gt;
        best_thr, best_roi = thr, roi&lt;/p&gt;

&lt;p&gt;test_roi = 100 * test.loc[test["fair_p"] &amp;lt; best_thr, "profit"].mean()&lt;br&gt;
print(f"\nChosen threshold: {best_thr}  train ROI {best_roi:.2f}%  test ROI {test_roi:.2f}%")&lt;/p&gt;

&lt;p&gt;If the test ROI collapses compared with the train ROI, you found noise, not an edge. That gap is the honest measure of how much a strategy is worth.&lt;/p&gt;

&lt;p&gt;For something stricter, try walk-forward testing: tune on seasons 1 to 3, test on season 4, then tune on seasons 1 to 4, test on season 5, and so on. It mimics how you'd actually use the rule in real life.&lt;/p&gt;

&lt;p&gt;Step 12: Visualize the Equity Curves&lt;/p&gt;

&lt;p&gt;Numbers are great, but a picture of the drawdowns makes the risk feel real:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def plot_curves(results: dict):&lt;br&gt;
    fig, ax = plt.subplots(figsize=(11, 6))&lt;br&gt;
    for name, bets in results.items():&lt;br&gt;
        if len(bets):&lt;br&gt;
            ax.plot(bets["kickoff"], bets["cum"], label=name, linewidth=1.4)&lt;br&gt;
    ax.axhline(0, color="grey", linewidth=0.8)&lt;br&gt;
    ax.set_title("Cumulative profit by strategy (1-unit flat stakes)")&lt;br&gt;
    ax.set_ylabel("Profit (units)")&lt;br&gt;
    ax.legend(fontsize=8)&lt;br&gt;
    fig.tight_layout()&lt;br&gt;
    fig.savefig("equity_curves.png", dpi=150)&lt;/p&gt;

&lt;p&gt;plot_curves(results)&lt;/p&gt;

&lt;p&gt;You'll almost certainly see lines that drift downward at roughly the rate the margin predicts, with some noisy detours. Strategies that look profitable for six months and then fall off a cliff are the classic shape of a fluke.&lt;/p&gt;

&lt;p&gt;Step 13: Simulate a Real Bankroll&lt;/p&gt;

&lt;p&gt;Flat one-unit stakes are clean for analysis, but real money compounds. Here's a quick bankroll simulation staking a fixed percentage:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def simulate_bankroll(bets: pd.DataFrame, start=1000.0, pct=0.01) -&amp;gt; pd.Series:&lt;br&gt;
    bankroll, path = start, []&lt;br&gt;
    for _, b in bets.iterrows():&lt;br&gt;
        stake = bankroll * pct&lt;br&gt;
        bankroll += stake * (b["odds"] - 1) if b["won"] else -stake&lt;br&gt;
        path.append(bankroll)&lt;br&gt;
    return pd.Series(path, index=bets["kickoff"].values)&lt;/p&gt;

&lt;p&gt;curve = simulate_bankroll(results["Favourite"])&lt;br&gt;
print(f"Start 1000 -&amp;gt; End {curve.iloc[-1]:.0f}")&lt;/p&gt;

&lt;p&gt;A note on Kelly staking: the formula f = (p*odds - 1) / (odds - 1) is popular, but it requires your own estimate of the true probability p. Plugging in the market's probability just gives you zero or negative stakes. Kelly is only as good as your model, and most people overestimate their model. Fractional Kelly (a quarter or half) is far safer.&lt;/p&gt;

&lt;p&gt;Step 14: Run the Whole Thing&lt;/p&gt;

&lt;p&gt;Add the entry point at the bottom of backtest.py:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    df = build_frame(load_raw())&lt;br&gt;
    sel = to_selections(df)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;results = {n: run_strategy(sel, r) for n, r in STRATEGIES.items()}
report = pd.DataFrame({n: summarize(b) for n, b in results.items()}).T
print(report.sort_values("roi_%", ascending=False).to_string())

print("\nCalibration:")
print(calibration(sel).to_string())

plot_curves(results)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;bash&lt;br&gt;
python backtest.py&lt;br&gt;
Pulling Much More Data: Bulk Export&lt;/p&gt;

&lt;p&gt;REST pagination is fine for a few thousand matches. If you want ten seasons across several leagues, the historical product page documents a bulk export that returns whole seasons as a compressed file, instead of paging through thousands of calls. The documented request body looks like this:&lt;/p&gt;

&lt;p&gt;http&lt;br&gt;
POST /v1/football/historical/export&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "league": "premier-league",&lt;br&gt;
  "seasons": ["2020", "2021", "2022", "2023", "2024"],&lt;br&gt;
  "include": ["results", "statistics", "odds"],&lt;br&gt;
  "format": "json"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Check your plan's access and the exact response behaviour in the docs before relying on it, since bulk export is listed as an upgraded feature.&lt;/p&gt;

&lt;p&gt;Taking It to Other Sports&lt;/p&gt;

&lt;p&gt;Because the historical data uses one schema for every sport, our to_selections design carries over with almost no changes. A few notes:&lt;/p&gt;

&lt;p&gt;Two-way markets like tennis have no draw, so odds_draw is simply missing and the code drops it. Remember that "favourite" strategies behave very differently when the favourite wins 70% of the time.&lt;br&gt;
Basketball is high-scoring and usually has no draw, so margins and calibration look different from football.&lt;br&gt;
Cricket has formats (Test, ODI, T20) with different draw probabilities. Test each format separately rather than mixing them.&lt;br&gt;
Coverage varies. The coverage matrix currently shows historical data for most sports, but not every one. Verify before building around a specific sport.&lt;/p&gt;

&lt;p&gt;For the thirteen sports Orbistats lists, the same pipeline works wherever history exists: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing. Start with the deepest archives (football, basketball) while you learn.&lt;/p&gt;

&lt;p&gt;Closing Line Value: A Better Test Than ROI&lt;/p&gt;

&lt;p&gt;ROI over a few hundred bets is mostly noise. Professionals often use a different yardstick: closing line value (CLV). The idea is that the closing price is the market's most informed estimate. If you consistently bet at prices better than the close, you're probably finding real information, even before the profits show up.&lt;/p&gt;

&lt;p&gt;To measure CLV you need an opening or intermediate price and the close. The historical odds documentation mentions closing lines on all plans and full line-movement history on higher plans, which is what makes this kind of analysis possible. If you're building something for a trading team, that's the data to look at; the Trading Desks page describes the use case, and the Analytics &amp;amp; Data Science page covers research-style workloads.&lt;/p&gt;

&lt;p&gt;Common Backtesting Pitfalls (Read This Twice)&lt;br&gt;
Look-ahead bias. Never use information that wasn't available at bet time. Final scores, end-of-season standings and post-match statistics are all off-limits for pre-match decisions.&lt;br&gt;
Closing-odds optimism. You usually can't bet at the exact closing price, and stake limits and price changes apply. Treat closing-odds backtests as an upper bound.&lt;br&gt;
Ignoring the margin. Always compare results to the baseline from Step 5, not to zero.&lt;br&gt;
Tiny samples. Under a few hundred bets, almost anything can look good.&lt;br&gt;
Data snooping. Every extra rule you try inflates the chance of a false discovery. Keep a held-out test set and touch it once.&lt;br&gt;
Survivorship and coverage gaps. Check that every season has about the right number of matches, and look for leagues or periods with missing odds.&lt;br&gt;
Ignoring drawdowns. A strategy with great ROI and a 60-unit drawdown is one you will probably abandon at the worst moment.&lt;br&gt;
Ideas to Extend This Project&lt;br&gt;
Fit a simple model (Elo ratings, Poisson goals, logistic regression) and compare its probabilities against the market's&lt;br&gt;
Test Asian handicap, totals and over/under markets, not just 1X2&lt;br&gt;
Add team form and statistics as features using the statistics endpoints&lt;br&gt;
Compare strategies by season to see if an edge decays over time&lt;br&gt;
Link this to a live system: backtest a rule, then feed it with the Odds API (note it requires an upgraded plan) so the live and historical data share the same schema&lt;br&gt;
Wrapping Up&lt;/p&gt;

&lt;p&gt;In one script we built a real backtesting pipeline:&lt;/p&gt;

&lt;p&gt;Fetched multi-season results and closing odds via a paginated, rate-limit-aware client&lt;br&gt;
Cleaned the data into a tidy DataFrame&lt;br&gt;
Converted odds into implied and margin-free probabilities&lt;br&gt;
Tested several strategies as simple filters&lt;br&gt;
Measured ROI, hit rate and drawdown&lt;br&gt;
Stress-tested the findings with bootstrap confidence intervals&lt;br&gt;
Validated with a time-based train/test split&lt;/p&gt;

&lt;p&gt;If you remember one thing, make it this: the goal of a backtest isn't to find a winner. It's to find out cheaply whether something deserves your trust. Most ideas won't. The few that survive an honest test are the only ones worth taking further.&lt;/p&gt;

&lt;p&gt;If you build something on top of this (a walk-forward tester, a calibration dashboard, a multi-sport comparison), share it in the comments. And if the sandbox returns a different JSON shape than the sample I used, paste it below and I'll help adapt the flatten() function.&lt;/p&gt;

&lt;p&gt;Happy testing! 📊&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
  </channel>
</rss>
