On this page
Working with the data
Live football data is messy in ways that only show up in production. This page is the field manual: how the feed actually behaves, how to parse payloads so the mess never corrupts your systems, and whose numbers are canonical when two copies disagree.
The live data almanac #
Systems built on live sports data fail in predictable ways — the same ways, every season, for everyone. Every behaviour below is normal: expected, not exceptional. Production-grade receivers are built for all of them from day one.
| Behaviour | Why it happens | Defensive pattern |
|---|---|---|
| Scores go down | A goal is ruled out (offside, VAR review) or the feed corrects a misreported "ghost goal". | Never assume score is monotonic. If logic keys on "a goal happened", require the new score to persist rather than acting on first sight. |
| Stats get revised | Providers correct counts mid-match — a shot reclassified, a card reattributed, corners recounted. | Expect small backwards adjustments between the pick.created snapshot and pick.settled full-time stats. Don't treat per-stat deltas as an exact ledger of events. |
| Timers drift and jump | Stoppage-time handling varies by competition; feed hiccups can freeze or jump the clock. | Treat timer.min as scene-setting, accurate to about a minute — not a clock to synchronise against. |
| Coverage drops mid-match | A stat that updated at minute 30 can silently stop updating — the provider's scout lost it. | Detect staleness by change over time, not presence. A frozen number looks exactly like a quiet match. |
| Data lags at the worst moments | Goalmouth scrambles, VAR reviews and simultaneous kickoffs are precisely when providers fall behind. | An event describes the feed's view at trigger time — reality may already have moved on. Build margins into anything time-sensitive. |
| Matches get interrupted | Abandonments, postponements, walkovers. | Picks on interrupted matches may never settle — expire your trackers (see Idempotency & ordering). |
| Quotes flicker and suspend | Markets suspend around goals and reviews; brief outlier values appear at exactly those moments. | Discard entries with suspended: true and sanity-bound quote values before using them. |
| Names change, IDs don't | Team and league names get renamed, rebranded and respelled — sometimes mid-season. | Key everything on IDs. Names are display-only. |
To be equally candid about our side: match data originates from third-party providers, and when their feed degrades, so do payloads. We surface what we receive — we can't correct what we never got. That is exactly why the patterns above aren't optional polish; they're the integration.
Parsing & precision #
Absent means unknown — never zero
A stat key missing from data.match.stats means the stat is not covered for that match. Storing it as 0 is the classic silent bug: months later, every average, model and threshold trained on that data is quietly wrong, and nothing ever errored. Store missing as NULL / None and exclude it from aggregates.
The same rule extends beyond stats: enrichment objects and fields — team form, formation, manager, market_value, squad indicators, stadium, season, weather — are present when coverage allows, never guaranteed. Read them all through the same "absent means unknown" lens.
stats = event["data"]["match"]["stats"]
xg = stats.get("xg") # None when not covered — keep it None
if xg is not None:
home_xg, away_xg = xg # only use it when it exists
# WRONG: turns "not covered" into "0.0 xG"
home_xg, away_xg = stats.get("xg", [0, 0])
The zero-is-falsy trap
favorite is 0 for home, 1 for away, null for none — and 0 is falsy in most languages. The truthiness shortcut silently skips every home favorite:
// BUG: home favorite is 0, and 0 is falsy — every home favorite is skipped.
if (match.favorite) { trackFavorite(match); }
// Correct: null means "no favorite"; 0 and 1 are both real values.
if (match.favorite !== null) { trackFavorite(match); }
Numbers
- Quotes are decimal numbers. Never compare them with float equality; if you need exactness (accounting, dedupe keys), store them as strings or fixed-point decimals, not doubles.
- Percentage stats (possession, action_areas) are 0–100 values that may not sum to exactly 100 after provider rounding.
- xg values are floats with provider-defined precision — treat trailing-digit differences as noise, not signal.
- Some numeric-looking fields arrive as strings — stadium capacity, league table positions, season round / max_rounds. Cast explicitly before doing math; a schema that types them as numbers will reject real payloads.
Identifiers
- Two id families exist: pick and strategy IDs are integers that can exceed 32-bit range (store as 64-bit or strings), while match, team, league, image and manager IDs are opaque encoded strings.
- All IDs are opaque either way: never parse, decode or order by them — compare byte-for-byte. The simplest uniform rule is to store every id as a string.
- Join across events and store keys on IDs — never on names (see almanac).
Strings & dates
- Team, league and venue names are UTF-8 with the full character range real football brings — accents, apostrophes, non-Latin scripts. Escape on output; never assume ASCII or a length cap.
- Timestamps are ISO 8601 UTC, in two spellings: data.match.date uses Z notation with milliseconds, while envelope fields like event_created_at use a +00:00 offset. Parse with a real ISO parser, not substring surgery, and never assume a local timezone.
- Stat tuples are always two-element [home, away] arrays — index 0 is home, everywhere, always.
System of record #
Copies drift for reasons that are all expected:
- Deliveries missed while your endpoint was down are never replayed — your store has a gap the platform doesn't.
- Upstream corrections after delivery are not re-sent — your stored snapshot preserves a value the platform has since improved.
- Your own processing bugs — dropped events, failed parses — leave holes only on your side.
Point-in-time values drift by design
strike_rate and strike_rate_league in a payload are the strategy's numbers at the moment the event was generated. Look at the same strategy today and the numbers will differ — more picks have settled since. That's not an inconsistency; a stored payload is a snapshot, the platform shows the live aggregate. Don't file the difference as a bug, and don't "reconcile" old snapshots against today's UI.
Reconciliation, done right
If your use case needs your store to be trustworthy, spot-check it periodically: compare your recent settled picks against the strategy history page. Where you find a gap, the first suspect is a missed delivery on your side — the delivery log will show the attempts and their outcomes. Reconcile toward the platform record, never away from it.