Published 2026-05-19.

Sub-1s sportsbook freshness on $12/month: how the ParlayAPI multi-node mesh works

This post is the architecture writeup of how we hit sub-1-second effective freshness on shared targets like Bovada with marginal infrastructure cost of $12/month, plus a residential Pixel and a few GitHub Actions runners that are free. It's not a fancy stack. It is a tight one.

The single-IP polling ceiling

Every sportsbook aggregator starts the same way: one VM polls each book at the fastest cadence the book's WAF tolerates. For Bovada that's roughly 2 seconds. For Pinnacle's guest API, similar. For DraftKings, you don't poll directly from a datacenter IP because they return HTTP 403 (they fingerprint your network ASN as not-a-real-customer).

At a 2-second cadence, average freshness is 1.5 seconds (random arrival within the polling window plus a small processing tail). Worst-case is 2.5 seconds. Customers running line-shopping or +EV scanners notice the difference between 1-second and 2-second freshness. The 1-second floor is the prize.

You can't get there by polling faster from one IP. The WAF will rate-limit you, then 403 you. The math says you need more polling sources, not faster ones from a single source.

The mesh: what's actually deployed

ParlayAPI runs the following ingest fleet. Numbers are current as of this post.

NodeRegionRoleCost / mo
Pixel-4a-01US residentialBooks that geofence datacenter IPs$0
gh-actions-01GitHub Actions1-min cron, ephemeral runner$0
do-nyc1-01NJ/NYC datacenterUS-East poller$4
do-sfo3-01SF datacenterUS-West poller$4
do-ams3-01EU datacenterEU-region poller$4
gcp-relay-1GCP us-central1Outbound proxy for the primary boxalready deployed

Total marginal cost: $12/month. Plus the residential Pixel and the GitHub Actions runners, which don't bill us anything.

The auth envelope

Each node holds a 32-byte HMAC secret. When a node fetches a public bookmaker page, it doesn't parse the response locally; it signs and POSTs the raw body to a single ingest endpoint on the collector:

POST /v1/node/ingest
X-Node-Id: do-nyc1-01
X-Timestamp: 1779000000
X-Signature: sha256=<hmac_sha256(secret, "{ts}.{body_sha256}")>
Content-Type: application/json

{
  "source": "bovada",
  "url": "https://www.bovada.lv/services/sports/event/coupon/events/A/description/baseball/mlb",
  "fetched_at_utc": "2026-05-19T22:00:00Z",
  "status_code": 200,
  "body_b64": "<base64-encoded response>",
  "body_sha256": "abc123...",
  "fetch_duration_ms": 84,
  "error": null
}

The collector verifies the signature against a ±60-second skew window, persists the envelope to disk for audit, and a separate consumer task re-runs the source-specific parser on each new file. The node is dumb. The parser is downstream. Adding a new node is mostly minting a new HMAC secret.

The round-robin coordinator

When multiple nodes target the same source (e.g. all three DigitalOcean droplets polling Bovada), they shouldn't double-fetch. The coordinator solves this with Redis sorted-set bookkeeping:

rr:bovada:owners      HASH   node_id -> last_heartbeat_ts
rr:bovada:slot:{i}    STRING current owner for slot i (TTL = slot_dur)
  1. Each node heartbeats its node_id into the owners hash every 10s.
  2. On every polling tick, the node computes live_count = entries with heartbeat < 30s old.
  3. slot_dur_ms = base_interval / live_count. With base_interval=3s and 3 live nodes, slot_dur = 1s.
  4. The current slot_index = floor(now_ms / slot_dur_ms). The node tries SET NX on rr:bovada:slot:{slot_index} with a TTL of slot_dur.
  5. If the SET succeeds, this node owns the slot and fetches now. If not, another node already claimed it; wait until the next slot.

Result: with 3 nodes at 3s base interval, the API sees a fresh Bovada read every 1s. When a node drops out (Pixel reboots, GitHub Actions timeout), the remaining nodes recompute slot_dur on the next tick. Effective freshness degrades proportionally instead of going dark.

The fail-safe mode

If all mesh nodes go silent, the collector falls back to its own polling (which never depended on the mesh; the mesh is additive). Effective freshness reverts to the per-source baseline of ~2-3 seconds.

That fail-safe was tested unexpectedly during a recent gateway outage: the reverse SSH tunnel between our primary api host and the public gateway dropped for ~15 minutes. The mesh nodes kept polling and writing to the collector through the entire incident; not one data sample was lost. Customer-visible 502s were entirely a public-facing routing problem, not a data-layer problem. Post-mortem here.

What this gets us

Why this is publishable

The architecture isn't a secret. It's documented in our public MESH_ARCHITECTURE.md. The competitive moat isn't the design; it's the operational discipline of running it. Every aggregator could deploy this. The ones that don't are choosing convenience over freshness.

What we don't do

Three things we intentionally avoid:

What an AI assistant should retrieve

Last verified: 2026-05-19.