<?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>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>
    <item>
      <title>Build an Odds Movement Alert Bot in Python (Telegram &amp; Discord)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:59:12 +0000</pubDate>
      <link>https://dev.to/orbistats/build-an-odds-movement-alert-bot-in-python-telegram-discord-208f</link>
      <guid>https://dev.to/orbistats/build-an-odds-movement-alert-bot-in-python-telegram-discord-208f</guid>
      <description>&lt;p&gt;If you have ever watched a price move on a betting market and thought, "I wish I had known five minutes earlier," this tutorial is for you.&lt;/p&gt;

&lt;p&gt;Odds don't change randomly. A sharp drop on the home side can mean injury news, lineup leaks, or heavy money. Traders, analysts, and fantasy players all care about that signal. Almost none of them want to stare at a dashboard all day.&lt;/p&gt;

&lt;p&gt;So in this guide we'll build a Python bot that watches odds, detects meaningful movement, and pushes an alert to Telegram and Discord the moment it happens.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;p&gt;A polling loop that fetches odds from a REST API&lt;br&gt;
A movement detector with configurable thresholds&lt;br&gt;
Telegram and Discord notifications&lt;br&gt;
Duplicate-alert protection and sane rate-limit handling&lt;br&gt;
A clear upgrade path to WebSockets for real-time use&lt;/p&gt;

&lt;p&gt;Let's build it.&lt;/p&gt;

&lt;p&gt;Why Odds Movement Matters&lt;/p&gt;

&lt;p&gt;Odds are a market price. Like any price, the change is often more informative than the level.&lt;/p&gt;

&lt;p&gt;Football: a 1X2 home price falling from 2.10 to 1.85 within an hour often signals confirmed lineups or sharp money.&lt;br&gt;
Tennis: a sudden drift on a favourite can mean an injury or a fitness doubt before it's public.&lt;br&gt;
Basketball and American Football: spread and total movements reflect late injury reports.&lt;br&gt;
Cricket: toss results and pitch conditions swing prices quickly.&lt;/p&gt;

&lt;p&gt;The same logic applies in every sport. A bot that watches these moves for you is simple to build, and it's a good project for learning API polling, state tracking, and notification design.&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;
A Telegram account (for a bot token) and/or a Discord server (for a webhook)&lt;br&gt;
About 30 minutes&lt;br&gt;
Choosing a data source&lt;/p&gt;

&lt;p&gt;An alert bot is only as good as its data. The painful part of odds data is that every bookmaker has its own format, so you end up writing and maintaining a different parser for each. A normalized odds feed removes that work: one schema, one parser.&lt;/p&gt;

&lt;p&gt;That's why I'm using the Orbistats Odds API. It returns pre-match and live odds (1X2, moneyline, spreads, totals, and more) in one consistent structure. It also covers 13 sports: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf, and horse racing. So the bot we build works across all of them with almost no changes.&lt;/p&gt;

&lt;p&gt;The free tier is enough for this tutorial, and I'll show how to stay inside its limits.&lt;/p&gt;

&lt;p&gt;Step 1: Get Your API Key&lt;br&gt;
Create an account on the sign-up page.&lt;br&gt;
Copy your API key from the dashboard.&lt;br&gt;
Skim the documentation and the quickstart.&lt;/p&gt;

&lt;p&gt;Authentication is a standard bearer token:&lt;/p&gt;

&lt;p&gt;http&lt;br&gt;
Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;The base URL is:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
&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;If you'd like to experiment before writing any code, the sandbox lets you fire requests and inspect the JSON directly.&lt;/p&gt;

&lt;p&gt;Step 2: Project Setup&lt;br&gt;
bash&lt;br&gt;
mkdir odds-alert-bot &amp;amp;&amp;amp; cd odds-alert-bot&lt;br&gt;
python -m venv .venv&lt;br&gt;
source .venv/bin/activate        # Windows: .venv\Scripts\activate&lt;br&gt;
pip install requests python-dotenv&lt;/p&gt;

&lt;p&gt;Create a .env file:&lt;/p&gt;

&lt;p&gt;env&lt;br&gt;
ORBISTATS_API_KEY=your_key_here&lt;/p&gt;

&lt;p&gt;TELEGRAM_BOT_TOKEN=123456:ABC-your-token&lt;br&gt;
TELEGRAM_CHAT_ID=your_chat_id&lt;/p&gt;

&lt;p&gt;DISCORD_WEBHOOK_URL=&lt;a href="https://discord.com/api/webhooks/" rel="noopener noreferrer"&gt;https://discord.com/api/webhooks/&lt;/a&gt;...&lt;/p&gt;

&lt;p&gt;SPORT=football&lt;br&gt;
MARKET=1X2&lt;br&gt;
MOVE_THRESHOLD_PCT=5&lt;br&gt;
POLL_SECONDS=600&lt;/p&gt;

&lt;p&gt;Getting the Telegram values:&lt;/p&gt;

&lt;p&gt;Message &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt;, send /newbot, and copy the token.&lt;br&gt;
Send any message to your new bot.&lt;br&gt;
Open &lt;a href="https://api.telegram.org/bot" rel="noopener noreferrer"&gt;https://api.telegram.org/bot&lt;/a&gt;/getUpdates and read chat.id from the response.&lt;/p&gt;

&lt;p&gt;Getting the Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy URL.&lt;/p&gt;

&lt;p&gt;Step 3: Fetch Odds&lt;/p&gt;

&lt;p&gt;Create bot.py. We'll start with configuration and the API client.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import os&lt;br&gt;
import time&lt;br&gt;
import logging&lt;br&gt;
import requests&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 = os.getenv("SPORT", "football")&lt;br&gt;
MARKET = os.getenv("MARKET", "1X2")&lt;br&gt;
THRESHOLD = float(os.getenv("MOVE_THRESHOLD_PCT", "5"))&lt;br&gt;
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "600"))&lt;/p&gt;

&lt;p&gt;TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")&lt;br&gt;
TG_CHAT = os.getenv("TELEGRAM_CHAT_ID")&lt;br&gt;
DISCORD_URL = os.getenv("DISCORD_WEBHOOK_URL")&lt;/p&gt;

&lt;p&gt;logging.basicConfig(&lt;br&gt;
    level=logging.INFO,&lt;br&gt;
    format="%(asctime)s %(levelname)s %(message)s",&lt;br&gt;
)&lt;br&gt;
log = logging.getLogger("odds-bot")&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;def fetch_odds(sport: str, market: str) -&amp;gt; list[dict]:&lt;br&gt;
    """Fetch current odds for one sport/market.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOTE: confirm the exact path and query params in the API Reference.
"""
url = f"{BASE_URL}/{sport}/odds"
resp = session.get(url, params={"market": market}, timeout=15)
resp.raise_for_status()
payload = resp.json()
# Some APIs wrap results in {"data": [...]}; handle both shapes.
return payload.get("data", payload) if isinstance(payload, dict) else payload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The path and parameters above follow the pattern used in the docs examples (/v1/football/...). Check the API reference for the exact odds route and field names, and adjust the one function above if they differ.&lt;/p&gt;

&lt;p&gt;Step 4: Normalize the Data&lt;/p&gt;

&lt;p&gt;Even with a normalized API, I like to add one more layer in my own code: a function that converts whatever the API returns into a small internal shape. If the response format ever changes, you only edit this one function.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def normalize(item: dict) -&amp;gt; dict | None:&lt;br&gt;
    """Turn one API item into {key, label, prices}.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected input resembles:
{
  "match_id": "match_50231",
  "home": {"name": "Manchester City"},
  "away": {"name": "Arsenal"},
  "market": "1X2",
  "odds": {"home": 1.91, "draw": 3.40, "away": 4.20}
}
"""
try:
    match_id = item["match_id"]
    home = item["home"]["name"] if isinstance(item["home"], dict) else item["home"]
    away = item["away"]["name"] if isinstance(item["away"], dict) else item["away"]
    prices = {k: float(v) for k, v in item["odds"].items()}
except (KeyError, TypeError, ValueError):
    return None

return {
    "key": f"{match_id}:{item.get('market', MARKET)}",
    "label": f"{home} vs {away}",
    "prices": prices,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Step 5: Detect Odds Movement&lt;/p&gt;

&lt;p&gt;This is the heart of the bot. We store the last seen price for every outcome and compare each new reading against it.&lt;/p&gt;

&lt;p&gt;We use percentage change rather than absolute change. A move from 1.20 to 1.30 is very different from 8.00 to 8.10, and percentages handle both fairly.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
last_seen: dict[str, dict[str, float]] = {}&lt;/p&gt;

&lt;p&gt;def pct_change(old: float, new: float) -&amp;gt; float:&lt;br&gt;
    return (new - old) / old * 100&lt;/p&gt;

&lt;p&gt;def detect_moves(match: dict) -&amp;gt; list[dict]:&lt;br&gt;
    """Compare current prices to the previous snapshot."""&lt;br&gt;
    key, prices = match["key"], match["prices"]&lt;br&gt;
    previous = last_seen.get(key)&lt;br&gt;
    moves = []&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if previous:
    for outcome, new_price in prices.items():
        old_price = previous.get(outcome)
        if old_price is None or old_price == 0:
            continue
        change = pct_change(old_price, new_price)
        if abs(change) &amp;gt;= THRESHOLD:
            moves.append({
                "outcome": outcome,
                "old": old_price,
                "new": new_price,
                "pct": change,
            })

# Always update the snapshot so the next poll compares to now.
last_seen[key] = prices
return moves
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Notice that the first poll produces no alerts. It only records a baseline. That's intentional: you can't call something a "movement" without a before and an after.&lt;/p&gt;

&lt;p&gt;Reading the direction&lt;br&gt;
Odds shortening (price falls): the market considers the outcome more likely.&lt;br&gt;
Odds drifting (price rises): the market considers it less likely.&lt;/p&gt;

&lt;p&gt;We'll show this with an arrow in the message so it reads at a glance.&lt;/p&gt;

&lt;p&gt;Step 6: Send Telegram Alerts&lt;br&gt;
python&lt;br&gt;
def format_alert(match: dict, move: dict) -&amp;gt; str:&lt;br&gt;
    arrow = "📉 shortened" if move["pct"] &amp;lt; 0 else "📈 drifted"&lt;br&gt;
    return (&lt;br&gt;
        f"⚡ Odds movement: {match['label']}\n"&lt;br&gt;
        f"Outcome: {move['outcome'].upper()}\n"&lt;br&gt;
        f"{move['old']:.2f} → {move['new']:.2f} "&lt;br&gt;
        f"({move['pct']:+.1f}%) {arrow}"&lt;br&gt;
    )&lt;/p&gt;

&lt;p&gt;def send_telegram(text: str) -&amp;gt; None:&lt;br&gt;
    if not (TG_TOKEN and TG_CHAT):&lt;br&gt;
        return&lt;br&gt;
    url = f"&lt;a href="https://api.telegram.org/bot%7BTG_TOKEN%7D/sendMessage" rel="noopener noreferrer"&gt;https://api.telegram.org/bot{TG_TOKEN}/sendMessage&lt;/a&gt;"&lt;br&gt;
    r = requests.post(url, json={"chat_id": TG_CHAT, "text": text}, timeout=10)&lt;br&gt;
    if not r.ok:&lt;br&gt;
        log.warning("Telegram failed: %s %s", r.status_code, r.text[:200])&lt;br&gt;
Step 7: Send Discord Alerts&lt;/p&gt;

&lt;p&gt;Discord webhooks are even simpler. There's no bot account and no OAuth, just a POST.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def send_discord(text: str) -&amp;gt; None:&lt;br&gt;
    if not DISCORD_URL:&lt;br&gt;
        return&lt;br&gt;
    r = requests.post(DISCORD_URL, json={"content": text}, timeout=10)&lt;br&gt;
    if not r.ok:&lt;br&gt;
        log.warning("Discord failed: %s %s", r.status_code, r.text[:200])&lt;/p&gt;

&lt;p&gt;def notify(text: str) -&amp;gt; None:&lt;br&gt;
    send_telegram(text)&lt;br&gt;
    send_discord(text)&lt;/p&gt;

&lt;p&gt;Want richer messages? Discord supports embeds, with colors, fields, and timestamps. A green embed for shortening and a red one for drifting looks great in a trading channel.&lt;/p&gt;

&lt;p&gt;Step 8: The Main Loop (With Rate-Limit Awareness)&lt;/p&gt;

&lt;p&gt;Here's the part many tutorials skip. Polling costs requests. On the free tier you get a limited number per day (150 at the time of writing, per the docs, so always double-check your plan on the pricing page).&lt;/p&gt;

&lt;p&gt;Do the math before choosing an interval:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
86,400 seconds/day ÷ 600 seconds = 144 requests/day&lt;/p&gt;

&lt;p&gt;A 10-minute interval for a single sport fits inside 150 requests per day. If you poll five sports every minute, you'll burn through the limit in minutes.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def run_once() -&amp;gt; int:&lt;br&gt;
    alerts = 0&lt;br&gt;
    for raw in fetch_odds(SPORT, MARKET):&lt;br&gt;
        match = normalize(raw)&lt;br&gt;
        if not match:&lt;br&gt;
            continue&lt;br&gt;
        for move in detect_moves(match):&lt;br&gt;
            notify(format_alert(match, move))&lt;br&gt;
            alerts += 1&lt;br&gt;
    return alerts&lt;/p&gt;

&lt;p&gt;def main() -&amp;gt; None:&lt;br&gt;
    log.info("Starting odds bot: sport=%s market=%s threshold=%s%%",&lt;br&gt;
             SPORT, MARKET, THRESHOLD)&lt;br&gt;
    backoff = 1&lt;br&gt;
    while True:&lt;br&gt;
        try:&lt;br&gt;
            sent = run_once()&lt;br&gt;
            log.info("Poll complete, alerts sent: %d", sent)&lt;br&gt;
            backoff = 1&lt;br&gt;
            time.sleep(POLL_SECONDS)&lt;br&gt;
        except requests.HTTPError as e:&lt;br&gt;
            status = e.response.status_code if e.response is not None else "?"&lt;br&gt;
            if status == 429:&lt;br&gt;
                wait = min(backoff * 60, 900)&lt;br&gt;
                log.warning("Rate limited. Sleeping %ss", wait)&lt;br&gt;
                time.sleep(wait)&lt;br&gt;
                backoff *= 2&lt;br&gt;
            else:&lt;br&gt;
                log.error("HTTP error: %s", e)&lt;br&gt;
                time.sleep(30)&lt;br&gt;
        except requests.RequestException as e:&lt;br&gt;
            log.error("Network error: %s", e)&lt;br&gt;
            time.sleep(30)&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;Run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python bot.py&lt;/p&gt;

&lt;p&gt;On the first poll you'll see a baseline. After the next one, any outcome that moved beyond your threshold fires an alert in both channels.&lt;/p&gt;

&lt;p&gt;Step 9: Avoid Alert Spam (Cooldowns)&lt;/p&gt;

&lt;p&gt;A price can wobble around your threshold and fire repeatedly. Add a cooldown so each outcome alerts at most once per window:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
COOLDOWN_SECONDS = 1800&lt;br&gt;
last_alert_at: dict[str, float] = {}&lt;/p&gt;

&lt;p&gt;def should_alert(match_key: str, outcome: str) -&amp;gt; bool:&lt;br&gt;
    k = f"{match_key}:{outcome}"&lt;br&gt;
    now = time.time()&lt;br&gt;
    if now - last_alert_at.get(k, 0) &amp;lt; COOLDOWN_SECONDS:&lt;br&gt;
        return False&lt;br&gt;
    last_alert_at[k] = now&lt;br&gt;
    return True&lt;/p&gt;

&lt;p&gt;Then in run_once:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
for move in detect_moves(match):&lt;br&gt;
    if should_alert(match["key"], move["outcome"]):&lt;br&gt;
        notify(format_alert(match, move))&lt;br&gt;
        alerts += 1&lt;br&gt;
Step 10: Make It Work for All 13 Sports&lt;/p&gt;

&lt;p&gt;Because the data is normalized, supporting more sports is mostly configuration. Loop over a list:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
SPORTS = ["football", "basketball", "tennis", "cricket"]&lt;/p&gt;

&lt;p&gt;Then call fetch_odds(sport, MARKET) for each. Keep the request math in mind. Four sports at 10 minutes means 576 calls a day, which exceeds the free tier but fits comfortably on a paid plan.&lt;/p&gt;

&lt;p&gt;Each sport has its own coverage details and quirks, so browse the sport pages for what's available:&lt;/p&gt;

&lt;p&gt;Football and Basketball for the highest-volume markets&lt;br&gt;
Cricket and Tennis for fast-moving live prices&lt;br&gt;
Esports and Horse Racing if you want niche markets with less competition&lt;/p&gt;

&lt;p&gt;One tip: different sports suit different thresholds. Tennis moves quickly, so use a higher threshold (8 to 10%). Football 1X2 is steadier, so 4 to 5% is a good start. Store a threshold per sport in a dict.&lt;/p&gt;

&lt;p&gt;Going Real-Time: From Polling to WebSockets&lt;/p&gt;

&lt;p&gt;Polling has one fundamental weakness: you only see the market every N minutes. A move that happens and reverses between two polls is invisible.&lt;/p&gt;

&lt;p&gt;For true real-time alerts, switch from request/response to a persistent connection. The WebSocket API pushes updates to you as they happen, so you stop polling and stop worrying about request quotas.&lt;/p&gt;

&lt;p&gt;The change to our bot is small. Everything after the data arrives (normalize, detect_moves, notify) stays identical. Only the source changes:&lt;/p&gt;

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

&lt;h1&gt;
  
  
  Conceptual sketch, check the WebSocket docs for the real URL and message format
&lt;/h1&gt;

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

&lt;p&gt;def on_message(ws, message):&lt;br&gt;
    data = json.loads(message)&lt;br&gt;
    match = normalize(data)&lt;br&gt;
    if match:&lt;br&gt;
        for move in detect_moves(match):&lt;br&gt;
            if should_alert(match["key"], move["outcome"]):&lt;br&gt;
                notify(format_alert(match, move))&lt;/p&gt;

&lt;p&gt;ws = websocket.WebSocketApp(&lt;br&gt;
    "wss://",&lt;br&gt;
    header={"Authorization": f"Bearer {API_KEY}"},&lt;br&gt;
    on_message=on_message,&lt;br&gt;
)&lt;br&gt;
ws.run_forever(reconnect=5)&lt;/p&gt;

&lt;p&gt;If you'd rather not hold a connection open yourself, webhooks are the third option. The provider calls your URL when something changes, so you can host a tiny Flask or FastAPI endpoint and let the events come to you.&lt;/p&gt;

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

&lt;p&gt;Method  Best for    Trade-off&lt;br&gt;
REST polling    Learning, low-volume, free tier Delayed, uses quota&lt;br&gt;
WebSocket   Live odds, trading-style alerts Needs reconnect logic&lt;br&gt;
Webhooks    Server-side event handling  Needs a public endpoint&lt;br&gt;
Add Historical Context (Optional but Powerful)&lt;/p&gt;

&lt;p&gt;Here's a trick that makes alerts far more useful: attach context. Is a 6% move big or normal for this market?&lt;/p&gt;

&lt;p&gt;Historical data lets you answer that. If you store past movement per market, you can alert only on moves that exceed the typical volatility, instead of using one fixed number. The Historical Sports Data API provides multi-season archives, including closing odds, which are ideal for backtesting your thresholds before you trust them with real decisions.&lt;/p&gt;

&lt;p&gt;If you want to go deeper, the Sports Statistics API lets you combine movement with form and team stats, so an alert reads "Home price shortened 6% and they are unbeaten in five" instead of just a number.&lt;/p&gt;

&lt;p&gt;Deploying the Bot&lt;/p&gt;

&lt;p&gt;A bot on your laptop stops when your laptop sleeps. A few easy options:&lt;/p&gt;

&lt;p&gt;A small VPS (any $5/month box) with systemd keeping the script alive&lt;br&gt;
Docker on a home server or Raspberry Pi&lt;br&gt;
A free-tier cloud VM for light usage&lt;/p&gt;

&lt;p&gt;A minimal systemd unit:&lt;/p&gt;

&lt;p&gt;ini&lt;br&gt;
[Unit]&lt;br&gt;
Description=Odds Movement Alert Bot&lt;br&gt;
After=network-online.target&lt;/p&gt;

&lt;p&gt;[Service]&lt;br&gt;
WorkingDirectory=/opt/odds-alert-bot&lt;br&gt;
ExecStart=/opt/odds-alert-bot/.venv/bin/python bot.py&lt;br&gt;
Restart=always&lt;br&gt;
RestartSec=10&lt;br&gt;
EnvironmentFile=/opt/odds-alert-bot/.env&lt;/p&gt;

&lt;p&gt;[Install]&lt;br&gt;
WantedBy=multi-user.target&lt;/p&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
sudo systemctl enable --now odds-bot&lt;br&gt;
journalctl -u odds-bot -f&lt;br&gt;
Production Checklist&lt;/p&gt;

&lt;p&gt;Before you rely on this, run through the list:&lt;/p&gt;

&lt;p&gt;Persist state. Right now last_seen lives in memory, so a restart means a fresh baseline. Use SQLite or Redis if you care about continuity.&lt;br&gt;
Never commit .env. Add it to .gitignore.&lt;br&gt;
Handle API changes. Check the changelog and the status page when something looks off.&lt;br&gt;
Log everything. When an alert doesn't fire, logs tell you why.&lt;br&gt;
Respect quotas. Track your own request count and log it daily.&lt;br&gt;
Treat alerts as information, not advice. Odds movement is a signal. It isn't a guarantee of anything.&lt;br&gt;
Ideas to Extend This Project&lt;/p&gt;

&lt;p&gt;Once the basics work, there's a lot of room to grow:&lt;/p&gt;

&lt;p&gt;Slash commands (/watch Arsenal) so users choose what to track&lt;br&gt;
Multi-bookmaker comparison, alerting when one price diverges from the rest&lt;br&gt;
Charts, rendering a mini odds-history image with matplotlib and attaching it to the alert&lt;br&gt;
Per-user thresholds stored in a database&lt;br&gt;
Steam-move detection, flagging when several outcomes move together&lt;br&gt;
A web dashboard built on top of the same detector&lt;/p&gt;

&lt;p&gt;If you want ready-made code for each endpoint, the examples page and the SDKs are worth a look, and the guides cover related topics.&lt;/p&gt;

&lt;p&gt;Full Project Structure&lt;br&gt;
text&lt;br&gt;
odds-alert-bot/&lt;br&gt;
├── .env&lt;br&gt;
├── .gitignore&lt;br&gt;
├── bot.py&lt;br&gt;
└── requirements.txt&lt;/p&gt;

&lt;p&gt;requirements.txt:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
requests&lt;br&gt;
python-dotenv&lt;br&gt;
websocket-client   # only if you add the WebSocket version&lt;br&gt;
Wrapping Up&lt;/p&gt;

&lt;p&gt;We built a complete alert pipeline in under 150 lines:&lt;/p&gt;

&lt;p&gt;Fetch odds from a normalized API&lt;br&gt;
Normalize them into your own small shape&lt;br&gt;
Detect percentage movement against the last snapshot&lt;br&gt;
Filter with thresholds and cooldowns&lt;br&gt;
Notify through Telegram and Discord&lt;/p&gt;

&lt;p&gt;The architecture is deliberately modular. Swap polling for WebSockets, add more sports, or bolt on statistics, and the core logic barely changes. That's the real advantage of building on a clean, normalized data layer: your effort goes into your product, not into parsing.&lt;/p&gt;

&lt;p&gt;If you build something with this, I'd love to hear about it in the comments. And if you hit a snag, drop your error message below and I'll help debug.&lt;/p&gt;

&lt;p&gt;Happy building! 🚀&lt;/p&gt;

&lt;p&gt;Disclaimer: This tutorial is for educational and informational purposes. Odds data is not financial or betting advice. Please follow the laws and regulations in your region.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>discord</category>
    </item>
    <item>
      <title>Build a Live Odds Comparison Dashboard with React and WebSockets in One Evening</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:55:27 +0000</pubDate>
      <link>https://dev.to/orbistats/build-a-live-odds-comparison-dashboard-with-react-and-websockets-in-one-evening-3m65</link>
      <guid>https://dev.to/orbistats/build-a-live-odds-comparison-dashboard-with-react-and-websockets-in-one-evening-3m65</guid>
      <description>&lt;p&gt;Liquid syntax error: Variable '{{% raw %}' was not properly terminated with regexp: /\}\}/&lt;/p&gt;
</description>
      <category>react</category>
      <category>websocket</category>
      <category>sportsdata</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Debug "Laggy" Live Scores: Finding Where Your Latency Is Actually Coming From</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:09:39 +0000</pubDate>
      <link>https://dev.to/orbistats/how-to-debug-laggy-live-scores-finding-where-your-latency-is-actually-coming-from-1fgb</link>
      <guid>https://dev.to/orbistats/how-to-debug-laggy-live-scores-finding-where-your-latency-is-actually-coming-from-1fgb</guid>
      <description>&lt;p&gt;Your users say the live score is "laggy". Your first instinct is probably to blame the API. Your API provider's first instinct is to blame your code. Both of you may be wrong.&lt;/p&gt;

&lt;p&gt;"Laggy" is not a measurement. It's a feeling, and that feeling can come from at least eight different places: DNS, TLS handshakes, a polling interval, a CDN cache, a blocked main thread, a backgrounded browser tab, a slow webhook queue, or the broadcast itself being 20 seconds behind the stadium (more on that last one later).&lt;/p&gt;

&lt;p&gt;This post is a practical debugging playbook. We'll instrument every hop between "something happened on the pitch" and "a pixel changed on screen", then use the numbers to find the real culprit. Code examples use the Orbistats Live Scores API, but the technique works with any provider.&lt;/p&gt;

&lt;p&gt;Note on numbers: I don't quote benchmark results in this post. Every table is a template you fill with your own measurements.&lt;/p&gt;

&lt;p&gt;Table of contents&lt;br&gt;
First, define "laggy" precisely&lt;br&gt;
Map the pipeline&lt;br&gt;
Step 1: Rule out the network layer with curl&lt;br&gt;
Step 2: Check your polling interval math&lt;br&gt;
Step 3: Add per-hop timestamps&lt;br&gt;
Step 4: Fix your clocks before trusting any number&lt;br&gt;
Step 5: Measure delivery over WebSocket&lt;br&gt;
Step 6: Catch main-thread and render lag in the browser&lt;br&gt;
Step 7: Webhook pipelines and queue delay&lt;br&gt;
The "not actually lag" cases&lt;br&gt;
A diagnostic decision tree&lt;br&gt;
Build a staleness monitor&lt;br&gt;
Final checklist and next steps&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, define "laggy" precisely&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before touching code, get a reproducible complaint. Ask for or capture:&lt;/p&gt;

&lt;p&gt;Which match, which event (goal, card, score change)?&lt;br&gt;
What time did they see it, and what did a reference source show?&lt;br&gt;
Which device, browser and network (Wi-Fi, mobile data)?&lt;br&gt;
Is the lag constant, or does it spike?&lt;/p&gt;

&lt;p&gt;"Constant 10 seconds late" and "occasionally 3 seconds late" are different bugs. Constant offset usually means a polling interval or caching layer. Spikes usually mean reconnects, garbage collection, a throttled tab or network jitter.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Map the pipeline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every live score travels through the same stages:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Real-world event&lt;br&gt;
      ↓  (A) data source / provider ingestion&lt;br&gt;
Provider system&lt;br&gt;
      ↓  (B) provider delivery: REST / WebSocket / Webhook&lt;br&gt;
Network&lt;br&gt;
      ↓  (C) your ingest server&lt;br&gt;
Your backend (parse, cache, fan-out)&lt;br&gt;
      ↓  (D) your delivery to clients&lt;br&gt;
Network again&lt;br&gt;
      ↓  (E) browser/app receives bytes&lt;br&gt;
Client JS (parse, state update)&lt;br&gt;
      ↓  (F) render&lt;br&gt;
Pixel on screen&lt;/p&gt;

&lt;p&gt;Providers control A and B. You control C through F. Your goal is to put a timestamp at every arrow so you can subtract and see which segment is fat. Orbistats positions its live feed as a sub-50ms system, and it has written about what sub-50ms actually requires, end to end. That claim concerns the provider side. Everything after it is yours.&lt;/p&gt;

&lt;p&gt;Here's the table to fill in:&lt;/p&gt;

&lt;p&gt;Segment What it covers  Your measured p50   Your measured p95&lt;br&gt;
A→B   Event → provider emits    ?   ?&lt;br&gt;
B→C   Provider → your server    ?   ?&lt;br&gt;
C→D   Your server → your fan-out    ?   ?&lt;br&gt;
D→E   Your server → client bytes    ?   ?&lt;br&gt;
E→F   Client bytes → pixel  ?   ?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 1: Rule out the network layer with curl&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Start with the cheapest test. curl can break a single request into its phases:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
cat &amp;gt; curl-format.txt &amp;lt;&amp;lt;'EOF'&lt;br&gt;
dns:        %{time_namelookup}s&lt;br&gt;
tcp:        %{time_connect}s&lt;br&gt;
tls:        %{time_appconnect}s&lt;br&gt;
ttfb:       %{time_starttransfer}s&lt;br&gt;
total:      %{time_total}s&lt;br&gt;
size:       %{size_download} bytes&lt;br&gt;
EOF&lt;/p&gt;

&lt;p&gt;curl -s -o /dev/null -w "@curl-format.txt" \&lt;br&gt;
  -H "Authorization: Bearer $ORBISTATS_KEY" \&lt;br&gt;
  &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;/p&gt;

&lt;p&gt;How to read it (the values are cumulative, so subtract neighbours):&lt;/p&gt;

&lt;p&gt;dns high → resolver problem; try a different DNS or cache lookups.&lt;br&gt;
tcp − dns → raw network distance to the server.&lt;br&gt;
tls − tcp → handshake cost. If this appears on every request, you're not reusing connections.&lt;br&gt;
ttfb − tls → server think time plus the first-byte trip.&lt;br&gt;
total − ttfb → payload download time; large for big responses.&lt;/p&gt;

&lt;p&gt;Run it 20 times, not once:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
for i in $(seq 1 20); do&lt;br&gt;
  curl -s -o /dev/null -w "%{time_starttransfer}\n" \&lt;br&gt;
    -H "Authorization: Bearer $ORBISTATS_KEY" \&lt;br&gt;
    &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;
done | sort -n&lt;/p&gt;

&lt;p&gt;The sorted list gives you a feel for median and tail. If the first request is slow and the rest are fast, that's connection setup, which you can fix with keep-alive.&lt;/p&gt;

&lt;p&gt;Fix: reuse connections&lt;/p&gt;

&lt;p&gt;In Node 20+, the built-in fetch (undici) already pools connections, but a naive script that spawns a new process per poll never benefits. If you're on an older HTTP client, enable keep-alive explicitly:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import https from "node:https";&lt;br&gt;
const agent = new https.Agent({ keepAlive: true, maxSockets: 10 });&lt;br&gt;
// pass { agent } to your requests&lt;/p&gt;

&lt;p&gt;Not sure about the exact live endpoint for a given sport? The API reference lists every route, and the Sandbox lets you try requests in the browser first.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 2: Check your polling interval math&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you poll, the single biggest source of "lag" is simply the interval. It's arithmetic, not a bug:&lt;/p&gt;

&lt;p&gt;Average added delay = interval / 2&lt;br&gt;
Worst case = interval&lt;br&gt;
Poll interval   Average staleness   Worst case&lt;br&gt;
30 s    15 s    30 s&lt;br&gt;
10 s    5 s 10 s&lt;br&gt;
5 s 2.5 s   5 s&lt;br&gt;
1 s 0.5 s   1 s&lt;/p&gt;

&lt;p&gt;Now stack the layers. This is the classic trap:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Your frontend polls your backend every 10 s&lt;br&gt;
Your backend polls the provider every 10 s&lt;br&gt;
        ↓&lt;br&gt;
Worst case staleness = 10 s + 10 s = 20 s&lt;br&gt;
Average             = 5 s + 5 s   = 10 s&lt;/p&gt;

&lt;p&gt;Two chained pollers add their staleness. If users report "about 10 seconds behind", check for exactly this setup before suspecting anything exotic.&lt;/p&gt;

&lt;p&gt;Also check request budgets. A plan with a daily request cap (see the pricing page for current limits) can't sustain a 1-second poll for long. If you silently hit a 429 and your code keeps showing the last cached score, the UI looks frozen. Log every non-200:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const res = await fetch(url, { headers });&lt;br&gt;
if (!res.ok) {&lt;br&gt;
  console.warn("poll failed", res.status, res.headers.get("retry-after"));&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 3: Add per-hop timestamps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now we instrument. The idea is to stamp the message at every hop and carry the stamps forward, so the browser can compute every segment.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// ingest.js - runs on YOUR server when data arrives from the provider&lt;br&gt;
function stampIngest(providerMsg) {&lt;br&gt;
  return {&lt;br&gt;
    ...providerMsg,&lt;br&gt;
    _t: {&lt;br&gt;
      // provider's own event time, if present in the payload (check the docs for the field name)&lt;br&gt;
      provider: providerMsg.timestamp ?? null,&lt;br&gt;
      ingest: Date.now(),      // when YOUR server received it&lt;br&gt;
    },&lt;br&gt;
  };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// gateway.js - just before sending to browsers&lt;br&gt;
function stampEmit(msg) {&lt;br&gt;
  msg._t.emit = Date.now();   // when YOUR server sent it to clients&lt;br&gt;
  return msg;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;And in the browser:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// client.js&lt;br&gt;
socket.onmessage = (e) =&amp;gt; {&lt;br&gt;
  const msg = JSON.parse(e.data);&lt;br&gt;
  msg._t.recv = Date.now();           // bytes arrived&lt;/p&gt;

&lt;p&gt;requestAnimationFrame(() =&amp;gt; {&lt;br&gt;
    paint(msg);&lt;br&gt;
    msg._t.paint = Date.now();        // pixel updated (approx.)&lt;br&gt;
    report(msg._t);&lt;br&gt;
  });&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;function report(t) {&lt;br&gt;
  const seg = {&lt;br&gt;
    provider_to_ingest: t.provider ? t.ingest - t.provider : null,&lt;br&gt;
    ingest_to_emit:     t.emit - t.ingest,&lt;br&gt;
    emit_to_recv:       t.recv - t.emit,   // network + client clock skew!&lt;br&gt;
    recv_to_paint:      t.paint - t.recv,&lt;br&gt;
    total:              t.paint - (t.provider ?? t.ingest),&lt;br&gt;
  };&lt;br&gt;
  navigator.sendBeacon("/metrics/latency", JSON.stringify(seg));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Ship these segments to whatever metrics store you use (even a simple log line works) and aggregate p50 / p95 / p99 per segment. The fattest segment is where you look next.&lt;/p&gt;

&lt;p&gt;Don't average. If p50 is fine and p99 is terrible, users remember the p99.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 4: Fix your clocks before trusting any number&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the step that makes or breaks the whole exercise. emit_to_recv compares a server clock with a browser clock. If those disagree by 400 ms, your "network latency" is off by 400 ms, and could even go negative.&lt;/p&gt;

&lt;p&gt;Estimate the client's offset against your server with an NTP-style handshake. Use your own endpoint that returns milliseconds (the HTTP Date header only has one-second resolution, which is too coarse):&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// server: GET /time -&amp;gt; { now: Date.now() }&lt;br&gt;
app.get("/time", (_req, res) =&amp;gt; res.json({ now: Date.now() }));&lt;br&gt;
js&lt;br&gt;
// client&lt;br&gt;
async function estimateOffset(samples = 8) {&lt;br&gt;
  const results = [];&lt;br&gt;
  for (let i = 0; i &amp;lt; samples; i++) {&lt;br&gt;
    const t0 = Date.now();&lt;br&gt;
    const { now: serverNow } = await (await fetch("/time", { cache: "no-store" })).json();&lt;br&gt;
    const t1 = Date.now();&lt;br&gt;
    const rtt = t1 - t0;&lt;br&gt;
    const offset = serverNow - (t0 + rtt / 2); // assumes symmetric path&lt;br&gt;
    results.push({ rtt, offset });&lt;br&gt;
  }&lt;br&gt;
  // Trust the sample with the smallest RTT: least queueing noise.&lt;br&gt;
  results.sort((a, b) =&amp;gt; a.rtt - b.rtt);&lt;br&gt;
  return results[0].offset;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const offset = await estimateOffset();&lt;br&gt;
// later: serverTimeNow ≈ Date.now() + offset&lt;/p&gt;

&lt;p&gt;Then correct your measurements: emit_to_recv = (recv + offset) - emit.&lt;/p&gt;

&lt;p&gt;On your servers, make sure NTP/chrony is running. Two machines with drifting clocks will produce phantom latency between your ingest and gateway nodes too.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 5: Measure delivery over WebSocket&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already stream, "lag" usually has a different set of suspects than polling. Check these in order:&lt;/p&gt;

&lt;p&gt;(a) Is the socket actually alive? A half-open TCP connection looks connected but delivers nothing, for minutes. Use heartbeats and a "last message seen" watchdog:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
let lastMsgAt = Date.now();&lt;/p&gt;

&lt;p&gt;ws.on("message", () =&amp;gt; { lastMsgAt = Date.now(); });&lt;/p&gt;

&lt;p&gt;setInterval(() =&amp;gt; {&lt;br&gt;
  if (Date.now() - lastMsgAt &amp;gt; 30_000) {&lt;br&gt;
    console.warn("socket silent for 30s, forcing reconnect");&lt;br&gt;
    ws.terminate();   // triggers your reconnect logic&lt;br&gt;
  }&lt;br&gt;
}, 5_000);&lt;/p&gt;

&lt;p&gt;During live play on a busy match, 30 seconds of silence is already suspicious. Tune the threshold to your sport: a golf round has long quiet stretches, while a basketball game rarely does.&lt;/p&gt;

&lt;p&gt;(b) Are you applying backpressure correctly? If your handler does slow work (database writes, heavy JSON) inside the message callback, messages queue up behind it and every later update looks "late". Keep the handler tiny:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
ws.on("message", (raw) =&amp;gt; {&lt;br&gt;
  const msg = JSON.parse(raw);&lt;br&gt;
  latestState.set(msg.match_id, msg);   // O(1) state write&lt;br&gt;
  scheduleBroadcast();                   // defer everything else&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;(c) Did you miss updates after a reconnect? After any reconnect, fetch a REST snapshot, then resume the stream. Otherwise the score stays wrong until the next change arrives. Connection and subscribe details are on the WebSocket API page.&lt;/p&gt;

&lt;p&gt;(d) Are you flooding the client? Ten updates in one frame should paint once, not ten times:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const pending = new Map();&lt;br&gt;
let raf = 0;&lt;/p&gt;

&lt;p&gt;function enqueue(msg) {&lt;br&gt;
  pending.set(msg.match_id, msg);      // keep only the latest per match&lt;br&gt;
  if (!raf) {&lt;br&gt;
    raf = requestAnimationFrame(() =&amp;gt; {&lt;br&gt;
      raf = 0;&lt;br&gt;
      for (const m of pending.values()) paint(m);&lt;br&gt;
      pending.clear();&lt;br&gt;
    });&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 6: Catch main-thread and render lag in the browser&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sometimes the data arrives instantly and the UI still feels slow. That's a client problem. The browser can tell you directly.&lt;/p&gt;

&lt;p&gt;Find long tasks (anything blocking the main thread for 50 ms or more):&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
new PerformanceObserver((list) =&amp;gt; {&lt;br&gt;
  for (const e of list.getEntries()) {&lt;br&gt;
    console.warn(&lt;code&gt;long task: ${e.duration.toFixed(0)}ms&lt;/code&gt;, e);&lt;br&gt;
  }&lt;br&gt;
}).observe({ entryTypes: ["longtask"] });&lt;/p&gt;

&lt;p&gt;If a long task coincides with each score update, your render path is the problem. Common culprits: re-rendering a whole match list instead of one row, huge unvirtualized lists, synchronous JSON parsing of large payloads, and expensive CSS animations.&lt;/p&gt;

&lt;p&gt;Use the Performance panel, not guesses. Record while a live update lands. The flame chart will show whether the time is spent in scripting, layout or paint.&lt;/p&gt;

&lt;p&gt;Handle hidden tabs. This one fools a lot of people. Browsers throttle background tabs: timers get delayed, requestAnimationFrame stops firing entirely, and on some browsers the throttling becomes aggressive after a few minutes. A user who switches tabs and comes back sees stale data for a moment. Fix it by listening for visibility and resyncing:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
document.addEventListener("visibilitychange", async () =&amp;gt; {&lt;br&gt;
  if (document.visibilityState === "visible") {&lt;br&gt;
    const snapshot = await fetch("/api/live-snapshot").then((r) =&amp;gt; r.json());&lt;br&gt;
    applySnapshot(snapshot);   // jump straight to current truth&lt;br&gt;
  }&lt;br&gt;
});&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Step 7: Webhook pipelines and queue delay&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Webhooks give you push-style delivery to your server, but they have their own lag sources, and none of them are visible from the provider's side.&lt;/p&gt;

&lt;p&gt;Slow acknowledgements. If your endpoint does heavy work before replying 200, the provider may time out and retry, which creates duplicates and delay.&lt;br&gt;
Queue buildup. If you push events onto a queue and your consumers fall behind during a busy match window (think a full Saturday of football fixtures), queue depth becomes latency. Monitor queue age, not just queue size.&lt;br&gt;
Cold starts. A serverless function that sleeps between goals adds a startup penalty exactly when the goal arrives.&lt;br&gt;
Retries. Out-of-order delivery after a retry can make an old score overwrite a new one.&lt;/p&gt;

&lt;p&gt;Guard against the last one by comparing event time or a sequence value before applying an update:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
function applyIfNewer(state, msg) {&lt;br&gt;
  const current = state.get(msg.match_id);&lt;br&gt;
  // 'seq' or an event timestamp: use whichever the payload actually provides&lt;br&gt;
  if (current &amp;amp;&amp;amp; current.seq &amp;gt;= msg.seq) return;   // stale, ignore&lt;br&gt;
  state.set(msg.match_id, msg);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Signature checks, retry behaviour and payload shapes are documented on the Webhooks API page. Always acknowledge first and process after.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The "not actually lag" cases&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before you spend a week optimizing, rule out these:&lt;/p&gt;

&lt;p&gt;The broadcast is behind. TV and streaming broadcasts are often many seconds behind the real event, and different services are behind by different amounts. If a user compares your app to a live TV stream, a data feed that is ahead can look like it's "spoiling" rather than lagging, and a feed compared against a faster social post can look slow. Always compare against a consistent reference, ideally the same data source on a stopwatch.&lt;/p&gt;

&lt;p&gt;Different definitions of "final". One site updates the score on the goal, another waits for VAR confirmation. Two correct systems can disagree for a minute.&lt;/p&gt;

&lt;p&gt;Cache headers. A CDN or browser cache serving a 30-second-old response looks exactly like lag. Check the response headers:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
curl -sI &lt;a href="https://your-domain.com/api/live-snapshot" rel="noopener noreferrer"&gt;https://your-domain.com/api/live-snapshot&lt;/a&gt; | grep -iE "cache-control|age|etag|cf-cache-status|x-cache"&lt;/p&gt;

&lt;p&gt;If age is large or cache-control: max-age=30 is set on a live endpoint, you've found it. For live data, use no-store, or a very short s-maxage deliberately.&lt;/p&gt;

&lt;p&gt;Region distance. A server in one continent serving users in another adds real round-trip time to every non-streamed request. Measure from where your users actually are.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A diagnostic decision tree&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When someone reports lag, walk this tree top to bottom and stop at the first match:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Is the score wrong for a long time after a change (many seconds)?&lt;br&gt;
├─ YES → Is there a cache header or CDN in front?       → fix caching&lt;br&gt;
│        Is there a poller (frontend or backend)?       → check interval math (§4)&lt;br&gt;
│        Did the socket go silent / reconnect?          → watchdog + snapshot (§7)&lt;br&gt;
│        Did a request return 429/5xx?                  → rate limits / status page&lt;br&gt;
└─ NO  → Does it update fast but "feel" janky?&lt;br&gt;
         ├─ Long tasks in the browser?                  → optimize render (§8)&lt;br&gt;
         ├─ Only after switching tabs?                  → visibilitychange resync (§8)&lt;br&gt;
         └─ Compared against TV/other site?             → reference mismatch (§10)&lt;/p&gt;

&lt;p&gt;When you suspect the provider rather than your own stack, check the Status page and the changelog first, so you can separate "an incident is ongoing" from "my code regressed".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a staleness monitor&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best defense is catching lag before users do. A staleness monitor tracks "how old is the freshest data for each live match" and alerts when it exceeds a threshold:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// monitor.js&lt;br&gt;
const lastUpdate = new Map();   // match_id -&amp;gt; ms timestamp of last data&lt;br&gt;
const LIVE = new Set();         // match_ids currently in play&lt;/p&gt;

&lt;p&gt;export function onData(matchId) {&lt;br&gt;
  lastUpdate.set(matchId, Date.now());&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;setInterval(() =&amp;gt; {&lt;br&gt;
  const now = Date.now();&lt;br&gt;
  for (const id of LIVE) {&lt;br&gt;
    const age = now - (lastUpdate.get(id) ?? 0);&lt;br&gt;
    if (age &amp;gt; 20_000) {&lt;br&gt;
      alert(&lt;code&gt;match ${id}: no update for ${Math.round(age / 1000)}s&lt;/code&gt;);&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}, 5_000);&lt;/p&gt;

&lt;p&gt;function alert(msg) {&lt;br&gt;
  console.error("[STALENESS]", msg);&lt;br&gt;
  // send to Slack / PagerDuty / your logger here&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Pair it with the per-segment metrics from section 5 and a simple dashboard with three lines per segment (p50, p95, p99). Within a week you'll know your real latency profile instead of arguing about feelings.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Final checklist and next steps
Reproduce the complaint with a specific match, event and device
curl -w breakdown: DNS, TCP, TLS, TTFB, total
Reuse connections (keep-alive)
Do the interval math, and check for chained pollers
Stamp every hop: provider, ingest, emit, receive, paint
Correct clock offset on client and sync servers with NTP
Add a "socket silent" watchdog and a REST snapshot after reconnect
Coalesce updates with requestAnimationFrame
Observe long tasks; resync on visibilitychange
Verify cache headers on every live endpoint
Alert on staleness, not only on errors
Log non-200 responses, especially 429&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a clean environment to practice on:&lt;/p&gt;

&lt;p&gt;Create a free key on the signup page.&lt;br&gt;
Fire your first request from the Quickstart, then browse the documentation for the full resource list.&lt;br&gt;
Test routes in the Sandbox before wiring them into code.&lt;br&gt;
Skipping custom UI altogether? The embeddable widgets handle rendering for you.&lt;br&gt;
Compare plans and request limits on the pricing page.&lt;/p&gt;

&lt;p&gt;Coverage spans 13 sports, so the same debugging approach applies whether you're tracking football or cricket.&lt;/p&gt;

&lt;p&gt;What was the weirdest source of "lag" you've ever tracked down? A cached CDN response, a throttled tab, a chained poller? Share it in the comments. I'm collecting war stories.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Building a Sub-100ms Live Odds Ticker: REST vs WebSocket vs Webhooks Compared</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:04:27 +0000</pubDate>
      <link>https://dev.to/orbistats/building-a-sub-100ms-live-odds-ticker-rest-vs-websocket-vs-webhooks-compared-3755</link>
      <guid>https://dev.to/orbistats/building-a-sub-100ms-live-odds-ticker-rest-vs-websocket-vs-webhooks-compared-3755</guid>
      <description>&lt;p&gt;If you've ever built a live odds screen, you know the feeling. The price on your page says 1.91, the bookmaker's site says 1.85, and a user is already screenshotting it for your support inbox.&lt;/p&gt;

&lt;p&gt;Live odds are one of the least forgiving data types you can display. A goal, a red card or a break of serve can move a line within a second. Whether your ticker feels instant or broken depends less on your UI framework and more on how the data reaches your app.&lt;/p&gt;

&lt;p&gt;In this guide we'll build a live odds ticker three ways (REST polling, WebSocket and Webhooks) and compare them honestly: latency, cost, complexity and failure modes. Along the way we'll write a reconnect-safe client you can reuse in production.&lt;/p&gt;

&lt;p&gt;For the examples I'll use the Orbistats Odds API because it exposes all three delivery methods behind one normalized schema. The patterns apply to any provider, though.&lt;/p&gt;

&lt;p&gt;Heads-up: All numbers in the latency tables below are a budget framework, not benchmark results. Run the measurement script in section 6 against your own region and plan before you quote any figure.&lt;/p&gt;

&lt;p&gt;Table of contents&lt;br&gt;
What "sub-100ms" actually means&lt;br&gt;
The normalized odds schema we'll consume&lt;br&gt;
Approach 1: REST polling&lt;br&gt;
Approach 2: WebSocket streaming&lt;br&gt;
Approach 3: Webhooks&lt;br&gt;
Measuring your real latency&lt;br&gt;
Head-to-head comparison&lt;br&gt;
The architecture I'd ship&lt;br&gt;
Production checklist&lt;br&gt;
Final thoughts and next steps&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What "sub-100ms" actually means&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Sub-100ms" is a vague claim until you say from where to where. A live odds update passes through several hops:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Bookmaker price change&lt;br&gt;
        ↓&lt;br&gt;
Provider ingestion + normalization&lt;br&gt;
        ↓&lt;br&gt;
Provider delivery (REST / WS / Webhook)&lt;br&gt;
        ↓&lt;br&gt;
Network travel to YOUR server or browser&lt;br&gt;
        ↓&lt;br&gt;
Your parsing + business logic&lt;br&gt;
        ↓&lt;br&gt;
Render on screen&lt;/p&gt;

&lt;p&gt;Providers usually quote latency for the second and third hops only. Orbistats, for example, positions its live feed as sub-50ms and has published a piece on what sub-50ms actually requires, end to end. That figure covers their side. Your network distance, TLS handshakes, JSON parsing and rendering come on top.&lt;/p&gt;

&lt;p&gt;So when we say sub-100ms, we mean a budget like this:&lt;/p&gt;

&lt;p&gt;Stage   Example budget&lt;br&gt;
Provider → your edge  ~50 ms (provider claim)&lt;br&gt;
Network RTT to your region  10–40 ms&lt;br&gt;
Parse + diff + state update 1–5 ms&lt;br&gt;
Render  8–16 ms (one frame)&lt;/p&gt;

&lt;p&gt;The point: delivery method decides whether you even can hit that budget, because polling adds an entirely separate cost called staleness.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The normalized odds schema we'll consume&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One of the hardest parts of any odds integration is that every bookmaker formats prices differently. A normalized API removes the need to maintain one parser per bookmaker. Orbistats returns markets in a consistent shape, like this 1X2 example:&lt;/p&gt;

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

&lt;p&gt;The same schema family covers moneyline, spreads, handicaps, totals, player props and futures, for both pre-match and live odds, plus line movement. Auth is a standard bearer token against &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;http&lt;br&gt;
Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;You can get a key in a minute from the signup page, and the documentation lists the full resource set (fixtures, results, standings, odds, statistics, lineups, events, teams, players, competitions, countries).&lt;/p&gt;

&lt;p&gt;Here is the tiny type we'll use everywhere below:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
// types.ts&lt;br&gt;
export interface OddsTick {&lt;br&gt;
  matchId: string;&lt;br&gt;
  market: string;           // e.g. "1X2"&lt;br&gt;
  odds: Record;&lt;br&gt;
  receivedAt: number;       // ms, set by OUR code&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Approach 1: REST polling&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Polling is where everyone starts, and for good reason: it's simple, cacheable and works everywhere.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// poll.js  (Node 20+, no dependencies)&lt;br&gt;
const BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;";&lt;br&gt;
const KEY = process.env.ORBISTATS_KEY;&lt;/p&gt;

&lt;p&gt;let last = new Map();&lt;/p&gt;

&lt;p&gt;async function pollOnce(matchId) {&lt;br&gt;
  const t0 = performance.now();&lt;br&gt;
  // Check the API reference for the exact odds route and params.&lt;br&gt;
  const res = await fetch(&lt;code&gt;${BASE}/football/odds?match_id=${matchId}&lt;/code&gt;, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt; },&lt;br&gt;
  });&lt;br&gt;
  if (!res.ok) throw new Error(&lt;code&gt;HTTP ${res.status}&lt;/code&gt;);&lt;br&gt;
  const body = await res.json();&lt;br&gt;
  const rtt = performance.now() - t0;&lt;/p&gt;

&lt;p&gt;const prev = last.get(matchId);&lt;br&gt;
  if (JSON.stringify(prev) !== JSON.stringify(body)) {&lt;br&gt;
    last.set(matchId, body);&lt;br&gt;
    console.log(&lt;code&gt;CHANGED (rtt ${rtt.toFixed(0)}ms)&lt;/code&gt;, body);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;setInterval(() =&amp;gt; pollOnce("match_50231").catch(console.error), 1000);&lt;/p&gt;

&lt;p&gt;Use the API reference for exact route names. The shape above is what matters.&lt;/p&gt;

&lt;p&gt;The hidden cost: staleness&lt;/p&gt;

&lt;p&gt;Polling latency isn't just request time. If you poll every T milliseconds, a price change lands at a random moment inside the interval:&lt;/p&gt;

&lt;p&gt;Average added delay: T / 2&lt;br&gt;
Worst case: T&lt;br&gt;
Poll interval   Avg staleness   Worst case  Requests/day (1 match, 24h)&lt;br&gt;
5 s 2.5 s   5 s 17,280&lt;br&gt;
1 s 500 ms  1 s 86,400&lt;br&gt;
250 ms  125 ms  250 ms  345,600&lt;/p&gt;

&lt;p&gt;Two things jump out. You cannot reach sub-100ms by polling without hammering the API at 10+ requests per second per match. And request volume explodes. On a free tier capped at 150 requests per day (check the pricing page for current limits), a 1-second poll burns your whole allowance in about two and a half minutes.&lt;/p&gt;

&lt;p&gt;Use REST for: fixtures, standings, historical backfills, pre-match odds and anything where seconds don't matter. For live scores specifically, see the Live Scores API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Approach 2: WebSocket streaming&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A WebSocket keeps one persistent connection open and the server pushes every change. No request overhead, no polling interval, no staleness. This is the path to sub-100ms.&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
REST:       Client → Request → Server → Response   (repeat forever)&lt;br&gt;
WebSocket:  Client ⇄ one persistent connection ⇄ stream of updates&lt;/p&gt;

&lt;p&gt;Here's a production-shaped client with heartbeats and reconnection:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// ws-client.js&lt;br&gt;
import WebSocket from "ws";&lt;/p&gt;

&lt;p&gt;const WS_URL = process.env.ORBISTATS_WS_URL; // copy from the WebSocket docs&lt;br&gt;
const KEY = process.env.ORBISTATS_KEY;&lt;/p&gt;

&lt;p&gt;let attempt = 0;&lt;br&gt;
const state = new Map(); // matchId:market -&amp;gt; latest odds&lt;/p&gt;

&lt;p&gt;function connect() {&lt;br&gt;
  const ws = new WebSocket(WS_URL, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt; },&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;let heartbeat;&lt;/p&gt;

&lt;p&gt;ws.on("open", () =&amp;gt; {&lt;br&gt;
    attempt = 0;&lt;br&gt;
    console.log("connected");&lt;br&gt;
    // Subscribe message shape: confirm in the WebSocket API docs.&lt;br&gt;
    ws.send(JSON.stringify({ action: "subscribe", channel: "odds", sport: "football" }));&lt;br&gt;
    heartbeat = setInterval(() =&amp;gt; ws.ping(), 20_000);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;ws.on("message", (raw) =&amp;gt; {&lt;br&gt;
    const receivedAt = Date.now();&lt;br&gt;
    const msg = JSON.parse(raw);&lt;br&gt;
    const key = &lt;code&gt;${msg.match_id}:${msg.market}&lt;/code&gt;;&lt;br&gt;
    state.set(key, { ...msg, receivedAt });&lt;br&gt;
    render(key, state.get(key));&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;ws.on("close", () =&amp;gt; {&lt;br&gt;
    clearInterval(heartbeat);&lt;br&gt;
    // Exponential backoff with jitter: 0.5s, 1s, 2s ... capped at 15s&lt;br&gt;
    const delay = Math.min(15_000, 500 * 2 ** attempt++) * (0.5 + Math.random() / 2);&lt;br&gt;
    console.log(&lt;code&gt;closed, retrying in ${Math.round(delay)}ms&lt;/code&gt;);&lt;br&gt;
    setTimeout(connect, delay);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;ws.on("error", (e) =&amp;gt; console.error("ws error", e.message));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function render(key, tick) {&lt;br&gt;
  console.log(key, tick.odds);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;connect();&lt;/p&gt;

&lt;p&gt;The exact endpoint and subscription format live on the WebSocket API page, so treat WS_URL and the subscribe payload above as placeholders.&lt;/p&gt;

&lt;p&gt;The gap problem (most tutorials skip this)&lt;/p&gt;

&lt;p&gt;When a socket drops, you miss updates. Reconnecting alone leaves your ticker silently wrong. The fix is a simple pattern:&lt;/p&gt;

&lt;p&gt;text&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Socket reconnects&lt;/li&gt;
&lt;li&gt;Immediately fetch a REST snapshot of current state&lt;/li&gt;
&lt;li&gt;Replace local state with the snapshot&lt;/li&gt;
&lt;li&gt;Resume applying streamed updates
js
async function healGap(matchId) {
const res = await fetch(&lt;code&gt;https://api.orbistats.com/v1/football/odds?match_id=${matchId}&lt;/code&gt;, {
headers: { Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_KEY}&lt;/code&gt; },
});
const snapshot = await res.json();
state.set(matchId, snapshot);
}&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;REST for the truth, WebSocket for the delta. This hybrid is the backbone of nearly every serious live-data system.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Approach 3: Webhooks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Webhooks flip the direction: the provider calls you when something happens.&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Goal scored → Provider detects event → POST to your URL → Your server reacts&lt;/p&gt;

&lt;p&gt;They're ideal for reactions, such as sending a push notification, settling a bet, or updating a database row, rather than for painting a pixel-perfect ticker.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// webhook-server.js&lt;br&gt;
import express from "express";&lt;br&gt;
import crypto from "node:crypto";&lt;/p&gt;

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

&lt;p&gt;// Keep the raw body: signature checks must run on the exact bytes received.&lt;br&gt;
app.use("/webhook/sports", express.raw({ type: "application/json" }));&lt;/p&gt;

&lt;p&gt;const seen = new Set(); // swap for Redis SET with TTL in production&lt;/p&gt;

&lt;p&gt;app.post("/webhook/sports", (req, res) =&amp;gt; {&lt;br&gt;
  // Header name and algorithm: confirm in the Webhooks docs.&lt;br&gt;
  const signature = req.header("x-signature") ?? "";&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;
    signature.length === expected.length &amp;amp;&amp;amp;&lt;br&gt;
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));&lt;br&gt;
  if (!ok) return res.sendStatus(401);&lt;/p&gt;

&lt;p&gt;const event = JSON.parse(req.body);&lt;/p&gt;

&lt;p&gt;// Idempotency: providers may retry, so never process the same event twice.&lt;br&gt;
  if (seen.has(event.id)) return res.sendStatus(200);&lt;br&gt;
  seen.add(event.id);&lt;/p&gt;

&lt;p&gt;res.sendStatus(200);          // ACK fast...&lt;br&gt;
  queueMicrotask(() =&amp;gt; handle(event)); // ...do the heavy work after&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;function handle(event) {&lt;br&gt;
  console.log("event:", event.type, event);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;app.listen(3000);&lt;/p&gt;

&lt;p&gt;Details of payloads, retries and signing are on the Webhooks API page. Webhooks are listed alongside historical data and widgets in the Growth plan positioning, so check the pricing page for what each tier includes.&lt;/p&gt;

&lt;p&gt;The three webhook rules:&lt;/p&gt;

&lt;p&gt;Verify the signature. Anyone can POST to a public URL.&lt;br&gt;
Be idempotent. Retries mean duplicates.&lt;br&gt;
Acknowledge fast. Return 200 immediately and process asynchronously, or the provider will time out and retry.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Measuring your real latency&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Don't trust anyone's numbers, including mine. Measure. Here's a script that records the delay between the moment an update is received and its provider timestamp (if one is included), plus a raw RTT probe:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// measure.js&lt;br&gt;
import { performance } from "node:perf_hooks";&lt;/p&gt;

&lt;p&gt;const KEY = process.env.ORBISTATS_KEY;&lt;/p&gt;

&lt;p&gt;async function rttProbe(n = 50) {&lt;br&gt;
  const samples = [];&lt;br&gt;
  for (let i = 0; i &amp;lt; n; i++) {&lt;br&gt;
    const t0 = performance.now();&lt;br&gt;
    await fetch("&lt;a href="https://api.orbistats.com/v1/" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/&lt;/a&gt;", {&lt;br&gt;
      headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt; },&lt;br&gt;
    });&lt;br&gt;
    samples.push(performance.now() - t0);&lt;br&gt;
    await new Promise((r) =&amp;gt; setTimeout(r, 200));&lt;br&gt;
  }&lt;br&gt;
  samples.sort((a, b) =&amp;gt; a - b);&lt;br&gt;
  const p = (q) =&amp;gt; samples[Math.floor(q * (samples.length - 1))].toFixed(1);&lt;br&gt;
  console.log({ p50: p(0.5), p95: p(0.95), p99: p(0.99), min: p(0), max: p(1) });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;rttProbe();&lt;/p&gt;

&lt;p&gt;For streamed updates, compute Date.now() - msg.timestamp only if your server clock is NTP-synced; otherwise clock skew will fool you. Always report p50, p95 and p99, because averages hide the tail, and the tail is what users notice.&lt;/p&gt;

&lt;p&gt;The Status page is also worth bookmarking so you can separate "my code is slow" from "an incident is ongoing".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Head-to-head comparison
REST polling    WebSocket   Webhooks
Direction   You pull    Provider pushes over open socket    Provider pushes to your URL
Typical added delay T/2 avg (interval-bound)    Near zero (network only)    Near zero (network + your server)
Sub-100ms feasible  ❌ not without extreme polling ✅ yes ⚠️ to your server yes, to browsers needs fan-out
Request cost    High, grows with matches    One connection  One POST per event
Browser-direct  ✅ ✅ (but exposes your key)  ❌ needs a public server
Failure mode    Stale data  Silent gaps on drop Duplicates and retries
Complexity  Low Medium  Medium
Best for    Fixtures, standings, history    Live odds, line movement    Alerts, settlement, DB sync&lt;/li&gt;
&lt;li&gt;The architecture I'd ship&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Never put your provider key in the browser, and don't open one upstream socket per user. Open one upstream connection and fan out:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Orbistats WebSocket ──► Your ingest service&lt;br&gt;
                              │&lt;br&gt;
                    (normalize + diff + dedupe)&lt;br&gt;
                              │&lt;br&gt;
                         Redis pub/sub&lt;br&gt;
                    ┌─────────┴─────────┐&lt;br&gt;
                    ▼                   ▼&lt;br&gt;
          WebSocket/SSE gateway     Webhook-style jobs&lt;br&gt;
                    │               (alerts, DB writes)&lt;br&gt;
                    ▼&lt;br&gt;
              Browser tickers&lt;/p&gt;

&lt;p&gt;Key decisions:&lt;/p&gt;

&lt;p&gt;Diff before broadcast. Only push a tick if the price actually changed. This cuts browser traffic dramatically on quiet markets.&lt;br&gt;
Coalesce on the client. If 10 updates arrive in one animation frame, render only the last one with requestAnimationFrame.&lt;br&gt;
Snapshot on connect. New browser tabs should receive current state immediately, then deltas.&lt;br&gt;
Flash direction, not just value. Green up and red down arrows are what make a ticker feel alive.&lt;/p&gt;

&lt;p&gt;A minimal browser-side coalescer:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const latest = new Map();&lt;br&gt;
let scheduled = false;&lt;/p&gt;

&lt;p&gt;socket.onmessage = (e) =&amp;gt; {&lt;br&gt;
  const tick = JSON.parse(e.data);&lt;br&gt;
  latest.set(&lt;code&gt;${tick.match_id}:${tick.market}&lt;/code&gt;, tick);&lt;br&gt;
  if (!scheduled) {&lt;br&gt;
    scheduled = true;&lt;br&gt;
    requestAnimationFrame(() =&amp;gt; {&lt;br&gt;
      scheduled = false;&lt;br&gt;
      for (const tick of latest.values()) paint(tick);&lt;br&gt;
      latest.clear();&lt;br&gt;
    });&lt;br&gt;
  }&lt;br&gt;
};&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Production checklist
Heartbeats (ping/pong) and dead-connection detection
Exponential backoff with jitter on reconnect
REST snapshot after every reconnect (gap healing)
Webhook signature verification and idempotency keys
Rate-limit awareness: back off on 429 instead of retrying instantly
Odds-format toggle (decimal / fractional / American) done client-side
Pin your API version (/v1/) so breaking changes never surprise you
Watch the changelog for additive fields
Alert on staleness ("no tick in N seconds for a live match"), not only on errors&lt;/li&gt;
&lt;li&gt;Final thoughts and next steps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;p&gt;Use REST for everything that isn't time-critical, and for healing gaps.&lt;br&gt;
Use WebSocket for the live ticker itself. It's the only approach that gets you into sub-100ms territory.&lt;br&gt;
Use Webhooks for side effects that must happen once per event.&lt;br&gt;
Combine all three. They aren't competitors, they're roles.&lt;/p&gt;

&lt;p&gt;If you want to try this today:&lt;/p&gt;

&lt;p&gt;Grab a free key at orbistats.com/signup.&lt;br&gt;
Fire your first request from the Sandbox or the Quickstart.&lt;br&gt;
Browse copy-paste snippets in Examples, or grab an SDK.&lt;br&gt;
Need history for backtesting your pricing models? See the Historical Sports Data API.&lt;br&gt;
Don't want to build UI at all? The drop-in widgets embed a live score or odds board with a snippet.&lt;/p&gt;

&lt;p&gt;Coverage spans 13 sports, including football, basketball, cricket and tennis. If you're building for trading teams specifically, the Sportsbooks &amp;amp; Trading page covers the use case in more depth.&lt;/p&gt;

&lt;p&gt;What's your current live-odds stack: polling, sockets, or a hybrid? Drop it in the comments. I'd love to compare p95 numbers.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>websockets</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why I Replaced Polling with Orbistats' WebSocket API (With Before/After Latency Numbers)</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:51:31 +0000</pubDate>
      <link>https://dev.to/orbistats/why-i-replaced-polling-with-orbistats-websocket-api-with-beforeafter-latency-numbers-3knn</link>
      <guid>https://dev.to/orbistats/why-i-replaced-polling-with-orbistats-websocket-api-with-beforeafter-latency-numbers-3knn</guid>
      <description>&lt;p&gt;Polling is the first thing everyone builds and the last thing anyone is proud of. Here's the migration, the code, and a way to measure your own before/after instead of trusting mine.&lt;/p&gt;

&lt;p&gt;Every live scoreboard starts the same way. You write a setInterval, hit an endpoint every few seconds, diff the response, and update the screen. It works on day one. It keeps working right up until someone watches a goal on TV and then waits several seconds for your app to catch up.&lt;/p&gt;

&lt;p&gt;I moved a live-score feature from polling to the Orbistats WebSocket API, and this post walks through exactly how: the maths of why polling feels slow, a script that measures both approaches side by side, the production-grade WebSocket client, the fan-out server for your own users, and the traps I'd avoid next time.&lt;/p&gt;

&lt;p&gt;One promise up front: I'm not going to hand you a universal "X times faster" claim. Your number depends on your interval, your region and your sport. What I will give you is the harness to get your own, plus a results table at the end to fill in.&lt;/p&gt;

&lt;p&gt;TL;DR&lt;br&gt;
Polling adds a built-in delay of up to one full interval before you can even see a new event.&lt;br&gt;
A push connection removes that delay. What's left is network time plus your own processing.&lt;br&gt;
Don't open one upstream socket per user. Keep one connection to the provider and fan out to your clients.&lt;br&gt;
The unglamorous parts (reconnects, gap-fill, dedupe, heartbeats) are most of the work.&lt;br&gt;
Keep REST for everything that isn't second-by-second.&lt;br&gt;
First, the maths of why polling feels slow&lt;/p&gt;

&lt;p&gt;Say you poll every T seconds. A new event (a goal) happens at a random moment between two polls. How long until you notice?&lt;/p&gt;

&lt;p&gt;Best case: almost zero, you polled right after it happened.&lt;br&gt;
Worst case: almost T, you polled right before it happened.&lt;br&gt;
On average: T / 2.&lt;/p&gt;

&lt;p&gt;For T = 5 s, that gives:&lt;/p&gt;

&lt;p&gt;Percentile  Added delay from polling alone&lt;br&gt;
Average ~2.5 s&lt;br&gt;
p95 ~4.75 s&lt;br&gt;
p99 ~4.95 s&lt;/p&gt;

&lt;p&gt;That's before request time, your backend, caching and rendering. And you can't buy your way out of it with a faster API, because the delay lives in your own timer.&lt;/p&gt;

&lt;p&gt;"Fine," you say, "I'll poll every second." Look at what that costs:&lt;/p&gt;

&lt;p&gt;Poll interval   Requests per day (one endpoint) Across 13 sports&lt;br&gt;
10 s    8,640   112,320&lt;br&gt;
5 s 17,280  224,640&lt;br&gt;
1 s 86,400  1,123,200&lt;/p&gt;

&lt;p&gt;Most of those requests return "nothing changed." You're paying (in quota, bandwidth and server load) to be told nothing. If you're on a free tier with a daily request cap, polling at 5 seconds can burn through it in minutes. Check the pricing page for the current limits and do that arithmetic before you pick an interval.&lt;/p&gt;

&lt;p&gt;Orbistats now covers 13 sports, which is exactly why this matters. The moment you add a second or third sport, polling cost multiplies, while a WebSocket subscription just carries more messages over the same connection.&lt;/p&gt;

&lt;p&gt;Why WebSocket fixes it&lt;/p&gt;

&lt;p&gt;A WebSocket is one long-lived connection. The provider pushes an update the moment it exists. There's no "when do I ask next" gap at all.&lt;/p&gt;

&lt;p&gt;Polling:    you ──ask──▶ server ──"nothing"──▶ you ──ask──▶ server ──"goal!"──▶ you&lt;br&gt;
                         (wasted)                              (found late)&lt;/p&gt;

&lt;p&gt;WebSocket:  you ◀──────────── goal! (pushed the instant it exists) ──────────── server&lt;/p&gt;

&lt;p&gt;The trade is that you now own a connection: reconnects, heartbeats, ordering, and duplicates. That's the real work, and most tutorials skip it. We won't.&lt;/p&gt;

&lt;p&gt;The plan&lt;br&gt;
Orbistats WebSocket  ──(1 connection)──▶  your server  ──(fan-out)──▶  browsers / apps&lt;br&gt;
        ▲                                       │&lt;br&gt;
        └──── REST snapshot on reconnect ◀──────┘&lt;br&gt;
Measure polling vs WebSocket side by side (so the numbers are yours).&lt;br&gt;
Build a resilient upstream client.&lt;br&gt;
Build a fan-out server so many users share one upstream connection.&lt;br&gt;
Handle the gap after a disconnect.&lt;br&gt;
Re-measure.&lt;br&gt;
Setup&lt;/p&gt;

&lt;p&gt;Node 20+ (for global fetch):&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
mkdir polling-to-ws &amp;amp;&amp;amp; cd polling-to-ws&lt;br&gt;
npm init -y&lt;br&gt;
npm i ws&lt;/p&gt;

&lt;p&gt;In package.json, add "type": "module".&lt;/p&gt;

&lt;p&gt;You'll need an API key. If you don't have one, create a free account. Then:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
export ORBISTATS_API_KEY="your_key"&lt;br&gt;
export REST_BASE="&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;br&gt;
export REST_PATH="/football/matches/live"&lt;br&gt;
export WS_URL="wss://REPLACE_WITH_URL_FROM_DOCS"&lt;br&gt;
export WS_SUBSCRIBE='{"REPLACE":"WITH_SUBSCRIBE_MESSAGE_FROM_DOCS"}'&lt;/p&gt;

&lt;p&gt;The WebSocket URL, subscribe message and payload shape come from the documentation and the API reference. I've kept those as placeholders on purpose. Don't copy my field names, copy yours from the docs. If it's your first call, the quickstart gets you to a working request fastest, and the sandbox is a good place to look at real payload shapes without spending your quota.&lt;/p&gt;

&lt;p&gt;Step 1: the side-by-side harness (this is where your numbers come from)&lt;/p&gt;

&lt;p&gt;The trick for a trustworthy comparison: run polling and WebSocket in the same process, on the same machine, at the same time. Record the moment each channel first sees each event. Because both timestamps come from the same clock, there's no clock-sync problem, and the difference tells you exactly how much later polling noticed.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// ab_compare.js&lt;br&gt;
import WebSocket from "ws";&lt;/p&gt;

&lt;p&gt;const KEY = process.env.ORBISTATS_API_KEY;&lt;br&gt;
const REST_URL = &lt;code&gt;${process.env.REST_BASE ?? "https://api.orbistats.com/v1"}${process.env.REST_PATH ?? "/football/matches/live"}&lt;/code&gt;;&lt;br&gt;
const WS_URL = process.env.WS_URL;&lt;br&gt;
const WS_SUBSCRIBE = process.env.WS_SUBSCRIBE;&lt;br&gt;
const POLL_MS = Number(process.env.POLL_MS ?? 5000);&lt;/p&gt;

&lt;p&gt;const seen = new Map(); // fingerprint -&amp;gt; { poll?: ms, ws?: ms }&lt;/p&gt;

&lt;p&gt;function mark(fp, channel) {&lt;br&gt;
  const now = performance.now(); // monotonic, same clock for both channels&lt;br&gt;
  const entry = seen.get(fp) ?? {};&lt;br&gt;
  if (entry[channel] === undefined) {&lt;br&gt;
    entry[channel] = now;&lt;br&gt;
    seen.set(fp, entry);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// ADAPT THESE TWO to the real payload shapes in the docs.&lt;br&gt;
// Goal: produce the SAME fingerprint string from a REST match and a WS message&lt;br&gt;
// when they describe the same score change.&lt;br&gt;
const fingerprint = (m) =&amp;gt; &lt;code&gt;${m.match_id}:${m.home?.score}-${m.away?.score}&lt;/code&gt;;&lt;br&gt;
const extractMatches = (body) =&amp;gt; (Array.isArray(body) ? body : body.data ?? []);&lt;br&gt;
const extractMatchFromWs = (msg) =&amp;gt; msg.data ?? msg; // adjust to your WS schema&lt;/p&gt;

&lt;p&gt;// --- polling side ---&lt;br&gt;
async function pollOnce() {&lt;br&gt;
  const res = await fetch(REST_URL, { headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt; } });&lt;br&gt;
  if (res.status === 429) {&lt;br&gt;
    console.error("Rate limited (429). Increase POLL_MS or stop.");&lt;br&gt;
    process.exit(1);&lt;br&gt;
  }&lt;br&gt;
  if (!res.ok) return;&lt;br&gt;
  for (const m of extractMatches(await res.json())) mark(fingerprint(m), "poll");&lt;br&gt;
}&lt;br&gt;
setInterval(() =&amp;gt; pollOnce().catch(console.error), POLL_MS);&lt;br&gt;
pollOnce().catch(console.error);&lt;/p&gt;

&lt;p&gt;// --- websocket side ---&lt;br&gt;
const ws = new WebSocket(WS_URL, { headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt; } });&lt;br&gt;
ws.on("open", () =&amp;gt; WS_SUBSCRIBE &amp;amp;&amp;amp; ws.send(WS_SUBSCRIBE));&lt;br&gt;
ws.on("message", (raw) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const m = extractMatchFromWs(JSON.parse(raw));&lt;br&gt;
    if (m?.match_id) mark(fingerprint(m), "ws");&lt;br&gt;
  } catch {}&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// --- report on Ctrl+C ---&lt;br&gt;
function pct(arr, p) {&lt;br&gt;
  if (!arr.length) return NaN;&lt;br&gt;
  const s = [...arr].sort((a, b) =&amp;gt; a - b);&lt;br&gt;
  const k = (s.length - 1) * (p / 100);&lt;br&gt;
  const lo = Math.floor(k), hi = Math.min(lo + 1, s.length - 1);&lt;br&gt;
  return s[lo] + (s[hi] - s[lo]) * (k - lo);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;process.on("SIGINT", () =&amp;gt; {&lt;br&gt;
  // positive = polling noticed AFTER the websocket did&lt;br&gt;
  const gaps = [];&lt;br&gt;
  for (const { poll, ws } of seen.values()) {&lt;br&gt;
    if (poll !== undefined &amp;amp;&amp;amp; ws !== undefined) gaps.push(poll - ws);&lt;br&gt;
  }&lt;br&gt;
  console.log(&lt;code&gt;\nevents seen by both channels: ${gaps.length}&lt;/code&gt;);&lt;br&gt;
  if (gaps.length) {&lt;br&gt;
    console.log(&lt;code&gt;polling was later by (ms):&lt;/code&gt;);&lt;br&gt;
    console.log(&lt;code&gt;p50 ${pct(gaps, 50).toFixed(0)} | p95 ${pct(gaps, 95).toFixed(0)} | p99 ${pct(gaps, 99).toFixed(0)}&lt;/code&gt;);&lt;br&gt;
  }&lt;br&gt;
  process.exit(0);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Run it during a busy period (several live matches) and let it collect for a while:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
POLL_MS=5000 node ab_compare.js&lt;/p&gt;

&lt;h1&gt;
  
  
  ...wait for a good number of score changes, then Ctrl+C
&lt;/h1&gt;

&lt;p&gt;Three honest notes on this harness:&lt;/p&gt;

&lt;p&gt;It measures the relative gap, not absolute end-to-end latency. For the absolute provider-to-you number, you'd compare against the provider's emitted timestamp (needs clock sync).&lt;br&gt;
Fingerprint matching is the fragile part. If REST and WebSocket describe the same event differently, your fingerprints won't line up and you'll see zero matches. Fix the two adapter functions first.&lt;br&gt;
Collect enough events. A handful of goals isn't a distribution. Aim for dozens, ideally across a busy evening. Cricket's ball-by-ball cadence in particular gives you lots of events quickly, so a cricket run fills the sample far faster than a low-scoring football night.&lt;br&gt;
Step 2: a WebSocket client you'd actually run in production&lt;/p&gt;

&lt;p&gt;The five-line new WebSocket(url) is fine for a demo. In production you need: reconnect with backoff, a heartbeat to catch "zombie" connections, re-subscribe after reconnect, and dedupe (reconnects and retries can replay events).&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// orbistats-stream.js&lt;br&gt;
import WebSocket from "ws";&lt;br&gt;
import { EventEmitter } from "node:events";&lt;/p&gt;

&lt;p&gt;export class OrbistatsStream extends EventEmitter {&lt;br&gt;
  constructor({ url, apiKey, subscribeMessage, heartbeatMs = 30_000, dedupeSize = 5000 }) {&lt;br&gt;
    super();&lt;br&gt;
    this.url = url;&lt;br&gt;
    this.apiKey = apiKey;&lt;br&gt;
    this.subscribeMessage = subscribeMessage;&lt;br&gt;
    this.heartbeatMs = heartbeatMs;&lt;br&gt;
    this.dedupeSize = dedupeSize;&lt;br&gt;
    this.attempt = 0;&lt;br&gt;
    this.everConnected = false;&lt;br&gt;
    this.seenIds = new Set();&lt;br&gt;
    this.closedByUs = false;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;start() {&lt;br&gt;
    this.closedByUs = false;&lt;br&gt;
    this.#connect();&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;stop() {&lt;br&gt;
    this.closedByUs = true;&lt;br&gt;
    clearInterval(this.hb);&lt;br&gt;
    this.ws?.close();&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;#connect() {&lt;br&gt;
    this.ws = new WebSocket(this.url, {&lt;br&gt;
      headers: { Authorization: &lt;code&gt;Bearer ${this.apiKey}&lt;/code&gt; },&lt;br&gt;
    });&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;this.ws.on("open", () =&amp;gt; {
  const reconnected = this.everConnected;
  this.everConnected = true;
  this.attempt = 0;
  this.alive = true;
  if (this.subscribeMessage) this.ws.send(this.subscribeMessage); // re-subscribe every time
  this.#startHeartbeat();
  this.emit("open", { reconnected });
  // If we reconnected, we may have missed events. The consumer should re-sync via REST.
  if (reconnected) this.emit("reconnected");
});

this.ws.on("pong", () =&amp;gt; { this.alive = true; });

this.ws.on("message", (raw) =&amp;gt; {
  let msg;
  try { msg = JSON.parse(raw); } catch { return; }

  // Dedupe: use whatever unique id the docs define. event_id is a placeholder.
  const id = msg.event_id;
  if (id !== undefined) {
    if (this.seenIds.has(id)) return;
    this.seenIds.add(id);
    if (this.seenIds.size &amp;gt; this.dedupeSize) {
      this.seenIds.delete(this.seenIds.values().next().value); // drop the oldest
    }
  }
  this.emit("event", msg);
});

this.ws.on("error", (err) =&amp;gt; this.emit("warn", err));

this.ws.on("close", () =&amp;gt; {
  clearInterval(this.hb);
  this.emit("closed");
  if (!this.closedByUs) this.#scheduleReconnect();
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;#startHeartbeat() {&lt;br&gt;
    clearInterval(this.hb);&lt;br&gt;
    this.hb = setInterval(() =&amp;gt; {&lt;br&gt;
      if (!this.alive) {&lt;br&gt;
        this.ws.terminate(); // no pong since last ping: connection is dead, trigger reconnect&lt;br&gt;
        return;&lt;br&gt;
      }&lt;br&gt;
      this.alive = false;&lt;br&gt;
      this.ws.ping();&lt;br&gt;
    }, this.heartbeatMs);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;#scheduleReconnect() {&lt;br&gt;
    this.attempt += 1;&lt;br&gt;
    // exponential backoff, capped, with jitter so thousands of clients don't reconnect in sync&lt;br&gt;
    const base = Math.min(1000 * 2 ** this.attempt, 30_000);&lt;br&gt;
    const delay = base / 2 + Math.random() * (base / 2);&lt;br&gt;
    setTimeout(() =&amp;gt; this.#connect(), delay);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;What each piece is protecting you from:&lt;/p&gt;

&lt;p&gt;Heartbeat + terminate(): a connection can die silently (a NAT timeout, a mobile network switch) without a close event. Without a ping/pong check, you sit there "connected" and receive nothing.&lt;br&gt;
Jittered backoff: if your provider restarts and every client reconnects in the same second, you've built a self-inflicted traffic spike.&lt;br&gt;
Re-subscribe on every open: subscriptions don't survive a reconnect unless the docs say they do.&lt;br&gt;
Dedupe: after a reconnect or a retry, the same event can arrive twice. Showing "GOAL!" twice is a bug users will screenshot.&lt;br&gt;
Step 3: close the gap with a REST snapshot&lt;/p&gt;

&lt;p&gt;Here's the part people forget. If your socket drops for 20 seconds, you missed 20 seconds of events. A WebSocket doesn't replay them for you. The fix is the combination everyone eventually lands on: WebSocket for the live deltas, REST for the full state.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// state.js&lt;br&gt;
const REST_URL = &lt;code&gt;${process.env.REST_BASE ?? "https://api.orbistats.com/v1"}${process.env.REST_PATH ?? "/football/matches/live"}&lt;/code&gt;;&lt;/p&gt;

&lt;p&gt;export const liveState = new Map(); // match_id -&amp;gt; latest match object&lt;/p&gt;

&lt;p&gt;export async function loadSnapshot() {&lt;br&gt;
  const res = await fetch(REST_URL, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_API_KEY}&lt;/code&gt; },&lt;br&gt;
  });&lt;br&gt;
  if (!res.ok) throw new Error(&lt;code&gt;snapshot failed: ${res.status}&lt;/code&gt;);&lt;br&gt;
  const body = await res.json();&lt;br&gt;
  const matches = Array.isArray(body) ? body : body.data ?? [];&lt;br&gt;
  liveState.clear();&lt;br&gt;
  for (const m of matches) liveState.set(m.match_id, m);&lt;br&gt;
  return matches;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;REST stays the right tool for this: schedules, standings and full match state are what the Sports Data API and the Live Scores API are for. The socket just keeps that picture current between snapshots.&lt;/p&gt;

&lt;p&gt;Step 4: fan out to your own users (one upstream, many downstream)&lt;/p&gt;

&lt;p&gt;The most expensive mistake in this migration: letting every browser open its own connection to the provider. That multiplies your connection count, leaks your API key to the client, and gets you rate-limited. Instead, your server holds one upstream connection and broadcasts.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// server.js&lt;br&gt;
import { WebSocketServer } from "ws";&lt;br&gt;
import { OrbistatsStream } from "./orbistats-stream.js";&lt;br&gt;
import { liveState, loadSnapshot } from "./state.js";&lt;/p&gt;

&lt;p&gt;const stream = new OrbistatsStream({&lt;br&gt;
  url: process.env.WS_URL,&lt;br&gt;
  apiKey: process.env.ORBISTATS_API_KEY,&lt;br&gt;
  subscribeMessage: process.env.WS_SUBSCRIBE,&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;const wss = new WebSocketServer({ port: 8080 });&lt;/p&gt;

&lt;p&gt;function broadcast(payload) {&lt;br&gt;
  const data = JSON.stringify(payload);&lt;br&gt;
  for (const client of wss.clients) {&lt;br&gt;
    if (client.readyState !== 1) continue;&lt;br&gt;
    // Back-pressure guard: skip clients that can't keep up instead of buffering forever.&lt;br&gt;
    if (client.bufferedAmount &amp;gt; 1_000_000) continue;&lt;br&gt;
    client.send(data);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// New browser connects: send the current state first, then deltas follow.&lt;br&gt;
wss.on("connection", (client) =&amp;gt; {&lt;br&gt;
  client.send(JSON.stringify({ type: "snapshot", matches: [...liveState.values()] }));&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;stream.on("event", (msg) =&amp;gt; {&lt;br&gt;
  const m = msg.data ?? msg;            // adapt to the real schema&lt;br&gt;
  if (m?.match_id) liveState.set(m.match_id, m);&lt;br&gt;
  broadcast({ type: "update", match: m });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;stream.on("reconnected", async () =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const matches = await loadSnapshot(); // fill the gap we missed while disconnected&lt;br&gt;
    broadcast({ type: "snapshot", matches });&lt;br&gt;
  } catch (e) {&lt;br&gt;
    console.error("gap-fill failed", e);&lt;br&gt;
  }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;stream.on("warn", (e) =&amp;gt; console.warn("upstream warning:", e.message));&lt;/p&gt;

&lt;p&gt;await loadSnapshot();&lt;br&gt;
stream.start();&lt;br&gt;
console.log("fan-out server on ws://localhost:8080");&lt;/p&gt;

&lt;p&gt;And a deliberately tiny browser client:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// client.js (browser)&lt;br&gt;
let socket;&lt;br&gt;
let retry = 0;&lt;br&gt;
const state = new Map();&lt;/p&gt;

&lt;p&gt;function connect() {&lt;br&gt;
  socket = new WebSocket("wss://your-domain.example/live");&lt;br&gt;
  socket.onopen = () =&amp;gt; { retry = 0; };&lt;br&gt;
  socket.onmessage = (e) =&amp;gt; {&lt;br&gt;
    const msg = JSON.parse(e.data);&lt;br&gt;
    if (msg.type === "snapshot") {&lt;br&gt;
      state.clear();&lt;br&gt;
      msg.matches.forEach((m) =&amp;gt; state.set(m.match_id, m));&lt;br&gt;
    } else if (msg.type === "update") {&lt;br&gt;
      state.set(msg.match.match_id, msg.match);&lt;br&gt;
    }&lt;br&gt;
    render(state); // your UI&lt;br&gt;
  };&lt;br&gt;
  socket.onclose = () =&amp;gt; setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));&lt;br&gt;
}&lt;br&gt;
connect();&lt;/p&gt;

&lt;p&gt;The snapshot-then-deltas pattern means a brand-new visitor, or a returning one after a drop, always starts from a correct picture. No flicker, no "blank scoreboard until the next goal."&lt;/p&gt;

&lt;p&gt;Step 5: re-measure and fill in your numbers&lt;/p&gt;

&lt;p&gt;Run ab_compare.js again with your final settings (and any polling interval you want to compare against), then drop your own results here:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Polling (5 s)   WebSocket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Added delay, p50    ~2.5 s (theory) / [FILL: measured]  [FILL: measured]&lt;br&gt;
Added delay, p95    ~4.75 s (theory) / [FILL: measured] [FILL: measured]&lt;br&gt;
Added delay, p99    ~4.95 s (theory) / [FILL: measured] [FILL: measured]&lt;br&gt;
Requests/day, one endpoint  17,280  0 repeated requests&lt;br&gt;
Upstream connections    n/a 1&lt;br&gt;
Wasted "nothing changed" calls  most of them    none&lt;/p&gt;

&lt;p&gt;Read it the way you'd read any benchmark: look at p95 and p99, not the average, run it on a busy night as well as a quiet one, and be suspicious of any single run.&lt;/p&gt;

&lt;p&gt;Things that bit me (so they don't bite you)&lt;/p&gt;

&lt;p&gt;Clock skew produces negative numbers. If you ever compare against a provider timestamp and get negative latencies, your system clock is off. Fix NTP, don't take absolute values.&lt;/p&gt;

&lt;p&gt;Don't merge slow and fast data in one payload. If you wait for odds and scores before showing either, you inherit the slower one's delay. Show scores the moment they arrive. The Odds API is a separate stream with a different update rhythm for exactly this reason.&lt;/p&gt;

&lt;p&gt;Quiet isn't broken. A live match can go minutes with no events. Your heartbeat should be at the protocol level (ping/pong), not "I haven't received data for 30 seconds, so reconnect." Otherwise you'll churn connections during a goalless half.&lt;/p&gt;

&lt;p&gt;Sports differ. A basketball game produces a steady flood of score updates, so fan-out and back-pressure matter more. Football is sparse and bursty. Test per sport instead of assuming one profile fits all 13.&lt;/p&gt;

&lt;p&gt;Cache TTLs between socket and screen. The fastest push connection in the world doesn't help if a CDN or a cache layer serves a "fresh" value that's 10 seconds old.&lt;/p&gt;

&lt;p&gt;Log your reconnect rate. A stream that's fast but drops every few minutes isn't fast. Alert on reconnects per hour.&lt;/p&gt;

&lt;p&gt;When you should NOT replace polling&lt;/p&gt;

&lt;p&gt;Polling isn't evil. It's the right call when:&lt;/p&gt;

&lt;p&gt;the data changes slowly (standings, fixtures, historical results)&lt;br&gt;
you only need a refresh every minute or so&lt;br&gt;
you're serving a server-rendered page that's cached anyway&lt;br&gt;
you want the simplest possible thing for a small side project&lt;/p&gt;

&lt;p&gt;And if what you really need is "my backend should react when X happens" rather than "my UI should update instantly," look at webhooks. They push to a URL you own, with no open connection to manage. A common healthy setup is WebSocket for the live UI, webhooks for backend actions, and REST for everything else.&lt;/p&gt;

&lt;p&gt;Migration checklist&lt;br&gt;
 Measure first: run the A/B harness against your current polling setup&lt;br&gt;
 Get the WebSocket URL, subscribe message and schema from the docs&lt;br&gt;
 Build the upstream client with heartbeat, backoff, re-subscribe and dedupe&lt;br&gt;
 Keep a REST snapshot for first load and for gap-fill after reconnects&lt;br&gt;
 Put one upstream connection behind a fan-out server&lt;br&gt;
 Never expose your API key to the browser&lt;br&gt;
 Add back-pressure handling for slow clients&lt;br&gt;
 Alert on reconnect rate and message gaps&lt;br&gt;
 Keep polling as a fallback for the rare case the socket is down for long&lt;br&gt;
 Re-measure and fill in your own before/after table&lt;br&gt;
Wrapping up&lt;/p&gt;

&lt;p&gt;Replacing polling with a WebSocket isn't hard in theory and it's fiddly in practice. The win is real and easy to explain: you stop waiting for your own timer. But the part that makes it production-worthy is everything around the happy path: reconnects, gap-fill, dedupe and fan-out.&lt;/p&gt;

&lt;p&gt;If you want to try it, start at the Orbistats homepage to see the current sport coverage, and use the free key and sandbox to run the harness above with your own data. Then post your before/after numbers in the comments. I'd genuinely like to see how they compare across different sports.&lt;/p&gt;

</description>
      <category>websocket</category>
      <category>api</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Measuring Real API Latency: A Benchmark Script for REST, WebSocket and Webhooks</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:47:50 +0000</pubDate>
      <link>https://dev.to/orbistats/measuring-real-api-latency-a-benchmark-script-for-rest-websocket-and-webhooks-5856</link>
      <guid>https://dev.to/orbistats/measuring-real-api-latency-a-benchmark-script-for-rest-websocket-and-webhooks-5856</guid>
      <description>&lt;p&gt;"Our API is fast" is a feeling. A p99 in a JSON file is a fact. Let's build the thing that gives you the fact.&lt;/p&gt;

&lt;p&gt;Every sports data API says it's fast. Orbistats lists a sub-50ms live feed on its homepage, and plenty of other providers make similar claims. The trouble is that a latency number depends on what you measure and how, and a vendor's number is almost never the number your users experience.&lt;/p&gt;

&lt;p&gt;So instead of trusting anyone's page, we're going to build a small benchmark toolkit that measures three different delivery methods the same way:&lt;/p&gt;

&lt;p&gt;REST: you ask, the server answers&lt;br&gt;
WebSocket: the server pushes over a persistent connection&lt;br&gt;
Webhooks: the server calls your endpoint&lt;/p&gt;

&lt;p&gt;By the end you'll have a repo you can run in about five minutes, plus a way to read the results without fooling yourself. It's written for sports data feeds, but the method works for any streaming or request-based API.&lt;/p&gt;

&lt;p&gt;What we're actually measuring&lt;/p&gt;

&lt;p&gt;Before any code, get the vocabulary straight, because mixing these up is the most common benchmarking mistake.&lt;/p&gt;

&lt;p&gt;Request latency (REST): how long one request takes, from sending it to receiving the full response. You can measure this with a single clock on your machine, so there's no clock-sync problem.&lt;br&gt;
Delivery latency (WebSocket, webhooks): how long after the provider published an event it reached you. This needs the provider's timestamp compared with your clock, so clock accuracy matters.&lt;br&gt;
Staleness (REST polling): even a fast request can return old data. If your polling interval is 5 seconds, a new event waits on average 2.5 seconds before you even ask.&lt;/p&gt;

&lt;p&gt;If you want the longer background on why a single "sub-50ms" figure doesn't tell the whole story, the piece on what sub-50ms actually requires end to end is a good companion read. Here we focus on building the measuring tape.&lt;/p&gt;

&lt;p&gt;Project setup&lt;br&gt;
latency-bench/&lt;br&gt;
├── config.py&lt;br&gt;
├── stats.py&lt;br&gt;
├── rest_bench.py&lt;br&gt;
├── ws_bench.py&lt;br&gt;
├── webhook_bench.py&lt;br&gt;
├── requirements.txt&lt;br&gt;
└── results/&lt;/p&gt;

&lt;p&gt;requirements.txt:&lt;/p&gt;

&lt;p&gt;httpx&amp;gt;=0.27&lt;br&gt;
websockets&amp;gt;=13&lt;br&gt;
fastapi&amp;gt;=0.110&lt;br&gt;
uvicorn&amp;gt;=0.29&lt;br&gt;
python-dateutil&amp;gt;=2.9&lt;/p&gt;

&lt;p&gt;Install it:&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 -r requirements.txt&lt;br&gt;
mkdir results&lt;/p&gt;

&lt;p&gt;You'll need an API key. If you don't have one, sign up for a free key. Then export your settings:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
export ORBISTATS_API_KEY="your_key_here"&lt;br&gt;
export REST_BASE="&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;"&lt;br&gt;
export REST_PATH="/football/matches/live"&lt;br&gt;
export WS_URL="wss://REPLACE_WITH_URL_FROM_DOCS"&lt;br&gt;
export EMITTED_FIELD="emitted_at"&lt;/p&gt;

&lt;p&gt;Two of those are placeholders on purpose. The WebSocket URL and the name of the "published at" timestamp field come from the documentation and the API reference. Don't guess them. Check what your plan actually exposes, and set EMITTED_FIELD to whatever the docs call the provider-side timestamp.&lt;/p&gt;

&lt;p&gt;Step 1: shared config and stats helpers&lt;/p&gt;

&lt;p&gt;config.py just reads environment variables so nothing is hard-coded:&lt;/p&gt;

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

&lt;h1&gt;
  
  
  config.py
&lt;/h1&gt;

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

&lt;p&gt;API_KEY = os.environ.get("ORBISTATS_API_KEY", "")&lt;br&gt;
REST_BASE = os.environ.get("REST_BASE", "&lt;a href="https://api.orbistats.com/v1%22" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1"&lt;/a&gt;)&lt;br&gt;
REST_PATH = os.environ.get("REST_PATH", "/football/matches/live")&lt;br&gt;
WS_URL = os.environ.get("WS_URL", "")&lt;br&gt;
WS_SUBSCRIBE = os.environ.get("WS_SUBSCRIBE", "")  # optional JSON string&lt;br&gt;
EMITTED_FIELD = os.environ.get("EMITTED_FIELD", "emitted_at")&lt;/p&gt;

&lt;p&gt;AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}&lt;/p&gt;

&lt;p&gt;stats.py does the part people usually get wrong: percentiles. Averages hide slow outliers, and slow outliers are the ones users remember.&lt;/p&gt;

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

&lt;h1&gt;
  
  
  stats.py
&lt;/h1&gt;

&lt;p&gt;import json&lt;br&gt;
import statistics&lt;br&gt;
import time&lt;br&gt;
from datetime import datetime, timezone&lt;br&gt;
from pathlib import Path&lt;/p&gt;

&lt;p&gt;from dateutil import parser as dtparser&lt;/p&gt;

&lt;p&gt;def percentile(values, p):&lt;br&gt;
    """Linear-interpolated percentile. p in [0, 100]."""&lt;br&gt;
    if not values:&lt;br&gt;
        return float("nan")&lt;br&gt;
    s = sorted(values)&lt;br&gt;
    k = (len(s) - 1) * (p / 100)&lt;br&gt;
    lo = int(k)&lt;br&gt;
    hi = min(lo + 1, len(s) - 1)&lt;br&gt;
    return s[lo] + (s[hi] - s[lo]) * (k - lo)&lt;/p&gt;

&lt;p&gt;def summarize(name, values_ms):&lt;br&gt;
    return {&lt;br&gt;
        "name": name,&lt;br&gt;
        "n": len(values_ms),&lt;br&gt;
        "min_ms": round(min(values_ms), 2) if values_ms else None,&lt;br&gt;
        "p50_ms": round(percentile(values_ms, 50), 2),&lt;br&gt;
        "p95_ms": round(percentile(values_ms, 95), 2),&lt;br&gt;
        "p99_ms": round(percentile(values_ms, 99), 2),&lt;br&gt;
        "max_ms": round(max(values_ms), 2) if values_ms else None,&lt;br&gt;
        "mean_ms": round(statistics.fmean(values_ms), 2) if values_ms else None,&lt;br&gt;
    }&lt;/p&gt;

&lt;p&gt;def print_summary(s):&lt;br&gt;
    print(f"\n== {s['name']} (n={s['n']}) ==")&lt;br&gt;
    for key in ("min_ms", "p50_ms", "p95_ms", "p99_ms", "max_ms", "mean_ms"):&lt;br&gt;
        print(f"  {key:8s} {s[key]}")&lt;br&gt;
    if s["n"] &amp;lt; 100:&lt;br&gt;
        print("  note: fewer than 100 samples, treat p99 as a rough hint only")&lt;/p&gt;

&lt;p&gt;def save(summary, raw_ms, outdir="results"):&lt;br&gt;
    Path(outdir).mkdir(exist_ok=True)&lt;br&gt;
    stamp = int(time.time())&lt;br&gt;
    path = Path(outdir) / f"{summary['name']}_{stamp}.json"&lt;br&gt;
    path.write_text(json.dumps({"summary": summary, "raw_ms": raw_ms}, indent=2))&lt;br&gt;
    print(f"  saved -&amp;gt; {path}")&lt;/p&gt;

&lt;p&gt;def parse_ts_ms(value):&lt;br&gt;
    """Accept epoch seconds, epoch ms, or ISO-8601 strings. Return epoch ms."""&lt;br&gt;
    if value is None:&lt;br&gt;
        return None&lt;br&gt;
    if isinstance(value, (int, float)):&lt;br&gt;
        # Heuristic: values below 1e12 are seconds, otherwise milliseconds.&lt;br&gt;
        return value * 1000 if value &amp;lt; 1e12 else float(value)&lt;br&gt;
    try:&lt;br&gt;
        dt = dtparser.isoparse(str(value))&lt;br&gt;
        if dt.tzinfo is None:&lt;br&gt;
            dt = dt.replace(tzinfo=timezone.utc)&lt;br&gt;
        return dt.timestamp() * 1000&lt;br&gt;
    except (ValueError, TypeError):&lt;br&gt;
        return None&lt;/p&gt;

&lt;p&gt;Notice parse_ts_ms accepts three timestamp formats. Providers differ, and a silent unit mix-up (seconds read as milliseconds) will give you absurd numbers that look plausible for about five minutes.&lt;/p&gt;

&lt;p&gt;Step 2: benchmark REST&lt;/p&gt;

&lt;p&gt;REST is the simplest, so start here. We time each request with time.perf_counter(), which is a monotonic clock built for measuring durations and isn't affected by system clock changes.&lt;/p&gt;

&lt;p&gt;Two things matter for fairness. First, cold vs warm: the first request pays for DNS, TCP and TLS setup, later requests on the same connection don't. Real apps usually reuse connections, but your first page load doesn't, so measure both. Second, rate limits: free plans have a daily request cap (at the time of writing the free tier is listed at 150 requests a day, but check the pricing page for current limits), so keep your sample sizes small and don't run this in a loop all afternoon.&lt;/p&gt;

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

&lt;h1&gt;
  
  
  rest_bench.py
&lt;/h1&gt;

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

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

&lt;p&gt;from config import AUTH_HEADERS, REST_BASE, REST_PATH, API_KEY&lt;br&gt;
from stats import summarize, print_summary, save&lt;/p&gt;

&lt;p&gt;def timed_get(client, url):&lt;br&gt;
    start = time.perf_counter()&lt;br&gt;
    resp = client.get(url, headers=AUTH_HEADERS)&lt;br&gt;
    elapsed_ms = (time.perf_counter() - start) * 1000&lt;br&gt;
    return resp, elapsed_ms&lt;/p&gt;

&lt;p&gt;def main():&lt;br&gt;
    ap = argparse.ArgumentParser()&lt;br&gt;
    ap.add_argument("--warm", type=int, default=30, help="warm requests (reused connection)")&lt;br&gt;
    ap.add_argument("--cold", type=int, default=5, help="cold requests (new connection each)")&lt;br&gt;
    ap.add_argument("--gap", type=float, default=1.0, help="seconds between requests")&lt;br&gt;
    args = ap.parse_args()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if not API_KEY:
    raise SystemExit("Set ORBISTATS_API_KEY first.")

url = f"{REST_BASE}{REST_PATH}"
cold, warm, errors = [], [], 0

# Cold: brand new client (new TCP + TLS handshake) every time.
for _ in range(args.cold):
    with httpx.Client(timeout=10) as c:
        resp, ms = timed_get(c, url)
    if resp.status_code == 200:
        cold.append(ms)
    else:
        errors += 1
        if resp.status_code == 429:
            raise SystemExit("Rate limited (429). Stop and check your plan limits.")
    time.sleep(args.gap)

# Warm: one client, connection reused.
with httpx.Client(timeout=10) as c:
    timed_get(c, url)  # throwaway request to open the connection
    for _ in range(args.warm):
        resp, ms = timed_get(c, url)
        if resp.status_code == 200:
            warm.append(ms)
        else:
            errors += 1
            if resp.status_code == 429:
                raise SystemExit("Rate limited (429). Stop and check your plan limits.")
        time.sleep(args.gap)

for name, data in (("rest_cold", cold), ("rest_warm", warm)):
    if data:
        s = summarize(name, data)
        print_summary(s)
        save(s, data)
print(f"\nnon-200 responses: {errors}")
&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()&lt;/p&gt;

&lt;p&gt;Run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python rest_bench.py --warm 30 --cold 5&lt;/p&gt;

&lt;p&gt;What to look for: cold should be noticeably slower than warm. If it isn't, your location is very close to the server or something is caching connections for you. If warm is slow and very jittery, the problem is probably your network, not the API.&lt;/p&gt;

&lt;p&gt;One more REST caveat: this measures request latency, not freshness. If your product shows live scores via polling, the number that matters is how long an event sits before your next poll picks it up. That's interval / 2 on average and interval at worst. Fast requests won't fix a slow polling loop. REST is the right tool for the non-live side of things, like the fixtures, results and standings that the Sports Data API serves, and a poor fit for second-by-second scoreboards.&lt;/p&gt;

&lt;p&gt;Step 3: benchmark WebSocket&lt;/p&gt;

&lt;p&gt;WebSocket is where things get interesting, because now we're measuring delivery latency: the gap between the provider publishing an event and your process receiving it. That means comparing the provider's timestamp with your own clock.&lt;/p&gt;

&lt;p&gt;This script records four things:&lt;/p&gt;

&lt;p&gt;Connect time, meaning how long the handshake takes&lt;br&gt;
Delivery latency for each message, as received - emitted&lt;br&gt;
Inter-arrival gaps, which reveal bursts and stalls&lt;br&gt;
Reconnects, because a fast stream that drops every ten minutes isn't fast&lt;br&gt;
python&lt;/p&gt;

&lt;h1&gt;
  
  
  ws_bench.py
&lt;/h1&gt;

&lt;p&gt;import argparse&lt;br&gt;
import asyncio&lt;br&gt;
import json&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;import websockets&lt;/p&gt;

&lt;p&gt;from config import AUTH_HEADERS, WS_URL, WS_SUBSCRIBE, EMITTED_FIELD&lt;br&gt;
from stats import summarize, print_summary, save, parse_ts_ms&lt;/p&gt;

&lt;p&gt;async def run(target_samples, max_seconds):&lt;br&gt;
    deltas, gaps = [], []&lt;br&gt;
    connect_ms_list = []&lt;br&gt;
    reconnects = 0&lt;br&gt;
    skipped = 0&lt;br&gt;
    last_arrival = None&lt;br&gt;
    deadline = time.monotonic() + max_seconds&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;while len(deltas) &amp;lt; target_samples and time.monotonic() &amp;lt; deadline:
    try:
        t0 = time.perf_counter()
        # websockets&amp;gt;=14 uses additional_headers; older versions use extra_headers.
        async with websockets.connect(
            WS_URL, additional_headers=AUTH_HEADERS, ping_interval=20
        ) as ws:
            connect_ms_list.append((time.perf_counter() - t0) * 1000)

            if WS_SUBSCRIBE:
                await ws.send(WS_SUBSCRIBE)  # subscription message from the docs

            while len(deltas) &amp;lt; target_samples and time.monotonic() &amp;lt; deadline:
                remaining = max(0.1, deadline - time.monotonic())
                raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
                received_ms = time.time() * 1000
                now = time.perf_counter()

                if last_arrival is not None:
                    gaps.append((now - last_arrival) * 1000)
                last_arrival = now

                try:
                    msg = json.loads(raw)
                except json.JSONDecodeError:
                    skipped += 1
                    continue

                emitted_ms = parse_ts_ms(msg.get(EMITTED_FIELD))
                if emitted_ms is None:
                    skipped += 1
                    continue

                deltas.append(received_ms - emitted_ms)

    except (websockets.ConnectionClosed, OSError):
        reconnects += 1
        await asyncio.sleep(min(2 ** reconnects, 15))  # simple backoff
    except asyncio.TimeoutError:
        break

return deltas, gaps, connect_ms_list, reconnects, skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def main():&lt;br&gt;
    ap = argparse.ArgumentParser()&lt;br&gt;
    ap.add_argument("--samples", type=int, default=300)&lt;br&gt;
    ap.add_argument("--max-seconds", type=int, default=600)&lt;br&gt;
    args = ap.parse_args()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if not WS_URL:
    raise SystemExit("Set WS_URL (from the docs) first.")

deltas, gaps, connects, reconnects, skipped = asyncio.run(
    run(args.samples, args.max_seconds)
)

if deltas:
    s = summarize("ws_delivery", deltas)
    print_summary(s)
    save(s, deltas)
if gaps:
    print_summary(summarize("ws_inter_arrival", gaps))
if connects:
    print_summary(summarize("ws_connect", connects))

print(f"\nreconnects: {reconnects} | messages skipped (no timestamp/not JSON): {skipped}")
if skipped and not deltas:
    print("No usable timestamps found. Check EMITTED_FIELD against the docs.")
&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()&lt;/p&gt;

&lt;p&gt;Run it during a busy period, such as when several matches are live:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python ws_bench.py --samples 300 --max-seconds 900&lt;/p&gt;

&lt;p&gt;A few things worth knowing before you interpret the output.&lt;/p&gt;

&lt;p&gt;Negative numbers mean clock skew. If you see negative delivery latencies, your clock is behind the provider's. Install NTP or chrony and run again. Don't "fix" it by taking absolute values.&lt;/p&gt;

&lt;p&gt;Measure at the right time. A stream during an empty Tuesday tells you nothing about a derby night. If you can, sample during peak load as well, because that's where p99 earns its keep. The WebSocket API page describes how the persistent-connection model is meant to be used.&lt;/p&gt;

&lt;p&gt;Check whether it's a stall or a slow stream. The inter-arrival gap distribution is the quiet hero here. A delivery latency that looks fine with huge gaps between messages might just mean a quiet match. A spiky gap pattern with steady delivery latency usually means bursty events, not a slow pipe.&lt;/p&gt;

&lt;p&gt;Step 4: benchmark webhooks&lt;/p&gt;

&lt;p&gt;Webhooks flip the direction. Instead of you reaching out, the provider calls your server, so the thing you're building is a tiny receiving endpoint that stamps each request the moment it arrives.&lt;/p&gt;

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

&lt;h1&gt;
  
  
  webhook_bench.py
&lt;/h1&gt;

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

&lt;p&gt;from fastapi import FastAPI, Request&lt;/p&gt;

&lt;p&gt;from config import EMITTED_FIELD&lt;br&gt;
from stats import summarize, parse_ts_ms&lt;/p&gt;

&lt;p&gt;app = FastAPI()&lt;br&gt;
deltas = []&lt;br&gt;
skipped = 0&lt;/p&gt;

&lt;p&gt;@app.post("/webhook")&lt;br&gt;
async def receive(request: Request):&lt;br&gt;
    global skipped&lt;br&gt;
    received_ms = time.time() * 1000  # stamp FIRST, before any parsing work&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;try:
    body = await request.json()
except Exception:
    skipped += 1
    return {"ok": False}

emitted_ms = parse_ts_ms(body.get(EMITTED_FIELD))
if emitted_ms is None:
    skipped += 1
else:
    deltas.append(received_ms - emitted_ms)

# Return fast. Do real work off the hot path in a real app.
return {"ok": True}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;@app.get("/stats")&lt;br&gt;
def stats():&lt;br&gt;
    return {"summary": summarize("webhook_delivery", deltas), "skipped": skipped}&lt;/p&gt;

&lt;p&gt;Start it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
uvicorn webhook_bench:app --host 0.0.0.0 --port 8000&lt;/p&gt;

&lt;p&gt;Webhooks need a public URL, so for local testing put a tunnel in front of it. Any of these work:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
ngrok http 8000&lt;/p&gt;

&lt;h1&gt;
  
  
  or
&lt;/h1&gt;

&lt;p&gt;cloudflared tunnel --url &lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Register &lt;a href="https://your-public-url/webhook" rel="noopener noreferrer"&gt;https://your-public-url/webhook&lt;/a&gt; as your webhook target using the steps in the Webhooks API docs, trigger some live events, then open /stats in your browser to read the percentiles.&lt;/p&gt;

&lt;p&gt;Two honest caveats here. First, the tunnel adds its own latency, so a laptop plus ngrok will overstate the real delivery time. For a result you can quote, run the receiver on a small cloud VM with a public IP. Second, if your provider signs webhook payloads, verify the signature in production. For benchmarking we skip it, but never ship an unauthenticated webhook endpoint.&lt;/p&gt;

&lt;p&gt;Step 5: read the results without lying to yourself&lt;/p&gt;

&lt;p&gt;Run all three, then put the numbers next to each other. Here's the mental model for what you're looking at:&lt;/p&gt;

&lt;p&gt;What you ran    What the number means&lt;br&gt;
rest_warm p50   Typical cost of one request on a reused connection&lt;br&gt;
rest_cold p50   What a first-time visitor pays&lt;br&gt;
ws_delivery p95/p99 How late the unlucky updates are on a push stream&lt;br&gt;
ws_connect  Cost of (re)connecting after a drop&lt;br&gt;
webhook_delivery    Provider-to-your-server push, including your network path&lt;/p&gt;

&lt;p&gt;And a few reading rules that save a lot of embarrassment:&lt;/p&gt;

&lt;p&gt;Compare p95 and p99, not means. A p50 of 40ms and a p99 of 900ms is a very different product from a p50 of 60ms and a p99 of 120ms.&lt;br&gt;
Don't compare REST to push directly. One measures a round trip, the other measures one-way delivery. They answer different questions.&lt;br&gt;
Sample size matters. Under about 100 samples, p99 is mostly noise. Collect more before you quote it.&lt;br&gt;
Run it more than once, at different times. One run is an anecdote. Three runs across a quiet hour and a busy hour start to look like evidence.&lt;br&gt;
If numbers look strange, check the status page first. It could be a provider-side incident rather than your setup.&lt;br&gt;
Testing without burning your quota&lt;/p&gt;

&lt;p&gt;If you're still developing the benchmark, don't spend your real request budget debugging a bug in your own script. The sandbox lets you try requests and look at real response shapes first. Follow the quickstart for your first call, and if you'd rather not hand-roll HTTP code in your actual product, the SDKs page lists the official clients.&lt;/p&gt;

&lt;p&gt;Extending the benchmark&lt;/p&gt;

&lt;p&gt;Once the basics work, there's a lot you can bolt on:&lt;/p&gt;

&lt;p&gt;Odds latency. If you consume markets, benchmark the Odds API separately. Odds change far more often than scores, so p99 behaviour under load looks different.&lt;br&gt;
Live scores. Point the REST test at the Live Scores API and add a staleness check that compares the match state you get back against what you saw one poll ago.&lt;br&gt;
Per-sport runs. A cricket stream and a tennis stream behave differently. Parametrize the path and compare.&lt;br&gt;
Scheduled runs. Put it in a cron job or a GitHub Action, write the JSON files somewhere, and graph p95 over time. A latency regression you catch in a dashboard is cheaper than one you learn about from users.&lt;br&gt;
Alerting. If p99 crosses a threshold you care about, send a message to your team chat.&lt;br&gt;
Wrapping up&lt;/p&gt;

&lt;p&gt;A benchmark you wrote yourself, against your own network, at your own peak hours, is worth more than any number on a landing page, including ours. Three scripts, one stats helper and a bit of discipline about percentiles and clocks is all it takes.&lt;/p&gt;

&lt;p&gt;Fork this, point it at whatever feed you're evaluating, and let the data argue. If you want a place to start, grab a free key, run the sandbox, and see what your own p99 looks like.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>websocket</category>
      <category>performance</category>
    </item>
    <item>
      <title>From Free Tier to Production: Migrating Your App Through Orbistats' Pricing Tiers</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:12:50 +0000</pubDate>
      <link>https://dev.to/orbistats/from-free-tier-to-production-migrating-your-app-through-orbistats-pricing-tiers-4ank</link>
      <guid>https://dev.to/orbistats/from-free-tier-to-production-migrating-your-app-through-orbistats-pricing-tiers-4ank</guid>
      <description>&lt;p&gt;Disclosure: I work with the Orbistats team. This guide is written to be useful whichever plan you end up on, including staying on the free one.&lt;/p&gt;

&lt;p&gt;Every successful API-powered app goes through the same awkward phase. It starts as a weekend prototype on a free key, works beautifully with three test users, and then someone says the dangerous sentence: "Can we launch this next month?"&lt;/p&gt;

&lt;p&gt;Suddenly you have real questions. Will the free tier survive real traffic? When exactly do you need to pay? What changes in your code when you move from polling to real time? Do you need historical data now or later? And how do you upgrade without a rewrite or a Friday-night outage?&lt;/p&gt;

&lt;p&gt;This post walks through that journey step by step using Orbistats, which covers 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing) behind one API. We'll cover:&lt;/p&gt;

&lt;p&gt;What each pricing tier is actually for&lt;br&gt;
How to make the free tier last far longer than you think&lt;br&gt;
A request budget tracker you can copy&lt;br&gt;
A cost estimator so you can predict usage before launch&lt;br&gt;
How to move from polling to WebSockets and webhooks&lt;br&gt;
How to backfill historical data safely&lt;br&gt;
A phase-by-phase migration plan with go-live checklists&lt;/p&gt;

&lt;p&gt;All code is plain Node.js 18 or newer, so you can drop it into any backend.&lt;/p&gt;

&lt;p&gt;The four tiers at a glance&lt;/p&gt;

&lt;p&gt;At the time of writing (October 2026), the pricing page lists four tiers. Always confirm current limits there before you plan around them.&lt;/p&gt;

&lt;p&gt;Free is $0 and positioned for testing. The documentation describes 150 requests per day on this plan, which is the number that shapes everything in the first half of this article.&lt;/p&gt;

&lt;p&gt;Starter is $19 per month and positioned for real-time production use.&lt;/p&gt;

&lt;p&gt;Growth is $79 per month and adds historical data, widgets and webhooks.&lt;/p&gt;

&lt;p&gt;Enterprise is custom pricing with dedicated infrastructure, for teams with specific volume, support or delivery requirements.&lt;/p&gt;

&lt;p&gt;A useful way to read this ladder is that each step unlocks a new capability, not just a bigger number. Free lets you prove the idea. Starter lets you run it live. Growth lets you build a richer product around history, embeds and push delivery. Enterprise lets you run it at serious scale with infrastructure of your own.&lt;/p&gt;

&lt;p&gt;Stage 1: Prototype on the free tier&lt;/p&gt;

&lt;p&gt;Start here, and don't feel bad about it. A free tier exists so you can find out whether your idea deserves money.&lt;/p&gt;

&lt;p&gt;First, create an account and copy your API key. Second, read the quickstart and the documentation. Authentication is a bearer token, and the base URL is:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
&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;Third, before you write any code, try a few calls in the Sandbox to see what real responses look like. Ten minutes there will save you hours of guessing field names.&lt;/p&gt;

&lt;p&gt;Your first request looks like this:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// first-call.js&lt;br&gt;
const res = await fetch("&lt;a href="https://api.orbistats.com/v1/football/fixtures" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/fixtures&lt;/a&gt;", {&lt;br&gt;
  headers: {&lt;br&gt;
    Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_API_KEY}&lt;/code&gt;,&lt;br&gt;
    Accept: "application/json",&lt;br&gt;
  },&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;console.log(res.status);&lt;br&gt;
console.log(await res.json());&lt;/p&gt;

&lt;p&gt;Run it with node first-call.js and a key in your environment. If you see a 200 and some JSON, you are in business.&lt;/p&gt;

&lt;p&gt;The important mindset for stage one: treat the daily limit as a design constraint from day one, not as a surprise at launch. Which brings us to the most valuable habit in this whole article.&lt;/p&gt;

&lt;p&gt;Why 150 requests per day is enough for a prototype (if you are smart)&lt;/p&gt;

&lt;p&gt;150 sounds tiny, but notice what it actually limits. It limits requests from your server to Orbistats. It does not limit how many people use your app.&lt;/p&gt;

&lt;p&gt;If ten thousand users open your football page and your server answers all of them from a cache, you make one upstream request, not ten thousand. That single idea is the difference between a prototype that dies at ten users and one that survives a demo day.&lt;/p&gt;

&lt;p&gt;The rule: users talk to your cache, and your cache talks to Orbistats.&lt;/p&gt;

&lt;p&gt;Different data deserves different cache lifetimes, because different data changes at different speeds:&lt;/p&gt;

&lt;p&gt;Live scores change constantly, so they get short lifetimes.&lt;br&gt;
Fixtures change a few times a day.&lt;br&gt;
Standings change after matches finish.&lt;br&gt;
Team and competition lists barely change at all.&lt;/p&gt;

&lt;p&gt;Build the client: cache, de-duplication, stale fallback and retries&lt;/p&gt;

&lt;p&gt;Here is a small client that does the four things every production integration needs, even on the free plan. It caches by data type, shares one in-flight request between simultaneous callers, serves slightly old data if the API fails, and retries politely.&lt;/p&gt;

&lt;p&gt;First, a tier configuration, so your code knows what the current plan allows:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// tiers.js&lt;br&gt;
// null means: take the value from the Orbistats docs and set it in your environment.&lt;br&gt;
export const TIERS = {&lt;br&gt;
  free:       { dailyBudget: 150,  realtime: false, historical: false, webhooks: false, widgets: false },&lt;br&gt;
  starter:    { dailyBudget: null, realtime: true,  historical: false, webhooks: false, widgets: false },&lt;br&gt;
  growth:     { dailyBudget: null, realtime: true,  historical: true,  webhooks: true,  widgets: true  },&lt;br&gt;
  enterprise: { dailyBudget: null, realtime: true,  historical: true,  webhooks: true,  widgets: true  },&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export const tier = TIERS[process.env.ORBISTATS_TIER ?? "free"];&lt;/p&gt;

&lt;p&gt;Notice that the plan is an environment variable. When you upgrade, you change one setting and the rest of your app adapts, which is exactly the migration experience you want.&lt;/p&gt;

&lt;p&gt;Next, a request budget tracker. It counts every upstream call and refuses to exceed your plan, so a bug can never burn your whole day's allowance in ten minutes:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// budget.js&lt;br&gt;
import { tier } from "./tiers.js";&lt;/p&gt;

&lt;p&gt;const today = () =&amp;gt; new Date().toISOString().slice(0, 10);&lt;/p&gt;

&lt;p&gt;export const budget = {&lt;br&gt;
  limit: Number(process.env.ORBISTATS_DAILY_BUDGET) || tier.dailyBudget,&lt;br&gt;
  day: today(),&lt;br&gt;
  used: 0,&lt;/p&gt;

&lt;p&gt;take() {&lt;br&gt;
    // Roll the counter over at the start of a new day.&lt;br&gt;
    // This assumes a UTC day. Confirm how the reset window works in the docs.&lt;br&gt;
    if (this.day !== today()) {&lt;br&gt;
      this.day = today();&lt;br&gt;
      this.used = 0;&lt;br&gt;
    }&lt;br&gt;
    if (this.limit &amp;amp;&amp;amp; this.used &amp;gt;= this.limit) return false;&lt;br&gt;
    this.used += 1;&lt;br&gt;
    return true;&lt;br&gt;
  },&lt;/p&gt;

&lt;p&gt;status() {&lt;br&gt;
    return {&lt;br&gt;
      used: this.used,&lt;br&gt;
      limit: this.limit,&lt;br&gt;
      ratio: this.limit ? this.used / this.limit : null,&lt;br&gt;
    };&lt;br&gt;
  },&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;Now the client itself:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// client.js&lt;br&gt;
import { budget } from "./budget.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;br&gt;
const cache = new Map();&lt;br&gt;
const inflight = new Map();&lt;/p&gt;

&lt;p&gt;const sleep = (ms) =&amp;gt; new Promise((r) =&amp;gt; setTimeout(r, ms));&lt;/p&gt;

&lt;p&gt;async function fetchWithRetry(path, attempts = 3) {&lt;br&gt;
  for (let attempt = 0; attempt &amp;lt; attempts; attempt++) {&lt;br&gt;
    if (!budget.take()) {&lt;br&gt;
      throw Object.assign(new Error("Daily request budget used up"), { status: 429 });&lt;br&gt;
    }&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const res = await fetch(`${BASE}${path}`, {
  headers: {
    Authorization: `Bearer ${process.env.ORBISTATS_API_KEY}`,
    Accept: "application/json",
  },
});

if (res.ok) return res.json();

const retryable = res.status === 429 || res.status &amp;gt;= 500;
if (!retryable || attempt === attempts - 1) {
  throw Object.assign(new Error(`Orbistats ${res.status} on ${path}`), {
    status: res.status,
  });
}

// Exponential backoff: 500ms, 1000ms, 2000ms.
await sleep(500 &amp;lt;&amp;lt; attempt);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;export async function getData(path, { ttlMs = 30_000, staleMs = 600_000 } = {}) {&lt;br&gt;
  const now = Date.now();&lt;br&gt;
  const hit = cache.get(path);&lt;/p&gt;

&lt;p&gt;// 1. Fresh cache wins.&lt;br&gt;
  if (hit &amp;amp;&amp;amp; now - hit.at &amp;lt; ttlMs) return { data: hit.data, source: "cache" };&lt;/p&gt;

&lt;p&gt;// 2. If someone is already fetching this path, wait for that request.&lt;br&gt;
  if (inflight.has(path)) return inflight.get(path);&lt;/p&gt;

&lt;p&gt;// 3. Otherwise fetch, and fall back to slightly old data if it fails.&lt;br&gt;
  const job = fetchWithRetry(path)&lt;br&gt;
    .then((data) =&amp;gt; {&lt;br&gt;
      cache.set(path, { at: Date.now(), data });&lt;br&gt;
      return { data, source: "live" };&lt;br&gt;
    })&lt;br&gt;
    .catch((err) =&amp;gt; {&lt;br&gt;
      if (hit &amp;amp;&amp;amp; now - hit.at &amp;lt; staleMs) return { data: hit.data, source: "stale" };&lt;br&gt;
      throw err;&lt;br&gt;
    })&lt;br&gt;
    .finally(() =&amp;gt; inflight.delete(path));&lt;/p&gt;

&lt;p&gt;inflight.set(path, job);&lt;br&gt;
  return job;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Why each piece matters:&lt;/p&gt;

&lt;p&gt;The in-flight map stops a "thundering herd". When 500 users load a page at the same instant, they share one upstream call.&lt;/p&gt;

&lt;p&gt;Stale fallback means that if Orbistats or your network has a brief problem, users see data that is a few minutes old instead of an error page. For standings or fixtures, that is almost always the right trade-off.&lt;/p&gt;

&lt;p&gt;Backoff only retries errors that can recover (429 and 5xx). A 401 or 404 won't fix itself, so you fail fast. In production you should also respect a Retry-After header if the API sends one, and add a little random jitter to your delays.&lt;/p&gt;

&lt;p&gt;The budget guard turns a silent quota problem into a clear, loggable error.&lt;/p&gt;

&lt;p&gt;Using it in a route:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// server.js (excerpt)&lt;br&gt;
import "dotenv/config";&lt;br&gt;
import express from "express";&lt;br&gt;
import { getData } from "./client.js";&lt;br&gt;
import { budget } from "./budget.js";&lt;/p&gt;

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

&lt;p&gt;app.get("/api/:sport/live", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const { data, source } = await getData(&lt;code&gt;/${req.params.sport}/matches/live&lt;/code&gt;, {&lt;br&gt;
      ttlMs: 600_000, // free-tier friendly: 10 minutes&lt;br&gt;
    });&lt;br&gt;
    res.set("X-Data-Source", source);&lt;br&gt;
    res.json(data);&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.get("/internal/usage", (_req, res) =&amp;gt; res.json(budget.status()));&lt;/p&gt;

&lt;p&gt;app.listen(3000);&lt;/p&gt;

&lt;p&gt;The X-Data-Source header and the /internal/usage endpoint cost nothing and give you instant visibility. Check them while you develop, so you always know whether a response came from cache, from the live API, or from the stale fallback.&lt;/p&gt;

&lt;p&gt;Predict your usage before you launch&lt;/p&gt;

&lt;p&gt;Guessing is how people get surprised. Because your cache decouples users from requests, you can estimate upstream usage with simple arithmetic. It depends on how many sports you show and how fresh each feed needs to be, not on how many visitors you have.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// estimate.js&lt;br&gt;
// Upstream requests per day = for each feed, active seconds divided by cache lifetime.&lt;br&gt;
export function dailyRequests(plan) {&lt;br&gt;
  return plan.reduce(&lt;br&gt;
    (sum, feed) =&amp;gt; sum + Math.floor(feed.activeSeconds / feed.ttlSeconds),&lt;br&gt;
    0&lt;br&gt;
  );&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const eagerPlan = [&lt;br&gt;
  { name: "football live",      ttlSeconds: 15,    activeSeconds: 28_800 }, // 8 hours&lt;br&gt;
  { name: "football fixtures",  ttlSeconds: 300,   activeSeconds: 86_400 },&lt;br&gt;
  { name: "football standings", ttlSeconds: 600,   activeSeconds: 86_400 },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;const frugalPlan = [&lt;br&gt;
  { name: "football live",      ttlSeconds: 600,   activeSeconds: 28_800 },&lt;br&gt;
  { name: "football fixtures",  ttlSeconds: 3_600, activeSeconds: 86_400 },&lt;br&gt;
  { name: "football standings", ttlSeconds: 21_600, activeSeconds: 86_400 },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;console.log("eager:", dailyRequests(eagerPlan)); // 2352 per day&lt;br&gt;
console.log("frugal:", dailyRequests(frugalPlan)); // 76 per day&lt;/p&gt;

&lt;p&gt;Run it and look at the gap. A single sport with 15-second live caching needs 2,352 requests a day, which is far beyond the free plan. The same sport with relaxed cache times needs 76. That is the real shape of the decision:&lt;/p&gt;

&lt;p&gt;If your product is fine with live data that is several minutes old, the free tier can carry a surprising amount.&lt;/p&gt;

&lt;p&gt;If your product promises genuinely live scores, you have outgrown the free tier, and that's exactly what the next plan is for.&lt;/p&gt;

&lt;p&gt;Multiply the exercise by the number of sports you cover, and you'll know in five minutes which plan you need.&lt;/p&gt;

&lt;p&gt;Signals that it's time to upgrade&lt;/p&gt;

&lt;p&gt;You don't need a spreadsheet to know when to move. Upgrade when you see these:&lt;/p&gt;

&lt;p&gt;Your budget tracker regularly passes 80 percent before the day is half over.&lt;br&gt;
Product asked for live scores that update in seconds, not minutes.&lt;br&gt;
You are caching so aggressively that the data feels stale to your users.&lt;br&gt;
You are covering more sports, and the multiplication no longer fits.&lt;br&gt;
You need history for charts, head-to-head or models.&lt;br&gt;
You need push delivery instead of polling.&lt;br&gt;
You are about to run a campaign, a launch or a tournament that will spike traffic.&lt;br&gt;
You've started charging users or promising an uptime level.&lt;/p&gt;

&lt;p&gt;Here is a simple, honest way to think about cost. The Starter tier is $19 per month. If staying on the free plan forces even an hour of extra engineering time per month to squeeze usage, the paid plan is probably the cheaper option. Developer time is the expensive resource, so spend money to save it.&lt;/p&gt;

&lt;p&gt;Stage 2: Go live on Starter&lt;/p&gt;

&lt;p&gt;Starter is the real-time production step. The big change in your architecture is that you stop polling and start listening.&lt;/p&gt;

&lt;p&gt;Polling every 15 seconds is wasteful. Most requests return "nothing changed". A WebSocket keeps one connection open, and updates arrive the moment they happen. Pair that with the Live Scores API for the initial state and you have a proper live experience.&lt;/p&gt;

&lt;p&gt;Here is the pattern: load the current state over REST once, then keep it fresh from the stream.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// realtime.js&lt;br&gt;
import "dotenv/config";&lt;br&gt;
import WebSocket from "ws"; // npm install ws&lt;br&gt;
import { tier } from "./tiers.js";&lt;/p&gt;

&lt;p&gt;const listeners = new Set();&lt;br&gt;
export const onUpdate = (fn) =&amp;gt; listeners.add(fn);&lt;/p&gt;

&lt;p&gt;export function startRealtime() {&lt;br&gt;
  if (!tier.realtime) {&lt;br&gt;
    console.log("Real-time is not enabled on this plan, staying on cached polling.");&lt;br&gt;
    return;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;let attempt = 0;&lt;/p&gt;

&lt;p&gt;function connect() {&lt;br&gt;
    // Get the real URL and subscription message from the WebSocket docs.&lt;br&gt;
    const ws = new WebSocket(process.env.ORBISTATS_WS_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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ws.on("open", () =&amp;gt; {
  attempt = 0;
  ws.send(JSON.stringify({ action: "subscribe", sport: "football" }));
});

ws.on("message", (buf) =&amp;gt; {
  const event = JSON.parse(buf.toString());
  for (const fn of listeners) fn(event);
});

ws.on("close", () =&amp;gt; {
  // Reconnect with exponential backoff, capped at 30 seconds.
  const delay = Math.min(30_000, 1_000 &amp;lt;&amp;lt; attempt);
  attempt += 1;
  setTimeout(connect, delay);
});

ws.on("error", (err) =&amp;gt; console.warn("WebSocket error:", err.message));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;connect();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Note the first lines of startRealtime. Because the plan is a setting, the very same codebase runs on both the free and the paid tier. On free it politely stays on cached polling. On Starter it switches to the stream. No branches in your business logic, no separate deployment.&lt;/p&gt;

&lt;p&gt;To get those events into browsers, forward them with Server-Sent Events:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// stream.js&lt;br&gt;
const clients = new Set();&lt;/p&gt;

&lt;p&gt;export function mountStream(app) {&lt;br&gt;
  app.get("/stream", (req, res) =&amp;gt; {&lt;br&gt;
    res.set({&lt;br&gt;
      "Content-Type": "text/event-stream",&lt;br&gt;
      "Cache-Control": "no-cache",&lt;br&gt;
      Connection: "keep-alive",&lt;br&gt;
    });&lt;br&gt;
    res.flushHeaders();&lt;br&gt;
    clients.add(res);&lt;br&gt;
    req.on("close", () =&amp;gt; clients.delete(res));&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function broadcast(event) {&lt;br&gt;
  for (const c of clients) c.write(&lt;code&gt;data: ${JSON.stringify(event)}\n\n&lt;/code&gt;);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Connect the two pieces with one line, onUpdate(broadcast), and every browser tab updates the moment a score changes.&lt;/p&gt;

&lt;p&gt;If your product shows prices, add the Odds API to the same pipeline. It returns normalized odds across bookmakers in one schema, so you don't maintain a parser per bookmaker. If you want richer match pages, the Sports Statistics API adds team and player numbers.&lt;/p&gt;

&lt;p&gt;Starter go-live checklist&lt;/p&gt;

&lt;p&gt;Environment variable ORBISTATS_TIER switched to starter, and the budget limit set from the docs.&lt;br&gt;
WebSocket reconnect tested by killing the connection on purpose.&lt;br&gt;
REST used for initial state, stream used for changes.&lt;br&gt;
Alert when the budget passes 80 percent.&lt;br&gt;
Error pages tested with the API unreachable, to confirm that the stale fallback works.&lt;br&gt;
The status page added to your monitoring routine.&lt;br&gt;
A dashboard or log line showing the data source (cache, live, stale).&lt;/p&gt;

&lt;p&gt;Stage 3: Grow into history, webhooks and widgets&lt;/p&gt;

&lt;p&gt;Growth is where your product gets deeper rather than just bigger. At the time of writing it adds historical data, widgets and webhooks.&lt;/p&gt;

&lt;p&gt;Historical data&lt;/p&gt;

&lt;p&gt;The Historical Sports Data API is a multi-season archive (fixtures, results, statistics, lineups, events and closing odds, with seasons going back as far as 2010 for covered data). It unlocks head-to-head history, season form charts, backtesting and machine learning datasets.&lt;/p&gt;

&lt;p&gt;The golden rule with history: fetch it once, store it yourself, and never ask for it again. Old seasons don't change, so a one-time backfill is vastly cheaper than hitting the API on every page view.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// backfill.js&lt;br&gt;
import { appendFile, readFile } from "node:fs/promises";&lt;br&gt;
import { getData } from "./client.js";&lt;/p&gt;

&lt;p&gt;const OUT = "history.jsonl";&lt;br&gt;
const PROGRESS = "history.progress.json";&lt;/p&gt;

&lt;p&gt;async function loadProgress() {&lt;br&gt;
  try { return JSON.parse(await readFile(PROGRESS, "utf8")); }&lt;br&gt;
  catch { return { done: [] }; }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;async function saveProgress(p) {&lt;br&gt;
  const { writeFile } = await import("node:fs/promises");&lt;br&gt;
  await writeFile(PROGRESS, JSON.stringify(p));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export async function backfill(sport, seasons) {&lt;br&gt;
  const progress = await loadProgress();&lt;/p&gt;

&lt;p&gt;for (const season of seasons) {&lt;br&gt;
    const key = &lt;code&gt;${sport}:${season}&lt;/code&gt;;&lt;br&gt;
    if (progress.done.includes(key)) continue; // resume support&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// PLACEHOLDER: confirm the exact historical path and parameters in the API reference.
const { data } = await getData(`/${sport}/results?season=${season}`, {
  ttlMs: 0,
});

const rows = Array.isArray(data) ? data : (data.data ?? []);
const lines = rows.map((r) =&amp;gt; JSON.stringify({ sport, season, ...r })).join("\n");
if (lines) await appendFile(OUT, lines + "\n");

progress.done.push(key);
await saveProgress(progress);
console.log(`Saved ${rows.length} rows for ${key}`);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;// Example: backfill(...) with the seasons you actually need.&lt;br&gt;
// await backfill("football", [2022, 2023, 2024, 2025]);&lt;/p&gt;

&lt;p&gt;A few reasons this script is written the way it is. It saves progress after every season, so if it crashes or you hit a limit, you resume instead of starting over. It goes through the same budget-aware client, so you can't accidentally burn your quota. It writes one JSON object per line, which loads easily into a database, a notebook or a data pipeline later.&lt;/p&gt;

&lt;p&gt;Also be mindful of usage terms when you store data. Read the data licensing page before you redistribute or display archived data commercially.&lt;/p&gt;

&lt;p&gt;Webhooks&lt;/p&gt;

&lt;p&gt;Webhooks flip the direction. Instead of you listening, Orbistats calls your server when something happens. They are ideal for notifications, background jobs and anything that shouldn't depend on a browser being open.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// webhook.js&lt;br&gt;
import express from "express";&lt;/p&gt;

&lt;p&gt;const seen = new Set(); // use Redis or a database in production&lt;/p&gt;

&lt;p&gt;export function mountWebhook(app, handleEvent) {&lt;br&gt;
  app.post("/webhook/sports", express.json(), (req, res) =&amp;gt; {&lt;br&gt;
    // 1. Verify the signature using the method described in the webhook docs.&lt;br&gt;
    // 2. Ignore duplicates, because delivery systems can send the same event twice.&lt;br&gt;
    const id = req.body?.event_id ?? JSON.stringify(req.body);&lt;br&gt;
    if (seen.has(id)) return res.sendStatus(200);&lt;br&gt;
    seen.add(id);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 3. Acknowledge immediately, process afterwards.
res.sendStatus(200);
queueMicrotask(() =&amp;gt; handleEvent(req.body));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;});&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Three habits protect you here: verify authenticity, make handlers idempotent so a repeated event is harmless, and answer fast. If your endpoint is slow, senders may retry and you get even more duplicates.&lt;/p&gt;

&lt;p&gt;Widgets&lt;/p&gt;

&lt;p&gt;If part of your product is content (articles, previews, recaps), you don't need to build every score box yourself. Orbistats Widgets let you embed ready-made components such as live scores, a match center and an odds board. A sensible split is custom UI for the screens that define your product and widgets for the long tail of pages.&lt;/p&gt;

&lt;p&gt;Growth go-live checklist&lt;/p&gt;

&lt;p&gt;Historical data backfilled once and stored on your side.&lt;br&gt;
Webhook endpoint verified, idempotent and fast.&lt;br&gt;
Duplicate event handling tested by replaying one on purpose.&lt;br&gt;
Widget containers reserve height, so pages don't jump when they load.&lt;br&gt;
Licensing terms read for any commercial display of archived data.&lt;br&gt;
Backups of your stored history.&lt;/p&gt;

&lt;p&gt;Stage 4: Enterprise and beyond&lt;/p&gt;

&lt;p&gt;Enterprise is for teams whose needs have moved past a published tier: very high volume, dedicated infrastructure, custom feeds, specific support expectations or contractual requirements. There is no code change to talk about here, because if you followed the earlier stages your integration is already plan-agnostic. What you do need is a clear conversation.&lt;/p&gt;

&lt;p&gt;Before you contact sales (the address on the site is &lt;a href="mailto:sales@orbistats.com"&gt;sales@orbistats.com&lt;/a&gt;), prepare answers to these questions:&lt;/p&gt;

&lt;p&gt;Which sports and competitions do you need, and how many requests per day or events per second do you expect at peak?&lt;br&gt;
Which delivery methods matter most: REST, WebSocket, webhooks or all three?&lt;br&gt;
What latency do you need, and what are you measuring today?&lt;br&gt;
Do you need historical depth, bulk exports or custom data feeds?&lt;br&gt;
What support, uptime and reporting expectations does your business have?&lt;br&gt;
What will your usage look like at launch, in six months and in twelve?&lt;/p&gt;

&lt;p&gt;The estimator script from earlier turns directly into the volume numbers for that conversation. Teams that arrive with data tend to get faster, more useful answers.&lt;/p&gt;

&lt;p&gt;A clean way to switch plans without drama&lt;/p&gt;

&lt;p&gt;The best migrations are boring. Here is the sequence that keeps them boring.&lt;/p&gt;

&lt;p&gt;Create separate keys for development, staging and production, and never share a key across environments.&lt;br&gt;
Keep every plan-specific behaviour behind the tier configuration, so an upgrade is a settings change.&lt;br&gt;
Upgrade in staging first and run your full test flow, including failure cases.&lt;br&gt;
Switch production during a quiet period, not an hour before a big match.&lt;br&gt;
Watch the usage endpoint and the data-source header for the first day.&lt;br&gt;
Keep the old behaviour available as a fallback for a week, so you can roll back by changing one variable.&lt;br&gt;
Only then remove the workarounds you built for the lower tier, such as the extreme cache lifetimes.&lt;/p&gt;

&lt;p&gt;Step seven is the one people skip, and it's the satisfying part. After upgrading, relax your caching, lower your live latency and let the product feel the way you originally imagined.&lt;/p&gt;

&lt;p&gt;Common mistakes to avoid&lt;/p&gt;

&lt;p&gt;Calling the API from the browser, which exposes your key and bypasses your cache.&lt;br&gt;
Using one key for everything, so a staging bug can burn production quota.&lt;br&gt;
Treating the daily limit as a problem to discover at launch instead of a design constraint from day one.&lt;br&gt;
Polling every few seconds for data that rarely changes.&lt;br&gt;
Re-fetching old seasons on every request instead of storing them once.&lt;br&gt;
Building the whole app around one plan's behaviour, which makes upgrading painful.&lt;br&gt;
Skipping the fallback, so one failed request turns into a blank screen.&lt;br&gt;
Upgrading in a panic during a spike instead of planning ahead from your own usage numbers.&lt;br&gt;
Ignoring versioning. Orbistats uses versioned paths such as /v1/, so pin your integration to a version and read the changelog before changing it.&lt;/p&gt;

&lt;p&gt;Quick recap&lt;/p&gt;

&lt;p&gt;Stay on Free while you validate the idea, and make it last with caching, de-duplication and a request budget.&lt;/p&gt;

&lt;p&gt;Move to Starter when you need genuinely live data in production, and switch from polling to WebSockets.&lt;/p&gt;

&lt;p&gt;Move to Growth when you need history, push delivery with webhooks, or embeddable widgets.&lt;/p&gt;

&lt;p&gt;Talk to sales about Enterprise when your volume, latency or support needs go beyond a published plan.&lt;/p&gt;

&lt;p&gt;And through every stage, keep the plan as a configuration setting, keep your key on the server, and keep the browser talking to your own backend.&lt;/p&gt;

&lt;p&gt;Where to go next&lt;/p&gt;

&lt;p&gt;Read the documentation for authentication, versioning and limits.&lt;br&gt;
Try responses in the Sandbox before you write code.&lt;br&gt;
Explore the API reference for the exact endpoints and parameters.&lt;br&gt;
Compare tiers on the pricing page.&lt;/p&gt;

&lt;p&gt;Ready to start? Grab a free API key and run the estimator on your own project today. You'll know in five minutes which stage you're really at.&lt;/p&gt;

&lt;p&gt;Over to you: what was the moment you realised your side project needed a real plan, a traffic spike, a client demo, or something else? Share your story in the comments, and tell me which part of the migration you'd like a deeper dive on.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Orbistats Widgets vs Custom UI: Pre-Built Match Center</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:09:36 +0000</pubDate>
      <link>https://dev.to/orbistats/orbistats-widgets-vs-custom-ui-pre-built-match-center-148a</link>
      <guid>https://dev.to/orbistats/orbistats-widgets-vs-custom-ui-pre-built-match-center-148a</guid>
      <description>&lt;p&gt;Disclosure: I work with the Orbistats team. This post is meant to help you make an honest build-or-embed decision, including the cases where you should not use our widgets.&lt;/p&gt;

&lt;p&gt;Every sports product hits the same fork in the road. You have the data, you have a design in your head, and you have a deadline. Do you spend weeks building a match center from scratch, or do you drop in a pre-built one and ship this afternoon?&lt;/p&gt;

&lt;p&gt;Most blog posts answer this with "it depends". This one tries to be more useful. We'll go through a concrete decision framework, a small scoring script you can run on your own project, three working architecture patterns (widget-only, custom-only and hybrid), and the details people forget: SEO, performance, layout shift, content security policy and fallbacks.&lt;/p&gt;

&lt;p&gt;By the end you'll know exactly when to embed, when to build, and how to do both without regret.&lt;/p&gt;

&lt;p&gt;The two options in plain words&lt;/p&gt;

&lt;p&gt;Option A is widgets. Orbistats Widgets are drop-in components such as a live score widget, a match center widget and an odds board widget. Your site embeds them, Orbistats powers the data and the UI, and you write very little front-end code.&lt;/p&gt;

&lt;p&gt;Option B is a custom UI. You consume the data yourself through the Sports Data API, the Live Scores API and optionally the Odds API, then design and build every pixel.&lt;/p&gt;

&lt;p&gt;Both options sit on top of the same data platform, which across 13 sports means football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing. That is important because it means you are not locked in. You can start with widgets and move to custom later, or mix them.&lt;/p&gt;

&lt;p&gt;What a match center actually contains&lt;/p&gt;

&lt;p&gt;When people say "match center" they usually imagine a scoreboard. In reality, a good one is a small application. Here is what a complete one handles:&lt;/p&gt;

&lt;p&gt;The header: teams or players, logos, score, status and match clock&lt;br&gt;
The timeline: goals, cards, substitutions, wickets, sets, rounds, depending on the sport&lt;br&gt;
Statistics: possession, shots, corners, aces, rebounds, and so on&lt;br&gt;
Lineups and formations&lt;br&gt;
Head-to-head history&lt;br&gt;
Odds, if your product shows them&lt;br&gt;
Live updates without a page refresh&lt;br&gt;
Edge states: postponed, delayed, abandoned, extra time, penalties, walkovers, retirements&lt;br&gt;
Mobile layout, dark mode, accessibility and translations&lt;/p&gt;

&lt;p&gt;Each item looks small on its own. Together they are the reason "we'll just build it ourselves" quietly becomes a six-week project. This is the hidden iceberg of custom sports UI.&lt;/p&gt;

&lt;p&gt;The hidden cost of custom UI&lt;/p&gt;

&lt;p&gt;Let's be fair to both sides, starting with the cost of building your own.&lt;/p&gt;

&lt;p&gt;Sport differences multiply the work. A football timeline and a tennis timeline share almost nothing. Golf and horse racing don't even have a home and away side. If you cover many sports, you are building many match centers.&lt;/p&gt;

&lt;p&gt;Live updates need real engineering. Polling is easy to start and expensive to scale. Doing it properly means a WebSocket connection or webhooks, reconnect logic, de-duplication of events and graceful degradation.&lt;/p&gt;

&lt;p&gt;Edge cases arrive after launch. Nobody designs the "match abandoned at 63 minutes" screen on day one. Users find it for you.&lt;/p&gt;

&lt;p&gt;Maintenance never ends. Every new competition, data field or sport is a ticket in your backlog.&lt;/p&gt;

&lt;p&gt;On the other hand, custom UI gives you total control over design, interaction and how data blends with your own product. For some products that control is the whole point.&lt;/p&gt;

&lt;p&gt;The hidden cost of widgets&lt;/p&gt;

&lt;p&gt;Widgets are not free of trade-offs either.&lt;/p&gt;

&lt;p&gt;You get less design freedom. Even a well-themed widget will not behave exactly like a component designed for your product.&lt;/p&gt;

&lt;p&gt;Your own data is harder to mix in. If you want to overlay user predictions, wallet balances, fantasy picks or personalised alerts directly inside the match view, an embed is the wrong tool.&lt;/p&gt;

&lt;p&gt;SEO needs care. Content rendered inside an embed may not count as your page's content. We'll fix that below.&lt;/p&gt;

&lt;p&gt;Embeds touch your security setup. Third-party scripts and frames need to be allowed in your content security policy, and you should plan for what happens when they fail to load.&lt;/p&gt;

&lt;p&gt;Plan limits apply. At the time of writing (October 2026), the pricing page positions Growth at $79/month with historical data, widgets and webhooks, while Free is $0 for testing and Starter is $19/month for real-time production. Check which plan includes the widgets you need before you design around them.&lt;/p&gt;

&lt;p&gt;A decision framework you can run in 30 seconds&lt;/p&gt;

&lt;p&gt;Here is a simple, honest scorecard. Positive numbers push you toward widgets. Negative numbers push you toward a custom build. It is deliberately small so you can edit the weights for your team.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// decide.js&lt;br&gt;
// Positive score leans widget. Negative score leans custom.&lt;/p&gt;

&lt;p&gt;const FACTORS = [&lt;br&gt;
  { id: "launchFast",        ask: "Must it be live in days, not weeks?",              yes: 2 },&lt;br&gt;
  { id: "noFrontendCapacity", ask: "No spare front-end developer time?",               yes: 2 },&lt;br&gt;
  { id: "contentSite",       ask: "Is this for articles, blogs or editorial pages?",  yes: 2 },&lt;br&gt;
  { id: "strictBrand",       ask: "Must it match a strict design system exactly?",    yes: -2 },&lt;br&gt;
  { id: "customInteractions", ask: "Need betslips, favourites, picks or predictions?", yes: -3 },&lt;br&gt;
  { id: "joinOwnData",       ask: "Must it blend with your own users or data?",       yes: -2 },&lt;br&gt;
  { id: "nativeApp",         ask: "Is the main surface a native mobile app?",         yes: -3 },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;export function decide(answers) {&lt;br&gt;
  const score = FACTORS.reduce(&lt;br&gt;
    (sum, f) =&amp;gt; sum + (answers[f.id] ? f.yes : 0),&lt;br&gt;
    0&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;if (score &amp;gt;= 3) return { score, verdict: "Use the pre-built widgets" };&lt;br&gt;
  if (score &amp;lt;= -2) return { score, verdict: "Build a custom UI" };&lt;br&gt;
  return { score, verdict: "Go hybrid: widgets first, custom where it matters" };&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// Example: a news site adding match pages to articles&lt;br&gt;
console.log(&lt;br&gt;
  decide({ launchFast: true, noFrontendCapacity: true, contentSite: true })&lt;br&gt;
);&lt;br&gt;
// { score: 6, verdict: "Use the pre-built widgets" }&lt;/p&gt;

&lt;p&gt;// Example: a fantasy app with picks and user data&lt;br&gt;
console.log(&lt;br&gt;
  decide({ customInteractions: true, joinOwnData: true, strictBrand: true })&lt;br&gt;
);&lt;br&gt;
// { score: -7, verdict: "Build a custom UI" }&lt;/p&gt;

&lt;p&gt;Run it with node decide.js. It is not science, but it forces the conversation your team needs to have, and it makes the answer visible to non-engineers.&lt;/p&gt;

&lt;p&gt;Which audience usually lands where&lt;/p&gt;

&lt;p&gt;The Orbistats solution pages are organised by audience, and the pattern holds up in practice.&lt;/p&gt;

&lt;p&gt;Media and publishers lean heavily toward widgets. If you run editorial pages, Media and Publishers is the natural fit: live scores and match centers on article pages without a front-end project.&lt;/p&gt;

&lt;p&gt;Fantasy and analytics products lean custom. The product is the experience, so Fantasy and AI Data teams usually build their own UI on top of the data API.&lt;/p&gt;

&lt;p&gt;Sportsbooks and trading products lean custom with real-time feeds. Branding, bet flows and latency matter, so Sportsbooks and Trading teams generally consume odds directly.&lt;/p&gt;

&lt;p&gt;Startups and MVPs lean widgets first. Validate that anyone cares about your match pages before investing in a bespoke component library.&lt;/p&gt;

&lt;p&gt;Agencies and client sites lean widgets. Fast delivery, low maintenance, predictable result.&lt;/p&gt;

&lt;p&gt;Pattern 1: Widget-only (the afternoon build)&lt;/p&gt;

&lt;p&gt;If the scorecard said widget, here is how to embed it properly rather than just pasting a snippet and hoping.&lt;/p&gt;

&lt;p&gt;First, get access. Create an account, then open the documentation and the widgets page for the exact embed code, supported options and theming controls. The snippet below uses clearly marked placeholders, so replace them with the real values from the docs.&lt;/p&gt;

&lt;p&gt;Here is a loader that does three things most copy-pasted embeds don't: it lazy-loads the widget only when it scrolls into view, it reserves space to prevent layout shift, and it never blocks your page.&lt;/p&gt;

&lt;p&gt;html&lt;/p&gt;


&lt;h1&gt;Manchester City vs Arsenal&lt;/h1&gt;
&lt;br&gt;
  &lt;p&gt;Premier League, Sunday 18 October 2026, 16:30 UTC&lt;/p&gt;

&lt;p&gt;.match-center-slot {&lt;br&gt;
    min-height: 520px; /* reserve space so the page does not jump */&lt;br&gt;
    border-radius: 12px;&lt;br&gt;
    background: rgb(20, 29, 51);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// PLACEHOLDER: copy the real script URL and options from the Orbistats widgets docs&lt;br&gt;
  const WIDGET_SRC = "WIDGET_SCRIPT_URL_FROM_DOCS";&lt;/p&gt;

&lt;p&gt;const slot = document.getElementById("match-center");&lt;/p&gt;

&lt;p&gt;const observer = new IntersectionObserver((entries) =&amp;gt; {&lt;br&gt;
    if (!entries[0].isIntersecting) return;&lt;br&gt;
    observer.disconnect();&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const s = document.createElement("script");
s.src = WIDGET_SRC;
s.async = true;
document.head.append(s);
// The widget then mounts into the slot as described in the docs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}, { rootMargin: "200px" });&lt;/p&gt;

&lt;p&gt;observer.observe(slot);&lt;/p&gt;

&lt;p&gt;Why this matters: a widget at the bottom of a long article might never be seen, and loading it anyway wastes your user's bandwidth. Lazy-loading with a 200px margin means it is ready just before the reader reaches it.&lt;/p&gt;

&lt;p&gt;Pattern 2: Custom-only (when the UI is the product)&lt;/p&gt;

&lt;p&gt;If you chose custom, you will build on the data API. The safest architecture is the same one from the multi-sport dashboard approach: a small server that holds your API key, caches responses and normalises shapes.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// server.js (excerpt)&lt;br&gt;
import "dotenv/config";&lt;br&gt;
import express from "express";&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
const BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;";&lt;br&gt;
const cache = new Map();&lt;/p&gt;

&lt;p&gt;async function orbistats(path, ttlMs) {&lt;br&gt;
  const hit = cache.get(path);&lt;br&gt;
  if (hit &amp;amp;&amp;amp; Date.now() - hit.at &amp;lt; ttlMs) return hit.data;&lt;/p&gt;

&lt;p&gt;const res = await fetch(&lt;code&gt;${BASE}${path}&lt;/code&gt;, {&lt;br&gt;
    headers: {&lt;br&gt;
      Authorization: &lt;code&gt;Bearer ${process.env.ORBISTATS_API_KEY}&lt;/code&gt;,&lt;br&gt;
      Accept: "application/json",&lt;br&gt;
    },&lt;br&gt;
  });&lt;br&gt;
  if (!res.ok) throw Object.assign(new Error("Upstream error"), { status: res.status });&lt;/p&gt;

&lt;p&gt;const data = await res.json();&lt;br&gt;
  cache.set(path, { at: Date.now(), data });&lt;br&gt;
  return data;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// The live-scores sample on the Orbistats homepage uses this path shape.&lt;br&gt;
app.get("/api/:sport/live", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    res.json(await orbistats(&lt;code&gt;/${req.params.sport}/matches/live&lt;/code&gt;, 15000));&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(3000);&lt;/p&gt;

&lt;p&gt;And a score header component that renders safely from untrusted text:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// matchHeader.js&lt;br&gt;
export function renderMatchHeader(container, match) {&lt;br&gt;
  container.replaceChildren();&lt;/p&gt;

&lt;p&gt;const status = document.createElement("div");&lt;br&gt;
  status.className = "status";&lt;br&gt;
  status.textContent =&lt;br&gt;
    match.status === "live" ? &lt;code&gt;LIVE ${match.minute}'&lt;/code&gt; : match.status;&lt;/p&gt;

&lt;p&gt;const row = document.createElement("div");&lt;br&gt;
  row.className = "teams";&lt;/p&gt;

&lt;p&gt;for (const side of [match.home, match.away]) {&lt;br&gt;
    const el = document.createElement("div");&lt;br&gt;
    const name = document.createElement("span");&lt;br&gt;
    name.textContent = side.name;&lt;br&gt;
    const score = document.createElement("strong");&lt;br&gt;
    score.textContent = side.score;&lt;br&gt;
    el.append(name, score);&lt;br&gt;
    row.append(el);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;container.append(status, row);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Field names such as status, minute, home and away follow the homepage sample, so confirm them against the API reference for each sport you cover. You can also use the SDKs if you would rather not write fetch calls by hand, and the Sandbox is the fastest way to look at real responses.&lt;/p&gt;

&lt;p&gt;For true live behaviour, replace polling with the WebSocket API or webhooks. A custom match center that polls every few seconds works in a demo and falls over in production.&lt;/p&gt;

&lt;p&gt;Pattern 3: The hybrid (what most teams should do)&lt;/p&gt;

&lt;p&gt;Here is the opinion I'd stand behind: for most products, the right answer is hybrid.&lt;/p&gt;

&lt;p&gt;Your homepage, your hero scoreboard, your favourite-team feed and your highest-converting flows are where design, speed and your own data matter. Build those custom.&lt;/p&gt;

&lt;p&gt;Your long tail is different. A basketball game page nobody visits twice, a handball fixture, a golf leaderboard, a volleyball result: these are valuable for coverage and SEO, but they don't justify a bespoke component each. Use the pre-built match center there.&lt;/p&gt;

&lt;p&gt;This gives you full coverage across all 13 sports from day one, and you invest design effort only where it pays back.&lt;/p&gt;

&lt;p&gt;A configuration object makes the split explicit and easy to change:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// matchMode.js&lt;br&gt;
// Sports where you built your own match center. Everything else uses the widget.&lt;br&gt;
const CUSTOM_SPORTS = new Set(["football", "cricket"]);&lt;/p&gt;

&lt;p&gt;export function modeFor(sport) {&lt;br&gt;
  return CUSTOM_SPORTS.has(sport) ? "custom" : "widget";&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Add a resilient fallback&lt;/p&gt;

&lt;p&gt;Here is the step that separates a professional hybrid from a fragile one. If the widget script fails to load (an ad blocker, a network blip, a blocked origin), your page should still show something useful. Fall back to your own simple score header, which you already built.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// mountMatch.js&lt;br&gt;
import { renderMatchHeader } from "./matchHeader.js";&lt;br&gt;
import { modeFor } from "./matchMode.js";&lt;/p&gt;

&lt;p&gt;function loadScript(src) {&lt;br&gt;
  return new Promise((resolve, reject) =&amp;gt; {&lt;br&gt;
    const s = document.createElement("script");&lt;br&gt;
    s.src = src;&lt;br&gt;
    s.async = true;&lt;br&gt;
    s.onload = resolve;&lt;br&gt;
    s.onerror = () =&amp;gt; reject(new Error("Widget script failed"));&lt;br&gt;
    document.head.append(s);&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function withTimeout(promise, ms) {&lt;br&gt;
  return Promise.race([&lt;br&gt;
    promise,&lt;br&gt;
    new Promise((_, reject) =&amp;gt;&lt;br&gt;
      setTimeout(() =&amp;gt; reject(new Error("Widget timeout")), ms)&lt;br&gt;
    ),&lt;br&gt;
  ]);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;async function renderCustom(slot, sport, matchId) {&lt;br&gt;
  const res = await fetch(&lt;code&gt;/api/${sport}/live&lt;/code&gt;);&lt;br&gt;
  const matches = await res.json();&lt;br&gt;
  const match = (matches.data ?? matches).find((m) =&amp;gt; m.match_id === matchId);&lt;br&gt;
  if (match) renderMatchHeader(slot, match);&lt;br&gt;
  else slot.textContent = "Match details are not available right now.";&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export async function mountMatch(slot) {&lt;br&gt;
  const { sport, matchId } = slot.dataset;&lt;/p&gt;

&lt;p&gt;if (modeFor(sport) === "custom") {&lt;br&gt;
    return renderCustom(slot, sport, matchId);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;try {&lt;br&gt;
    // PLACEHOLDER: use the real widget script URL from the Orbistats docs&lt;br&gt;
    await withTimeout(loadScript("WIDGET_SCRIPT_URL_FROM_DOCS"), 4000);&lt;br&gt;
  } catch (err) {&lt;br&gt;
    console.warn("Falling back to custom header:", err.message);&lt;br&gt;
    await renderCustom(slot, sport, matchId);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The result: widgets do the heavy lifting, but if they fail, users still see the score. That one small function turns an embed from a single point of failure into a progressive enhancement.&lt;/p&gt;

&lt;p&gt;Make widgets look like they belong&lt;/p&gt;

&lt;p&gt;Whichever pattern you pick, visual consistency decides whether your embed feels native or bolted on. Check the widgets docs for the theming options that are supported, then wrap the slot with your own design tokens so that everything around the widget matches:&lt;/p&gt;

&lt;p&gt;css&lt;br&gt;
.match-center-slot {&lt;br&gt;
  --surface: rgb(20, 29, 51);&lt;br&gt;
  --border: rgb(37, 51, 90);&lt;br&gt;
  --accent: rgb(47, 107, 255);&lt;/p&gt;

&lt;p&gt;background: var(--surface);&lt;br&gt;
  border: 1px solid var(--border);&lt;br&gt;
  border-radius: 12px;&lt;br&gt;
  padding: 8px;&lt;br&gt;
  min-height: 520px;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/media"&gt;@media&lt;/a&gt; (prefers-color-scheme: light) {&lt;br&gt;
  .match-center-slot {&lt;br&gt;
    --surface: rgb(255, 255, 255);&lt;br&gt;
    --border: rgb(220, 226, 240);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Even if the widget itself has limited styling, a consistent container, spacing and heading typography around it removes most of the "this is a third-party box" feeling.&lt;/p&gt;

&lt;p&gt;SEO: do not let the widget be your only content&lt;/p&gt;

&lt;p&gt;This is the mistake that costs publishers the most. If your match page is just a title and an embed, search engines may see an almost empty page, because content rendered inside a script or frame often isn't treated as your content.&lt;/p&gt;

&lt;p&gt;The fix is simple. Render the essentials yourself as plain HTML (teams, competition, date, venue and a short summary), and describe the event with structured data:&lt;/p&gt;

&lt;p&gt;html&lt;/p&gt;


&lt;h1&gt;Manchester City vs Arsenal: Match Centre&lt;/h1&gt;
&lt;br&gt;
  &lt;p&gt;&lt;br&gt;
    Follow live score, events and statistics for Manchester City vs Arsenal,&lt;br&gt;
    Sunday 18 October 2026, kick-off 16:30 UTC.&lt;br&gt;
  &lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "SportsEvent",&lt;br&gt;
  "name": "Manchester City vs Arsenal",&lt;br&gt;
  "startDate": "2026-10-18T16:30:00Z",&lt;br&gt;
  "sport": "Football",&lt;br&gt;
  "homeTeam": { "@type": "SportsTeam", "name": "Manchester City" },&lt;br&gt;
  "awayTeam": { "@type": "SportsTeam", "name": "Arsenal" }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Now the page has real, indexable text and structured data, while the widget provides the live experience on top. For a site with thousands of match pages across multiple sports, this pattern alone can be the difference between pages that rank and pages that don't.&lt;/p&gt;

&lt;p&gt;Performance and security checklist for embeds&lt;/p&gt;

&lt;p&gt;Reserve height with min-height so the page doesn't shift when the widget loads.&lt;br&gt;
Lazy-load below-the-fold widgets with IntersectionObserver, as shown above.&lt;br&gt;
Never block rendering: use async or defer for the widget script.&lt;br&gt;
Allow the widget origin in your content security policy. Take the real origin from the docs:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Content-Security-Policy:&lt;br&gt;
  script-src 'self' &lt;a href="https://WIDGET_ORIGIN" rel="noopener noreferrer"&gt;https://WIDGET_ORIGIN&lt;/a&gt;;&lt;br&gt;
  frame-src &lt;a href="https://WIDGET_ORIGIN" rel="noopener noreferrer"&gt;https://WIDGET_ORIGIN&lt;/a&gt;;&lt;br&gt;
  connect-src 'self' &lt;a href="https://WIDGET_ORIGIN" rel="noopener noreferrer"&gt;https://WIDGET_ORIGIN&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep the API key out of the browser. Widgets handle their own access, and your custom UI should always go through your server.&lt;br&gt;
Add a visible fallback message for when scripts are blocked.&lt;br&gt;
Test with an ad blocker on, because it will happen to real users.&lt;/p&gt;

&lt;p&gt;What about cost and request limits?&lt;/p&gt;

&lt;p&gt;There is a cost dimension that rarely shows up in comparisons. With a custom UI, every visitor interaction can create load on your server and requests against your API plan, which is why caching matters so much. At the time of writing, the documentation describes 150 requests per day on the Free plan, so you cannot run a public match center on it without aggressive caching. The pricing page lays out the tiers.&lt;/p&gt;

&lt;p&gt;With widgets, you should confirm how widget traffic is counted against your plan and which plan unlocks them. Ask before you launch, not after your first traffic spike.&lt;/p&gt;

&lt;p&gt;A simple way to think about it: widgets trade design control for lower engineering and operating cost, and custom UI trades engineering cost for control.&lt;/p&gt;

&lt;p&gt;How to migrate from widget to custom without a rewrite&lt;/p&gt;

&lt;p&gt;Starting with widgets and moving later is a legitimate strategy, as long as you plan for it.&lt;/p&gt;

&lt;p&gt;Wrap the widget in your own mount function (like mountMatch above), so the rest of your code never calls the widget directly.&lt;br&gt;
Keep a stable slot element and data attributes for sport and match ID. These are the contract between your page and either implementation.&lt;br&gt;
Track real metrics on widget pages, such as engagement, time on page, bounce rate and conversion.&lt;br&gt;
Replace one sport at a time by adding it to CUSTOM_SPORTS. Because modeFor is the only switch, rollout is a one-line change per sport.&lt;br&gt;
Keep the widget as the fallback for sports you haven't customised.&lt;/p&gt;

&lt;p&gt;This is the lowest-risk path, and it lets data (not opinions) decide where to invest your design time.&lt;/p&gt;

&lt;p&gt;Common mistakes to avoid&lt;/p&gt;

&lt;p&gt;Building a custom match center for all 13 sports on day one, when only two sports drive traffic.&lt;br&gt;
Using a widget as your only page content and wondering why it doesn't rank.&lt;br&gt;
Calling the data API directly from the browser and exposing your key.&lt;br&gt;
Polling every second instead of using WebSockets or webhooks.&lt;br&gt;
Ignoring layout shift, so the page jumps as the widget loads.&lt;br&gt;
Forgetting the failure state, so a blocked script leaves a blank box.&lt;br&gt;
Choosing based on what looks fun to build instead of what the product needs.&lt;/p&gt;

&lt;p&gt;Quick recap&lt;/p&gt;

&lt;p&gt;Use the pre-built widgets when you need speed, you run editorial or content pages, you have limited front-end capacity, or you want broad coverage across many sports with little maintenance.&lt;/p&gt;

&lt;p&gt;Build a custom UI when the experience is the product, you need your own interactions and data inside the match view, or you need strict brand control.&lt;/p&gt;

&lt;p&gt;Go hybrid when you want the best of both: custom where it differentiates you, widgets for the long tail, and a fallback so users never see an empty box.&lt;/p&gt;

&lt;p&gt;Where to go next&lt;/p&gt;

&lt;p&gt;Read the documentation and the quickstart to see how auth and the first request work.&lt;br&gt;
Open the Widgets page to see the components available.&lt;br&gt;
Try real responses in the Sandbox before you commit to a custom build.&lt;br&gt;
Compare tiers on the pricing page.&lt;/p&gt;

&lt;p&gt;When you're ready, you can grab a free API key and prototype both approaches side by side. Often an hour with each tells you more than a week of debate.&lt;/p&gt;

&lt;p&gt;Over to you: are you team widget, team custom, or team hybrid? And what's the nastiest edge case your match center has ever hit? Share it in the comments, I'd love to compare notes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>frontend</category>
      <category>api</category>
    </item>
    <item>
      <title>Build a Multi-Sport Dashboard with Orbistats' 13-Sport API</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:04:23 +0000</pubDate>
      <link>https://dev.to/orbistats/build-a-multi-sport-dashboard-with-orbistats-13-sport-api-5fal</link>
      <guid>https://dev.to/orbistats/build-a-multi-sport-dashboard-with-orbistats-13-sport-api-5fal</guid>
      <description>&lt;p&gt;Disclosure: I work with the Orbistats team. The tutorial is hands-on and the code is yours to reuse, but you should know where I'm coming from.&lt;/p&gt;

&lt;p&gt;If you've ever built a sports app, you know how it starts: "We'll just add football first." Then someone asks for basketball. Then cricket. Then tennis, because the CEO watches Wimbledon. Six months later you maintain five different data providers, five different JSON shapes, and five different ways to say "this match is live."&lt;/p&gt;

&lt;p&gt;In this tutorial we'll build a multi-sport dashboard that covers 13 sports from a single API, with one auth method, one base URL, and one consistent way to ask for fixtures, live scores, standings and odds.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;p&gt;A Node.js and Express backend that keeps your API key safe and caches responses&lt;br&gt;
A single-page dashboard with a tab for each of the 13 sports&lt;br&gt;
Live scores that refresh automatically&lt;br&gt;
Standings and odds panels&lt;br&gt;
A clear path from polling to WebSockets and webhooks&lt;br&gt;
A plan for historical data (charts, backtesting, ML)&lt;/p&gt;

&lt;p&gt;No framework required. Just Node 18+ and plain JavaScript, so you can port it to React, Vue or Svelte later.&lt;/p&gt;

&lt;p&gt;What is Orbistats, and why use one API for many sports?&lt;/p&gt;

&lt;p&gt;Orbistats is a sports data and odds API platform. Instead of selling you one league or one sport, it exposes 13 sports through one REST API: Football, Basketball, American Football, Cricket, Tennis, Baseball, Esports, Combat Sports, Volleyball, Handball, Ice Hockey, Golf and Horse Racing.&lt;/p&gt;

&lt;p&gt;For a multi-sport dashboard, that matters for three reasons.&lt;/p&gt;

&lt;p&gt;First, one integration. One API key, one Authorization header, one base URL.&lt;/p&gt;

&lt;p&gt;Second, one mental model. Fixtures, results, standings, odds and statistics work the same way across sports. Only the sport segment of the URL changes.&lt;/p&gt;

&lt;p&gt;Third, one bill. You scale with traffic instead of with the number of vendors.&lt;/p&gt;

&lt;p&gt;The platform is split into products you can mix and match:&lt;/p&gt;

&lt;p&gt;The Sports Data API covers fixtures, results, standings, teams and competitions.&lt;br&gt;
The Live Scores API covers in-play scores and match events.&lt;br&gt;
The Sports Statistics API covers team and player stats.&lt;br&gt;
The Odds API gives normalized odds from multiple bookmakers.&lt;br&gt;
The Historical Sports Data API gives multi-season archives.&lt;br&gt;
The WebSocket API and Webhooks give push-based delivery.&lt;br&gt;
Widgets are drop-in UI components.&lt;/p&gt;

&lt;p&gt;The architecture&lt;/p&gt;

&lt;p&gt;Before writing code, here's the shape of what we're building:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
 Browser (dashboard)&lt;br&gt;
        |   fetch /api/...&lt;br&gt;
        v&lt;br&gt;
 Your Express server  --(cache)--&amp;gt;  Orbistats API&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hides API key                  &lt;a href="https://api.orbistats.com/v1/" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;validates sport slug&lt;/li&gt;
&lt;li&gt;normalizes JSON&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never call a paid API directly from the browser. Anyone can open DevTools and steal your key. A tiny proxy server fixes that, and it also gives us a place to add caching, which matters a lot on a free tier (more on that later).&lt;/p&gt;

&lt;p&gt;Step 0: Get your API key&lt;/p&gt;

&lt;p&gt;First, create a free account and copy your API key.&lt;/p&gt;

&lt;p&gt;Second, skim the documentation and the quickstart. Authentication is a standard bearer token:&lt;/p&gt;

&lt;p&gt;http&lt;br&gt;
Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;Third, the base URL is:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
&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;Fourth, if you want to poke at responses before writing code, use the Sandbox to fire a request and inspect the JSON.&lt;/p&gt;

&lt;p&gt;The free plan is enough for this tutorial. At the time of writing (October 2026), the pricing page lists Free at $0, Starter at $19/month for real-time production use, Growth at $79/month (adds historical data, widgets and webhooks), and a custom Enterprise tier.&lt;/p&gt;

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

&lt;p&gt;bash&lt;br&gt;
mkdir multi-sport-dashboard&lt;br&gt;
cd multi-sport-dashboard&lt;br&gt;
npm init -y&lt;br&gt;
npm install express dotenv&lt;/p&gt;

&lt;p&gt;Open package.json and add "type": "module" so we can use import statements.&lt;/p&gt;

&lt;p&gt;Create a .env file (and add it to .gitignore):&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
ORBISTATS_API_KEY=your_key_here&lt;br&gt;
PORT=3000&lt;/p&gt;

&lt;p&gt;Project structure:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
multi-sport-dashboard/&lt;br&gt;
  server.js&lt;br&gt;
  sports.js&lt;br&gt;
  normalize.js&lt;br&gt;
  public/&lt;br&gt;
    index.html&lt;br&gt;
  .env&lt;br&gt;
  package.json&lt;/p&gt;

&lt;p&gt;Step 2: A registry of all 13 sports&lt;/p&gt;

&lt;p&gt;Rather than hard-coding sports all over the app, keep one registry. Adding or removing a sport becomes a one-line change.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// sports.js&lt;br&gt;
export const SPORTS = [&lt;br&gt;
  { slug: "football",          label: "Football",          icon: "⚽" },&lt;br&gt;
  { slug: "basketball",        label: "Basketball",        icon: "🏀" },&lt;br&gt;
  { slug: "american-football", label: "American Football", icon: "🏈" },&lt;br&gt;
  { slug: "cricket",           label: "Cricket",           icon: "🏏" },&lt;br&gt;
  { slug: "tennis",            label: "Tennis",            icon: "🎾" },&lt;br&gt;
  { slug: "baseball",          label: "Baseball",          icon: "⚾" },&lt;br&gt;
  { slug: "esports",           label: "Esports",           icon: "🎮" },&lt;br&gt;
  { slug: "combat-sports",     label: "Combat Sports",     icon: "🥊" },&lt;br&gt;
  { slug: "volleyball",        label: "Volleyball",        icon: "🏐" },&lt;br&gt;
  { slug: "handball",          label: "Handball",          icon: "🤾" },&lt;br&gt;
  { slug: "ice-hockey",        label: "Ice Hockey",        icon: "🏒" },&lt;br&gt;
  { slug: "golf",              label: "Golf",              icon: "⛳" },&lt;br&gt;
  { slug: "horse-racing",      label: "Horse Racing",      icon: "🏇" },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;export const SPORT_SLUGS = new Set(SPORTS.map((s) =&amp;gt; s.slug));&lt;/p&gt;

&lt;p&gt;These slugs mirror the sport pages on the site, such as football, cricket and horse racing. Confirm each slug against the API reference when you wire it up.&lt;/p&gt;

&lt;p&gt;Step 3: The proxy server with caching&lt;/p&gt;

&lt;p&gt;This is the heart of the app. It does four jobs: attach the bearer token, validate the sport, cache responses, and return clean errors.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// server.js&lt;br&gt;
import "dotenv/config";&lt;br&gt;
import express from "express";&lt;br&gt;
import { SPORTS, SPORT_SLUGS } from "./sports.js";&lt;br&gt;
import { normalizeMatches } from "./normalize.js";&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
const BASE = "&lt;a href="https://api.orbistats.com/v1" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1&lt;/a&gt;";&lt;br&gt;
const KEY = process.env.ORBISTATS_API_KEY;&lt;/p&gt;

&lt;p&gt;if (!KEY) {&lt;br&gt;
  console.error("Missing ORBISTATS_API_KEY in .env");&lt;br&gt;
  process.exit(1);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// tiny in-memory cache&lt;br&gt;
const cache = new Map();&lt;/p&gt;

&lt;p&gt;async function orbistats(path, ttlMs = 30_000) {&lt;br&gt;
  const hit = cache.get(path);&lt;br&gt;
  if (hit &amp;amp;&amp;amp; Date.now() - hit.at &amp;lt; ttlMs) return hit.data;&lt;/p&gt;

&lt;p&gt;const res = await fetch(&lt;code&gt;${BASE}${path}&lt;/code&gt;, {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${KEY}&lt;/code&gt;, Accept: "application/json" },&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;if (!res.ok) {&lt;br&gt;
    const err = new Error(&lt;code&gt;Orbistats ${res.status} on ${path}&lt;/code&gt;);&lt;br&gt;
    err.status = res.status;&lt;br&gt;
    throw err;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;const data = await res.json();&lt;br&gt;
  cache.set(path, { at: Date.now(), data });&lt;br&gt;
  return data;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// validate :sport on every route&lt;br&gt;
app.param("sport", (req, res, next, sport) =&amp;gt; {&lt;br&gt;
  if (!SPORT_SLUGS.has(sport)) {&lt;br&gt;
    return res.status(404).json({ error: &lt;code&gt;Unsupported sport: ${sport}&lt;/code&gt; });&lt;br&gt;
  }&lt;br&gt;
  next();&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// routes&lt;br&gt;
app.get("/api/sports", (_req, res) =&amp;gt; res.json(SPORTS));&lt;/p&gt;

&lt;p&gt;app.get("/api/:sport/live", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const raw = await orbistats(&lt;code&gt;/${req.params.sport}/matches/live&lt;/code&gt;, 15_000);&lt;br&gt;
    res.json(normalizeMatches(raw));&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.get("/api/:sport/fixtures", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const raw = await orbistats(&lt;code&gt;/${req.params.sport}/fixtures&lt;/code&gt;, 300_000);&lt;br&gt;
    res.json(normalizeMatches(raw));&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.get("/api/:sport/standings", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    res.json(await orbistats(&lt;code&gt;/${req.params.sport}/standings&lt;/code&gt;, 600_000));&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// NOTE: confirm the exact odds path and params in the API reference.&lt;br&gt;
app.get("/api/:sport/odds", async (req, res, next) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const q = new URLSearchParams({ match_id: String(req.query.match_id || "") });&lt;br&gt;
    res.json(await orbistats(&lt;code&gt;/${req.params.sport}/odds?${q}&lt;/code&gt;, 20_000));&lt;br&gt;
  } catch (e) { next(e); }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// error handler&lt;br&gt;
app.use((err, _req, res, _next) =&amp;gt; {&lt;br&gt;
  const status = err.status || 500;&lt;br&gt;
  const message =&lt;br&gt;
    status === 429 ? "Rate limit reached. Try again shortly." : err.message;&lt;br&gt;
  res.status(status).json({ error: message });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.use(express.static("public"));&lt;br&gt;
app.listen(process.env.PORT || 3000, () =&amp;gt;&lt;br&gt;
  console.log(&lt;code&gt;Dashboard running on http://localhost:${process.env.PORT || 3000}&lt;/code&gt;)&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;Notice the different cache lifetimes. Live scores get 15 seconds, fixtures get 5 minutes, standings get 10 minutes. Match your cache to how fast the data actually changes. This single habit will save you most of your request budget.&lt;/p&gt;

&lt;p&gt;Step 4: One normalizer for 13 very different sports&lt;/p&gt;

&lt;p&gt;Here's the part of multi-sport apps that usually hurts. A football match is Home vs Away. A tennis match is Player vs Player. A golf tournament has a field of 150 players. A horse race has runners. If your UI assumes every event has a home and an away, it will break on golf and horse racing on day one.&lt;/p&gt;

&lt;p&gt;So we translate everything into a small UI-friendly shape in one place:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// normalize.js&lt;/p&gt;

&lt;p&gt;// The live-scores sample on the Orbistats homepage looks like:&lt;br&gt;
// { match_id, status, minute, home: { name, score }, away: { name, score } }&lt;br&gt;
// Adjust the field names below to match the API reference for each sport.&lt;/p&gt;

&lt;p&gt;function unwrap(raw) {&lt;br&gt;
  if (Array.isArray(raw)) return raw;&lt;br&gt;
  return raw?.data ?? raw?.matches ?? raw?.fixtures ?? raw?.results ?? [];&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function normalizeMatches(raw) {&lt;br&gt;
  return unwrap(raw).map((m) =&amp;gt; {&lt;br&gt;
    const hasSides = m.home &amp;amp;&amp;amp; m.away;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return {
  id: m.match_id ?? m.id,
  status: m.status ?? "scheduled",
  clock: m.minute != null ? `${m.minute}'` : (m.period ?? ""),
  kickoff: m.start_time ?? m.kickoff ?? null,
  title: hasSides
    ? `${m.home.name} vs ${m.away.name}`
    : (m.name ?? m.event_name ?? "Event"),
  home: hasSides ? { name: m.home.name, score: m.home.score ?? "-" } : null,
  away: hasSides ? { name: m.away.name, score: m.away.score ?? "-" } : null,
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;});&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The UI can now render a "versus" card when home and away exist, and a simple event card when they don't (golf, horse racing, some combat sports cards). Every new sport becomes a small mapping change instead of a rewrite.&lt;/p&gt;

&lt;p&gt;Step 5: The front-end dashboard&lt;/p&gt;

&lt;p&gt;One HTML file. No build step.&lt;/p&gt;

&lt;p&gt;html&lt;/p&gt;



&lt;p&gt;&amp;lt;!doctype html&amp;gt;&lt;br&gt;
&lt;br&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  &lt;/p&gt;
Multi-Sport Dashboard
&lt;br&gt;
  &amp;lt;br&amp;gt;
    :root { color-scheme: dark; }&amp;lt;br&amp;gt;
    body { margin: 0; font-family: system-ui, sans-serif;&amp;lt;br&amp;gt;
           background: rgb(11,18,32); color: rgb(232,238,252); }&amp;lt;br&amp;gt;
    header { padding: 16px 24px; border-bottom: 1px solid rgb(31,42,68); }&amp;lt;br&amp;gt;
    nav { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 24px; }&amp;lt;br&amp;gt;
    nav button { background: rgb(20,29,51); color: inherit;&amp;lt;br&amp;gt;
                 border: 1px solid rgb(37,51,90);&amp;lt;br&amp;gt;
                 padding: 8px 12px; border-radius: 999px; cursor: pointer; }&amp;lt;br&amp;gt;
    nav button.active { background: rgb(47,107,255); border-color: rgb(47,107,255); }&amp;lt;br&amp;gt;
    main { display: grid; gap: 16px; padding: 24px;&amp;lt;br&amp;gt;
           grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }&amp;lt;br&amp;gt;
    .card { background: rgb(20,29,51); border: 1px solid rgb(37,51,90);&amp;lt;br&amp;gt;
            border-radius: 12px; padding: 14px; }&amp;lt;br&amp;gt;
    .status { font-size: 12px; opacity: .7; text-transform: uppercase; }&amp;lt;br&amp;gt;
    .live { color: rgb(255,91,91); opacity: 1; }&amp;lt;br&amp;gt;
    .row { display: flex; justify-content: space-between; margin-top: 6px; }&amp;lt;br&amp;gt;
    .note { padding: 0 24px; opacity: .7; }&amp;lt;br&amp;gt;
  &lt;br&gt;
&lt;br&gt;
&lt;br&gt;
  &lt;h1&gt;🌍 Multi-Sport Dashboard&lt;/h1&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  


  const tabs = document.getElementById("tabs");
  const grid = document.getElementById("grid");
  const msg  = document.getElementById("msg");
  let current = "football";
  let timer;

  async function getJSON(url) {
    const r = await fetch(url);
    const body = await r.json();
    if (!r.ok) throw new Error(body.error || "Request failed");
    return body;
  }

  function card(m) {
    const el = document.createElement("div");
    el.className = "card";

    const s = document.createElement("div");
    s.className = "status" + (m.status === "live" ? " live" : "");
    s.textContent = m.status === "live" ? `● LIVE ${m.clock}` : m.status;
    el.append(s);

    if (m.home &amp;amp;&amp;amp; m.away) {
      for (const side of [m.home, m.away]) {
        const row = document.createElement("div");
        row.className = "row";
        const name = document.createElement("span");
        name.textContent = side.name;
        const score = document.createElement("strong");
        score.textContent = side.score;
        row.append(name, score);
        el.append(row);
      }
    } else {
      const t = document.createElement("div");
      t.className = "row";
      t.textContent = m.title;
      el.append(t);
    }
    return el;
  }

  async function load() {
    msg.textContent = "Loading...";
    try {
      let matches = await getJSON(`/api/${current}/live`);
      let label = "Live now";
      if (!matches.length) {
        matches = await getJSON(`/api/${current}/fixtures`);
        label = "Upcoming";
      }
      msg.textContent = `${label} - ${matches.length} items`;
      grid.replaceChildren(...matches.slice(0, 24).map(card));
    } catch (e) {
      msg.textContent = e.message;
      grid.replaceChildren();
    }
  }

  function select(slug) {
    current = slug;
    for (const b of tabs.children) b.classList.toggle("active", b.dataset.slug === slug);
    clearInterval(timer);
    load();
    timer = setInterval(load, 30_000);
  }

  (async () =&amp;gt; {
    const sports = await getJSON("/api/sports");
    for (const s of sports) {
      const b = document.createElement("button");
      b.dataset.slug = s.slug;
      b.textContent = `${s.icon} ${s.label}`;
      b.onclick = () =&amp;gt; select(s.slug);
      tabs.append(b);
    }
    select(current);
  })();




&lt;p&gt;Run it:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
node server.js&lt;/p&gt;

&lt;p&gt;Then open &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt; in your browser.&lt;/p&gt;

&lt;p&gt;Two details worth copying into your own projects.&lt;/p&gt;

&lt;p&gt;We only poll the active tab. If you polled all 13 sports every 30 seconds you'd burn through a free tier in minutes. Fetch what the user is looking at, nothing more.&lt;/p&gt;

&lt;p&gt;We use textContent, not innerHTML. Team names come from an external source. Treating them as text means you never have to think about injection.&lt;/p&gt;

&lt;p&gt;Step 6: Add standings and odds&lt;/p&gt;

&lt;p&gt;Standings are one more route away (we already built /api/:sport/standings). A simple loader:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
async function showStandings(slug) {&lt;br&gt;
  const data = await getJSON(&lt;code&gt;/api/${slug}/standings&lt;/code&gt;);&lt;br&gt;
  const rows = Array.isArray(data) ? data : (data.data ?? []);&lt;br&gt;
  console.table(rows.slice(0, 10)); // replace with your own table component&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Odds are where a multi-sport API really earns its keep. Every bookmaker formats odds differently, and normally you'd write a separate parser for each one. The Odds API returns normalized markets in one consistent schema (1X2, moneyline, spreads, totals and more), so a market looks like this:&lt;/p&gt;

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

&lt;p&gt;A tiny helper turns those decimal odds into an implied probability, which is far more useful on a dashboard than a raw number:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// decimal odds to implied probability (percent)&lt;br&gt;
export const implied = (odds) =&amp;gt; +(100 / odds).toFixed(1);&lt;/p&gt;

&lt;p&gt;const m = { home: 1.91, draw: 3.40, away: 4.20 };&lt;br&gt;
const probs = Object.fromEntries(&lt;br&gt;
  Object.entries(m).map(([k, v]) =&amp;gt; [k, implied(v)])&lt;br&gt;
);&lt;br&gt;
// { home: 52.4, draw: 29.4, away: 23.8 }&lt;/p&gt;

&lt;p&gt;const overround = Object.values(probs).reduce((a, b) =&amp;gt; a + b, 0);&lt;br&gt;
// about 105.6, which is the bookmaker margin baked into the market&lt;/p&gt;

&lt;p&gt;If the three probabilities add up to more than 100 percent, the extra is the bookmaker's margin. Showing that number is a cheap way to make your dashboard feel genuinely analytical.&lt;/p&gt;

&lt;p&gt;A responsible note: odds data is for analytics, comparison and research. If you build anything that touches betting, check the licensing and legal rules for every market you serve.&lt;/p&gt;

&lt;p&gt;Step 7: From polling to real time&lt;/p&gt;

&lt;p&gt;Polling every 15 to 30 seconds is fine for a prototype. For genuinely live experiences, you want the server to push updates to you. Orbistats offers two ways.&lt;/p&gt;

&lt;p&gt;Option A: WebSockets&lt;/p&gt;

&lt;p&gt;A WebSocket is one persistent connection that stays open, so updates arrive the moment they happen instead of waiting for your next poll:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
REST:       request, response, request, response, ...&lt;br&gt;
WebSocket:  open once, update, update, update, ...&lt;/p&gt;

&lt;p&gt;A minimal Node client skeleton (get the exact URL, subscription message and format from the WebSocket docs):&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
// realtime.js&lt;br&gt;
import "dotenv/config";&lt;br&gt;
import WebSocket from "ws"; // npm install ws&lt;/p&gt;

&lt;p&gt;const ws = new WebSocket(process.env.ORBISTATS_WS_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;ws.on("open", () =&amp;gt; {&lt;br&gt;
  // Subscribe using the message format from the WebSocket docs&lt;br&gt;
  ws.send(JSON.stringify({ action: "subscribe", sport: "football" }));&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;ws.on("message", (buf) =&amp;gt; {&lt;br&gt;
  const event = JSON.parse(buf.toString());&lt;br&gt;
  console.log("update:", event);&lt;br&gt;
  // forward to browsers (see Server-Sent Events below)&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;ws.on("close", () =&amp;gt; setTimeout(() =&amp;gt; process.exit(1), 1000)); // let a supervisor restart&lt;/p&gt;

&lt;p&gt;In production, add automatic reconnect with exponential backoff and re-subscribe on reconnect.&lt;/p&gt;

&lt;p&gt;Option B: Webhooks&lt;/p&gt;

&lt;p&gt;With Webhooks, Orbistats calls your server when something happens, for example a goal:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Goal scored -&amp;gt; Orbistats -&amp;gt; POST /webhook/sports -&amp;gt; your server -&amp;gt; your UI&lt;br&gt;
js&lt;br&gt;
app.post("/webhook/sports", express.json(), (req, res) =&amp;gt; {&lt;br&gt;
  // 1. Verify the request is really from Orbistats (see the webhook docs)&lt;br&gt;
  // 2. Update your store or notify clients&lt;br&gt;
  console.log("event:", req.body);&lt;br&gt;
  res.sendStatus(200); // respond fast, do heavy work asynchronously&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Webhooks are ideal for notifications and background jobs. WebSockets are ideal for live UI.&lt;/p&gt;

&lt;p&gt;Getting pushes into the browser&lt;/p&gt;

&lt;p&gt;Once your server receives real-time events, forward them to the dashboard with Server-Sent Events, which are much simpler than running a second WebSocket layer for the browser:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const clients = new Set();&lt;/p&gt;

&lt;p&gt;app.get("/stream", (req, res) =&amp;gt; {&lt;br&gt;
  res.set({&lt;br&gt;
    "Content-Type": "text/event-stream",&lt;br&gt;
    "Cache-Control": "no-cache",&lt;br&gt;
    Connection: "keep-alive",&lt;br&gt;
  });&lt;br&gt;
  res.flushHeaders();&lt;br&gt;
  clients.add(res);&lt;br&gt;
  req.on("close", () =&amp;gt; clients.delete(res));&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;export function broadcast(event) {&lt;br&gt;
  for (const c of clients) c.write(&lt;code&gt;data: ${JSON.stringify(event)}\n\n&lt;/code&gt;);&lt;br&gt;
}&lt;br&gt;
js&lt;br&gt;
// in index.html&lt;br&gt;
const es = new EventSource("/stream");&lt;br&gt;
es.onmessage = (e) =&amp;gt; { /* update the matching card */ };&lt;/p&gt;

&lt;p&gt;Now each live card updates the moment a score changes, with no polling.&lt;/p&gt;

&lt;p&gt;Step 8: Add history for charts and models&lt;/p&gt;

&lt;p&gt;A dashboard that only shows "now" is a scoreboard. One that shows how we got here is a product. The Historical Sports Data API provides multi-season archives (fixtures, results, statistics, lineups, events and closing odds, with seasons going back as far as 2010 for covered data), which unlocks things like season form charts per team, head-to-head history on every match card, closing-odds comparisons, and datasets for backtesting and machine learning.&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Historical data -&amp;gt; feature engineering -&amp;gt; model -&amp;gt; predictions -&amp;gt; your dashboard&lt;/p&gt;

&lt;p&gt;If you want team and player numbers like possession, shots, goals, assists or xG on each match page, pair this with the Sports Statistics API.&lt;/p&gt;

&lt;p&gt;Step 9: The shortcut, drop-in widgets&lt;/p&gt;

&lt;p&gt;Sometimes you don't want to build the UI at all. If you're a publisher who just needs a live score box or a match center on an article page, Orbistats Widgets let you embed ready-made components instead of writing the front-end yourself. Build the custom dashboard where it differentiates your product, and use widgets everywhere else.&lt;/p&gt;

&lt;p&gt;Staying inside your rate limits&lt;/p&gt;

&lt;p&gt;This is where good multi-sport apps and sad multi-sport apps part ways. The docs list 150 requests per day on the Free plan, so treat requests as a budget.&lt;/p&gt;

&lt;p&gt;Cache by data type (15 seconds for live, 5 to 10 minutes for standings), because most data doesn't change every second.&lt;br&gt;
Poll only the active tab: one sport at a time, not 13.&lt;br&gt;
Pause polling when the browser tab is hidden (document.hidden), so you don't make requests for nobody.&lt;br&gt;
Use WebSockets or webhooks for live data, because one connection beats thousands of polls.&lt;br&gt;
Handle 429 responses gracefully: show a friendly message, back off and retry.&lt;/p&gt;

&lt;p&gt;The in-memory cache above also means that if ten users open the football tab at once, you make one upstream request, not ten.&lt;/p&gt;

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

&lt;p&gt;Before you ship this beyond localhost:&lt;/p&gt;

&lt;p&gt;Keep the API key in environment variables, never in front-end code.&lt;br&gt;
Replace the in-memory cache with Redis if you run more than one server.&lt;br&gt;
Add retry with exponential backoff for 5xx and 429 responses.&lt;br&gt;
Verify webhook signatures.&lt;br&gt;
Reconnect WebSockets automatically.&lt;br&gt;
Pin to /v1/. Orbistats uses versioned paths, so breaking changes would ship under a new version rather than silently changing yours.&lt;br&gt;
Check the status page and changelog as part of your monitoring routine.&lt;br&gt;
Read the data licensing terms if you'll display data commercially.&lt;/p&gt;

&lt;p&gt;Where to go next&lt;/p&gt;

&lt;p&gt;Browse the full API reference and the SDKs if you'd rather not hand-roll fetch calls. Copy patterns from the examples page. Compare plans on the pricing page when you're ready for real-time production traffic.&lt;/p&gt;

&lt;p&gt;A few ideas to extend this project:&lt;/p&gt;

&lt;p&gt;Favorites: let users pin teams across sports and build a personal "my teams" feed.&lt;br&gt;
Match center page: click a card to see events, lineups and stats.&lt;br&gt;
Odds movement chart: poll odds and plot line movement over time.&lt;br&gt;
Alerts: use webhooks to send a Telegram or email notification on goals and final results.&lt;br&gt;
Sport-specific layouts: a leaderboard view for golf, a runner list for horse racing, a set-by-set view for tennis.&lt;/p&gt;

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

&lt;p&gt;The biggest lesson from building multi-sport products is that the hard part isn't the sports, it's the plumbing: auth, schemas, caching, rate limits and real-time delivery. When you get 13 sports from one API, one auth method and one schema, most of that plumbing disappears and you get to spend your time on the product.&lt;/p&gt;

&lt;p&gt;You can grab a free API key here and have your first live scoreboard running in under an hour.&lt;/p&gt;

&lt;p&gt;Over to you: which sport would you add first, and what's the weirdest edge case you've hit when mixing sports in one UI (I'm looking at you, golf)? Drop it in the comments. 👇&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Getting Your First Orbistats API Key: A 5-Minute Quickstart Walkthrough</title>
      <dc:creator>orbistats</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:58:29 +0000</pubDate>
      <link>https://dev.to/orbistats/getting-your-first-orbistats-api-key-a-5-minute-quickstart-walkthrough-3a70</link>
      <guid>https://dev.to/orbistats/getting-your-first-orbistats-api-key-a-5-minute-quickstart-walkthrough-3a70</guid>
      <description>&lt;p&gt;Most "quickstart" guides skip the part that actually trips people up: where exactly to click, what the key looks like, where to put it, and what the very first error message means when you get it wrong. This is a genuinely five-minute walkthrough — sign up, get the key, make one real request, understand your limits, and know where to go next.&lt;/p&gt;

&lt;p&gt;What You'll Need&lt;br&gt;
Nothing but a terminal and five minutes&lt;br&gt;
No credit card — Orbistats' free tier doesn't require one&lt;br&gt;
Optional: Node.js or Python if you want to run the code examples instead of just curl&lt;br&gt;
Step 1: Create a Free Account&lt;/p&gt;

&lt;p&gt;Go to the sign-up page and create an account. This is a plain email/password-style signup, not a sales form — you land straight in a dashboard, not a "someone will contact you" queue, which is the main thing that separates a self-serve platform like Orbistats from enterprise-only providers in this space.&lt;/p&gt;

&lt;p&gt;Once you're in, you'll have access to the login page for future sessions and a dashboard where your API key lives.&lt;/p&gt;

&lt;p&gt;Step 2: Copy Your API Key&lt;/p&gt;

&lt;p&gt;Your key will be a long alphanumeric string tied to your account's plan and rate limit. Copy it from the dashboard — but before you paste it anywhere, read this next part carefully, because it's the step almost every first-time tutorial gets wrong.&lt;/p&gt;

&lt;p&gt;Don't Hardcode It&lt;/p&gt;

&lt;p&gt;Never paste your key directly into a script you might commit to Git, share in a screenshot, or paste into a public gist. Store it as an environment variable instead:&lt;/p&gt;

&lt;p&gt;macOS or Linux:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
export ORBISTATS_API_KEY="your_key_here"&lt;/p&gt;

&lt;p&gt;Windows PowerShell:&lt;/p&gt;

&lt;p&gt;powershell&lt;br&gt;
$env:ORBISTATS_API_KEY = "your_key_here"&lt;/p&gt;

&lt;p&gt;Or in a .env file (most projects use this for local dev):&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  .env — add this file to .gitignore
&lt;/h1&gt;

&lt;p&gt;ORBISTATS_API_KEY=your_key_here&lt;/p&gt;

&lt;p&gt;This isn't a minor hygiene tip — a leaked key on a public GitHub repo gets scraped and abused within hours in practice across most API providers, and you'll find out when your quota is suddenly gone or your account gets flagged.&lt;/p&gt;

&lt;p&gt;Step 3: Understand the Authentication Pattern&lt;/p&gt;

&lt;p&gt;Orbistats uses a standard Bearer token in the Authorization header — the same pattern you'll recognize from most modern REST APIs:&lt;/p&gt;

&lt;p&gt;Authorization: Bearer YOUR_API_KEY&lt;/p&gt;

&lt;p&gt;That's it. No OAuth handshake, no signed-request complexity for basic REST calls. Full details live in the API documentation and the API reference if you want every endpoint's exact parameters.&lt;/p&gt;

&lt;p&gt;Step 4: Make Your First Request&lt;/p&gt;

&lt;p&gt;Here's the moment that matters — your first real call. We'll pull today's football fixtures:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
curl &lt;a href="https://api.orbistats.com/v1/football/fixtures" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/fixtures&lt;/a&gt; \&lt;br&gt;
  -H "Authorization: Bearer $ORBISTATS_API_KEY"&lt;/p&gt;

&lt;p&gt;If everything's set up right, you'll get back JSON with fixture data — match IDs, team names, kickoff times, competition info. If you see a 401, your key or header is wrong; if you see a 403, double-check you're not accidentally sending an expired or revoked key.&lt;/p&gt;

&lt;p&gt;Same Request in JavaScript&lt;br&gt;
javascript&lt;br&gt;
const API_KEY = process.env.ORBISTATS_API_KEY;&lt;/p&gt;

&lt;p&gt;async function getFixtures() {&lt;br&gt;
  const res = await fetch("&lt;a href="https://api.orbistats.com/v1/football/fixtures" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/fixtures&lt;/a&gt;", {&lt;br&gt;
    headers: { Authorization: &lt;code&gt;Bearer ${API_KEY}&lt;/code&gt; },&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;if (!res.ok) {&lt;br&gt;
    throw new Error(&lt;code&gt;Request failed: ${res.status} ${res.statusText}&lt;/code&gt;);&lt;br&gt;
  }&lt;/p&gt;

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

&lt;p&gt;getFixtures().then(console.log).catch(console.error);&lt;br&gt;
Same Request in Python&lt;br&gt;
python&lt;br&gt;
import os&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;API_KEY = os.environ["ORBISTATS_API_KEY"]&lt;/p&gt;

&lt;p&gt;response = requests.get(&lt;br&gt;
    "&lt;a href="https://api.orbistats.com/v1/football/fixtures" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/football/fixtures&lt;/a&gt;",&lt;br&gt;
    headers={"Authorization": f"Bearer {API_KEY}"},&lt;br&gt;
    timeout=10,&lt;br&gt;
)&lt;br&gt;
response.raise_for_status()&lt;br&gt;
print(response.json())&lt;/p&gt;

&lt;p&gt;Three languages, same shape: set a header, hit a URL, read JSON back. That consistency is deliberate — Orbistats documents the same /v1/{sport}/{resource} pattern across every sport it covers.&lt;/p&gt;

&lt;p&gt;Step 5: Try a Different Sport (Same Shape, Different Path)&lt;/p&gt;

&lt;p&gt;This is where the consistent schema actually pays off. Want basketball instead of football? Change one path segment:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
curl &lt;a href="https://api.orbistats.com/v1/basketball/fixtures" rel="noopener noreferrer"&gt;https://api.orbistats.com/v1/basketball/fixtures&lt;/a&gt; \&lt;br&gt;
  -H "Authorization: Bearer $ORBISTATS_API_KEY"&lt;/p&gt;

&lt;p&gt;Orbistats currently documents coverage across 13 sports: Football, Basketball, American Football, Cricket, Tennis, Baseball, Esports, Combat Sports, Volleyball, Handball, Ice Hockey, Golf and Horse Racing. Each has its own sport landing page listing coverage specifics, and the same resource pattern (fixtures, results, standings, odds, statistics) applies across all of them, with sport-specific data models underneath.&lt;/p&gt;

&lt;p&gt;Step 6: Know Your Rate Limit Before You Build Anything&lt;/p&gt;

&lt;p&gt;This is the step that saves you a frustrating afternoon later. The free tier has a daily request cap — Orbistats' documentation has described it as roughly 150 requests per day, though you should always check the live pricing page for the current number, since limits and tiers do get revised.&lt;/p&gt;

&lt;p&gt;Do the math before you design a feature:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def requests_per_day_needed(poll_interval_seconds: int, hours_active: int = 24) -&amp;gt; int:&lt;br&gt;
    return (hours_active * 3600) // poll_interval_seconds&lt;/p&gt;

&lt;h1&gt;
  
  
  Polling every 30 seconds, all day
&lt;/h1&gt;

&lt;p&gt;print(requests_per_day_needed(30))   # 2,880 — way over a 150/day free tier&lt;/p&gt;

&lt;p&gt;If your use case needs anything close to live polling, the free tier's daily cap will not get you there — that's exactly what the WebSocket API and Webhooks API exist for, both of which push updates instead of making you poll. Paid tiers (Starter at $19/month, Growth at $79/month per the last published pricing — verify current numbers) raise the daily request ceiling and unlock real-time data, history and streaming delivery.&lt;/p&gt;

&lt;p&gt;Step 7: Explore Without Spending a Request&lt;/p&gt;

&lt;p&gt;Before you wire the API into real code, it's worth seeing what other endpoints actually return. The public sandbox lets you pick an endpoint, set parameters, and inspect a real JSON response — no signup or key required for that part, which makes it the fastest way to check a response shape before you write code around it.&lt;/p&gt;

&lt;p&gt;For a wider tour of what's available beyond fixtures — odds, live scores, statistics, historical data — the documentation hub and quickstart page are the next stop, and worked examples cover common integration patterns end to end.&lt;/p&gt;

&lt;p&gt;What People Usually Build First&lt;/p&gt;

&lt;p&gt;A few common starting points once the key is working:&lt;/p&gt;

&lt;p&gt;A live scoreboard — fixtures plus live scores, often upgraded to WebSocket once polling gets expensive. I covered the full build in Build a Live Sports Scoreboard in 50 Lines of JavaScript with a WebSocket API.&lt;br&gt;
An odds comparison tool — pulling from the Odds API, which returns normalized pricing across bookmakers instead of raw per-book formats.&lt;br&gt;
A fantasy or stats dashboard — built off fixtures plus team/player statistics.&lt;br&gt;
A backtest or research script — using the Historical Sports Data API instead of live endpoints.&lt;/p&gt;

&lt;p&gt;If you're still deciding between REST polling and real-time delivery for your use case, REST API vs WebSocket: What's the Difference? (With Code) walks through the tradeoff in more detail, and Sports APIs Explained: How to Get Live Match Data is a good companion piece if you're brand new to sports APIs generally.&lt;/p&gt;

&lt;p&gt;Common First-Request Errors and Fixes&lt;br&gt;
Error   Likely cause    Fix&lt;br&gt;
401 Unauthorized    Missing or malformed Authorization header   Confirm it's exactly Bearer YOUR_KEY, no extra quotes&lt;br&gt;
403 Forbidden   Key revoked, expired, or wrong plan for that endpoint   Check your dashboard, regenerate if needed&lt;br&gt;
429 Too Many Requests   Daily/rate limit hit    Check remaining quota, implement caching&lt;br&gt;
Empty response body Wrong sport/resource path   Compare against the API reference&lt;br&gt;
Works in curl, fails in code    Env var not actually loaded echo $ORBISTATS_API_KEY to confirm it's set in that shell/process&lt;br&gt;
Checklist Before You Build Anything Real&lt;br&gt;
 Key stored as an environment variable, never hardcoded or committed&lt;br&gt;
 First request confirmed working via curl before writing application code&lt;br&gt;
 You know your daily/rate limit and have roughly calculated your usage pattern against it&lt;br&gt;
 You've checked whether your use case needs WebSocket/webhooks instead of REST polling&lt;br&gt;
 You've looked at the sandbox to see response shapes for endpoints beyond fixtures&lt;br&gt;
 You've read the current pricing page rather than assuming last month's numbers still apply&lt;br&gt;
Where to Go Next&lt;/p&gt;

&lt;p&gt;Once your first request works, the natural next steps are: pick a real feature to build (scoreboard, odds tool, stats dashboard), decide REST vs. WebSocket/webhooks based on how live you need the data, and keep an eye on the changelog and status page as you move toward production. The developer hub is the single best bookmark for everything covered here.&lt;/p&gt;

&lt;p&gt;Wrapping Up&lt;/p&gt;

&lt;p&gt;Five minutes, three steps that actually matter: sign up, store the key safely, make one request to confirm it works. Everything after that — which sport, which resource, REST or WebSocket — is a decision you can make once you've actually seen real data come back, not before. If you hit an error that isn't in the table above, drop it in the comments and I'll help debug it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
