<?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: Leo Marsh</title>
    <description>The latest articles on DEV Community by Leo Marsh (@leomarsh886).</description>
    <link>https://dev.to/leomarsh886</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%2F4101098%2Fff943a94-c191-4f72-abeb-0217f8d234ad.png</url>
      <title>DEV Community: Leo Marsh</title>
      <link>https://dev.to/leomarsh886</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leomarsh886"/>
    <language>en</language>
    <item>
      <title>A Survey of Public APIs for Building a Football Live Score Site in 2026 — Part 3: Football Data API Review</title>
      <dc:creator>Leo Marsh</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:38:23 +0000</pubDate>
      <link>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-3-football-data-api-16hi</link>
      <guid>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-3-football-data-api-16hi</guid>
      <description>&lt;p&gt;A 2026 review of football data APIs for live scores, fixtures, standings, events, history, and odds.&lt;/p&gt;

&lt;p&gt;News and weather can improve a match page, but neither can rescue a late score, an incorrect kickoff time, or a fixture that disappears after being postponed. The football data API is the central dependency of the entire product.&lt;/p&gt;

&lt;p&gt;This is Part 3 of a survey of the public APIs needed to build a football live score site in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-1-news-api-review-5f87"&gt;News APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-2-weather-api-review-4g3k"&gt;Weather APIs for match-day context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Football data APIs&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Series scope and ground rules
&lt;/h2&gt;

&lt;p&gt;The following rules apply to every article in this series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Candidate source:&lt;/strong&gt; Every API considered comes from the &lt;a href="https://github.com/public-apis/public-apis" rel="noopener noreferrer"&gt;public-apis repository on GitHub&lt;/a&gt;. The repository was selected because it has more than 400,000 GitHub stars and offers a broad, well-organized set of categories and API options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review order:&lt;/strong&gt; APIs are considered in the same order in which they appear in the repository. The order is not a ranking, and providers have not paid for placement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shortlisting:&lt;/strong&gt; Every entry in the relevant category is screened, but only APIs that plausibly fit a football live score site receive a detailed review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No implied endorsement:&lt;/strong&gt; Inclusion in public-apis supplies the candidate pool; it does not mean that the repository or this series recommends an API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type of review:&lt;/strong&gt; This is a product-fit and documentation review, not a latency, uptime, completeness, or data-accuracy benchmark. Provider claims are identified as documented capabilities rather than independent measurements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot date:&lt;/strong&gt; Documentation, pricing, and public usage terms were checked on &lt;strong&gt;September 12, 2026&lt;/strong&gt;. They may change, so the linked official pages remain the final source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meaning of football:&lt;/strong&gt; In this series, “football” means association football, also called soccer, unless stated otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For Part 3, all 48 entries in the repository's &lt;a href="https://github.com/public-apis/public-apis#sports--fitness" rel="noopener noreferrer"&gt;Sports &amp;amp; Fitness section&lt;/a&gt; were screened. Eight services made the detailed shortlist because they can plausibly supply the fixtures, scores, standings, or match details required by a football site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the core football feed actually needs
&lt;/h2&gt;

&lt;p&gt;A homepage can be built from one “matches today” response, but a reliable match centre needs a more complete contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stable competition, season, team, venue, and fixture identifiers&lt;/li&gt;
&lt;li&gt;Kickoff timestamps in UTC, plus a clear postponement and rescheduling model&lt;/li&gt;
&lt;li&gt;Explicit match states for scheduled, live, half-time, finished, extra time, penalties, postponed, cancelled, and abandoned fixtures&lt;/li&gt;
&lt;li&gt;Home and away scores with half-time, full-time, extra-time, and penalty-shootout breakdowns&lt;/li&gt;
&lt;li&gt;A live event timeline for goals, cards, substitutions, VAR, and penalties&lt;/li&gt;
&lt;li&gt;Lineups, formations, benches, referees, and team or player statistics where the product needs them&lt;/li&gt;
&lt;li&gt;Tables with points deductions, split groups, play-offs, and competition-specific rules&lt;/li&gt;
&lt;li&gt;Coverage metadata that states which fields exist for each league and season&lt;/li&gt;
&lt;li&gt;Sufficient history for team pages, head-to-head views, and corrections&lt;/li&gt;
&lt;li&gt;Predictable quotas, caching rights, attribution rules, and commercial-use terms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response shape matters, but corrections matter more. A provider must be able to change a score, event minute, lineup, or fixture status without breaking the application's identifiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shortlist at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Free starting point&lt;/th&gt;
&lt;th&gt;Live scores on free access&lt;/th&gt;
&lt;th&gt;Lowest listed paid step&lt;/th&gt;
&lt;th&gt;Initial fit for a football site&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5DollarFootballAPI&lt;/td&gt;
&lt;td&gt;Free: 60 requests/hour and top five leagues; Community: 300/hour and one additional country pack&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$5/month&lt;/td&gt;
&lt;td&gt;Two $0 routes, followed by low-cost global coverage and unusually deep odds data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-FOOTBALL&lt;/td&gt;
&lt;td&gt;100 requests/day; all endpoints, recent-season limits&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$19/month&lt;/td&gt;
&lt;td&gt;Broadest low-cost all-in-one feature set in this shortlist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;football-data.org&lt;/td&gt;
&lt;td&gt;12 competitions; 10 calls/minute&lt;/td&gt;
&lt;td&gt;No; free scores are delayed&lt;/td&gt;
&lt;td&gt;€12/month&lt;/td&gt;
&lt;td&gt;Clean fixture, score, and table API for selected competitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenFootAPI&lt;/td&gt;
&lt;td&gt;5,000 requests/month; 120-competition catalogue&lt;/td&gt;
&lt;td&gt;Scores, but advanced live events require paid access&lt;/td&gt;
&lt;td&gt;$14/month&lt;/td&gt;
&lt;td&gt;Open-data-oriented feed with lineups and xG on higher access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenLigaDB&lt;/td&gt;
&lt;td&gt;No key; community-maintained open database&lt;/td&gt;
&lt;td&gt;Depends on community updates&lt;/td&gt;
&lt;td&gt;No paid tier&lt;/td&gt;
&lt;td&gt;Excellent hobby and German-league starting point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SportScore&lt;/td&gt;
&lt;td&gt;About 10,000 requests/day/IP with attribution&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No paid tier required&lt;/td&gt;
&lt;td&gt;Generous multi-sport feed if its link requirement is acceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sportmonks Football&lt;/td&gt;
&lt;td&gt;Free-forever access to two leagues&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;€29/month&lt;/td&gt;
&lt;td&gt;Deep professional data with league-based pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TheSportsDB&lt;/td&gt;
&lt;td&gt;Shared free key; 30 requests/minute&lt;/td&gt;
&lt;td&gt;Premium only&lt;/td&gt;
&lt;td&gt;$9/month&lt;/td&gt;
&lt;td&gt;Accessible multi-sport metadata, artwork, schedules, and basic scores&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These allowances are not directly comparable. A request that returns every live fixture is more valuable than a request that returns one league or one match. “Live score” can also mean anything from a near-real-time event stream to a score updated every two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. 5DollarFootballAPI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://5dollarfootballapi.com/docs" rel="noopener noreferrer"&gt;5DollarFootballAPI&lt;/a&gt; provides fixtures, live scores, results, standings, match events, team statistics, and pre-match and in-play odds. Its strongest differentiator is not a larger player database; it is the combination of a low entry price with historical bookmaker movement, including corner and card lines.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://5dollarfootballapi.com/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; covers the Premier League, La Liga, Serie A, Bundesliga, and Ligue 1 at 60 requests per hour. It includes live scores, fixtures, standings, three months of results, and Bet365 odds, but fixture-list requests cannot expand a whole day's odds in one response.&lt;/p&gt;

&lt;p&gt;Separately, &lt;a href="https://5dollarfootballapi.com/community" rel="noopener noreferrer"&gt;Community Access&lt;/a&gt; provides a second $0 option for students, researchers, early-career developers, and people building without a budget. It raises the allowance to 300 requests per hour, with a 20-request-per-minute burst limit, and covers the same five leagues plus one country pack of the user's choice. It also extends results and Bet365 odds to 12 months. Access runs for 12 months at a time and can be renewed; it does not add batch odds or full tick history.&lt;/p&gt;

&lt;p&gt;The $5 Pro plan moves to 10 requests per minute, more than 130 competitions, batch odds, and 12 months of history. The $25 Ultra plan adds all listed competitions, 19 bookmakers, and full odds tick history and results back to 2014. Public-facing products using either $0 plan must provide attribution; attribution is optional on paid plans.&lt;/p&gt;

&lt;p&gt;The limitations are important. This is not the right choice for a product whose central features are player profiles, squads, transfers, injuries, confirmed lineups, or detailed player statistics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A budget-conscious live score or odds product that needs broad competition coverage and deep Asian, goal, corner, and card markets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The player and lineup layer is much thinner than API-FOOTBALL or Sportmonks.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. API-FOOTBALL
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.api-football.com/documentation-v3" rel="noopener noreferrer"&gt;API-FOOTBALL&lt;/a&gt; has the broadest conventional football feature set in this group. The documented endpoints cover fixtures, livescores, events, lineups, team and player statistics, standings, injuries, transfers, coaches, predictions, and pre-match and in-play odds.&lt;/p&gt;

&lt;p&gt;Its current &lt;a href="https://www.api-football.com/coverage" rel="noopener noreferrer"&gt;coverage page&lt;/a&gt; lists 1,244 leagues and cups, with field-level coverage varying by competition and season. That coverage object is useful: an integration can check whether a league has events, lineups, statistics, predictions, or odds before presenting an empty module.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.api-football.com/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; provides 100 requests per day and ten requests per minute. All endpoints and competitions are available, although the free plan limits historical seasons. Pro costs $19 per month for 7,500 requests per day, Ultra costs $29 for 75,000, and Mega costs $39 for 150,000.&lt;/p&gt;

&lt;p&gt;One hundred daily calls are enough to study the schema but not to run a live-score service. Polling a centralized live endpoint every 30 seconds for four hours already consumes 480 requests. The application therefore needs a paid quota before it needs a large audience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A site that wants fixtures, live incidents, players, injuries, lineups, statistics, odds, and predictions from one established interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free quota is an evaluation tier, and the large response surface requires careful coverage checks and caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. football-data.org
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.football-data.org/documentation/quickstart" rel="noopener noreferrer"&gt;football-data.org&lt;/a&gt; is narrower and easier to understand. Its main resources are competitions, matches, teams, standings, scorers, and people. For a conventional European fixtures-and-tables site, that smaller model can be an advantage.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.football-data.org/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; covers 12 competitions at ten calls per minute, but scores and schedules are delayed. The €12 “Free w/ Livescores” plan removes that problem for the same 12 competitions and raises the limit to 20 calls per minute.&lt;/p&gt;

&lt;p&gt;Deeper match data is packaged separately. The €29 Deep Data option adds lineups, substitutions, goal scorers, bookings, and squads. Standard starts at €49 for 30 competitions, while odds and detailed match statistics are separate €15 add-ons that require a regular plan.&lt;/p&gt;

&lt;p&gt;This structure is more transparent than a generic “everything included” promise, but it means the cheapest live plan is not a complete match-centre plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A focused application covering a small set of prominent competitions with a clean resource model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free tier is delayed, and events, squads, odds, and statistics can raise the effective production price.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. OpenFootAPI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://openfootapi.com/docs" rel="noopener noreferrer"&gt;OpenFootAPI&lt;/a&gt; combines fixtures, results, standings, team search, lineups, live events, commentary, xG shot maps, derived match context, an SSE stream, and webhooks. Its responses also expose source and licence metadata, which is useful when the service combines several open-data sources.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://openfootapi.com/pricing" rel="noopener noreferrer"&gt;free Starter plan&lt;/a&gt; provides 5,000 authenticated requests per month, 60 requests per minute, and fixtures, scores, standings, and search across a 120-competition catalogue. The $14 Developer plan raises the allowance to 250,000 monthly requests and adds live events, lineups, xG, SSE, webhooks, and derived analytics. The $39 Pro plan provides two million monthly requests and explicitly includes commercial use.&lt;/p&gt;

&lt;p&gt;Coverage is not uniform. The provider marks some advanced features as limited to major leagues or available on a best-effort basis. It also documents upstream-source migrations that can change historical identifiers. Consumers should therefore store the provider's coverage and provenance fields instead of assuming every match supports the same UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A developer who values open-data provenance, a modern API shape, and optional lineups, xG, streaming, or webhook delivery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Advanced field coverage varies by upstream source, and commercial rights are stated most clearly on the higher Pro plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OpenLigaDB
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.openligadb.de/" rel="noopener noreferrer"&gt;OpenLigaDB&lt;/a&gt; is a free community database. Reading through its JSON API requires no authentication, and its OpenAPI interface exposes leagues, fixtures, results, goals, teams, groups, next and previous matches, and tables.&lt;/p&gt;

&lt;p&gt;The project is strongest around German football. Its current database includes Bundesliga, 2. Bundesliga, 3. Liga, the DFB-Pokal, European competitions, German national teams, and a selection of other leagues maintained by users.&lt;/p&gt;

&lt;p&gt;The data is available under the Open Database License, but club names, marks, logos, and linked images can have separate rights. Because results are entered and maintained by the community, the quality and update speed of one league should not be assumed for another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Hobby projects, teaching, prediction games, Bundesliga widgets, and applications that prefer an open database over a commercial feed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Community maintenance and uneven league coverage make it risky as the only source for a global production live-score site.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. SportScore
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sportscore.com/developers/" rel="noopener noreferrer"&gt;SportScore&lt;/a&gt; offers eight CORS-enabled REST endpoints for live scores, fixtures, match details, standings, brackets, top scorers, and player statistics across football, basketball, cricket, and tennis. No API key is required for its open-source and attribution access.&lt;/p&gt;

&lt;p&gt;The documented allowance is approximately 10,000 requests per 24 hours per IP. That is unusually generous for a free live-score feed and is sufficient for a centrally cached prototype.&lt;/p&gt;

&lt;p&gt;The commercial trade-off is unusually explicit: pages using the data must display a visible “Powered by SportScore” link, and the provider specifically requires that link to be dofollow. Some publishers and organizations do not permit contractual requirements about search-engine link attributes, so this condition needs legal and SEO review before integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A free multi-sport project whose publishing policy accepts the required attribution link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The dofollow-link requirement will be unacceptable or impossible on some platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Sportmonks Football
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.sportmonks.com/football-api/" rel="noopener noreferrer"&gt;Sportmonks Football&lt;/a&gt; is the deepest professional product in this shortlist. It documents more than 2,200 leagues and covers fixtures, live events, lineups, team and player statistics, standings, squads, coaches, referees, transfers, predictions, odds, xG, pressure metrics, and additional match facts.&lt;/p&gt;

&lt;p&gt;The free-forever tier currently covers the Danish Superliga and Scottish Premiership. The &lt;a href="https://www.sportmonks.com/football-api/plans-pricing/" rel="noopener noreferrer"&gt;Starter plan&lt;/a&gt; costs €29 per month for five selected leagues and 2,000 calls per entity per hour. Growth costs €99 for 30 leagues, and Pro costs €249 for 120. Annual billing reduces the displayed monthly equivalents.&lt;/p&gt;

&lt;p&gt;Core football data is included, but advanced products such as odds and predictions, xG and pressure, news, and deeper historical data have separate pricing. The API's include system can return a deeply nested match in one call, reducing the need to stitch many resources together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A professional match centre, fantasy product, or analytics service that values deep player and match data and can price coverage by league.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Costs scale with selected leagues, while advanced analytics and odds can add to the advertised starting price.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. TheSportsDB
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.thesportsdb.com/documentation" rel="noopener noreferrer"&gt;TheSportsDB&lt;/a&gt; is a crowd-sourced multi-sport database with teams, players, venues, events, schedules, tables, artwork, and video metadata. The public V1 interface is deliberately easy to explore in a browser by using the shared key &lt;code&gt;123&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Free users receive 30 requests per minute, but some heavily used endpoints are restricted. Premium costs $9 per month, raises the limit to 100 requests per minute, unlocks the newer V2 API, and includes livescores and video highlights. The advertised livescore refresh is approximately two minutes.&lt;/p&gt;

&lt;p&gt;This makes TheSportsDB more attractive for team pages, badges, venue information, schedules, and cross-sport discovery than for a second-by-second football match centre. Its community model also means that completeness varies by sport and competition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; An affordable multi-sport site that needs rich descriptive metadata and artwork alongside schedules and scores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The live-score feature is paid and slower than feeds designed around rapid in-play updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the other 40 entries were not detailed
&lt;/h2&gt;

&lt;p&gt;The rest of the Sports &amp;amp; Fitness list contains useful services, but most answer a different product question.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different sports:&lt;/strong&gt; balldontlie, CFL, CollegeFootballData, Ergast F1, MLB, NBA, NHL, OpenF1, RacingHub, Squiggle, Strava, TourneyRadar, Tredict, Wger, and others are not association-football feeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Betting and odds specialists:&lt;/strong&gt; Bet Better, Cloudbet, Lumify, Odds-API, Oddsmagnet, PropLine, and TheRundown may complement a score feed, but odds alone cannot establish the authoritative fixture and result model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow football additions:&lt;/strong&gt; ScoreBat supplies video embeds; Football Charts supplies tables and model projections; Football Standings is an ESPN-derived standings wrapper; PlayerElo supplies player ratings; Premier League Standings covers one competition. Each can enrich a site but cannot power the entire core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directories, places, and recognition:&lt;/strong&gt; Sport List &amp;amp; Data, Sport Places, Sport Vision, City Bikes, Fitbit, DiscGolf, and similar entries solve unrelated discovery or fitness problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy or unclear starting points:&lt;/strong&gt; The RapidAPI entry named Football still uses the old &lt;code&gt;X-Mashape-Key&lt;/code&gt; label in the directory. Sport Data and SuredBits do not currently present a sufficiently clear, modern self-service path for a fair production comparison.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exclusion here is not a negative product rating. It only means the API cannot independently supply the minimum football match model used in this survey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live-score quota math
&lt;/h2&gt;

&lt;p&gt;The useful question is not “How many requests are free?” It is “How many upstream requests does one live matchday require?”&lt;/p&gt;

&lt;p&gt;Suppose the backend polls one endpoint every 30 seconds during a four-hour window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 hours × 60 minutes × 2 requests per minute = 480 requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single centralized job already exceeds API-FOOTBALL's 100-request free daily quota and consumes nearly 10% of OpenFootAPI's free monthly allowance in one afternoon. If the application polls once per league or once per visitor, the architecture fails before the product becomes popular.&lt;/p&gt;

&lt;p&gt;A production design should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Poll upstream providers only from the backend.&lt;/li&gt;
&lt;li&gt;Prefer a single all-live or date-level endpoint over one request per match.&lt;/li&gt;
&lt;li&gt;Cache slow-changing teams, competitions, venues, and standings separately.&lt;/li&gt;
&lt;li&gt;Increase polling only while matches are in play.&lt;/li&gt;
&lt;li&gt;Push changes to browsers through SSE, WebSockets, or short polling against the application's own cache.&lt;/li&gt;
&lt;li&gt;Keep the last known value and its update time, but never label stale data as live.&lt;/li&gt;
&lt;li&gt;Reserve quota for corrections after full time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Batching and cache design can be more important than the headline rate limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize the match before building the interface
&lt;/h2&gt;

&lt;p&gt;Every provider names scores and statuses differently. The website should depend on an internal match object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider:fixture-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"competition_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider:competition-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"season"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026/27"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kickoff_at_utc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-12T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in_play"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"second_half"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"home"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider:home-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Home FC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"away"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider:away-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Away FC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"half_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"full_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"extra_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"penalties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated_at_utc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-12T15:26:10Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter should preserve the raw provider status and identifiers alongside the normalized values. Otherwise, debugging an unusual abandoned match or penalty shootout becomes guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision guide
&lt;/h2&gt;

&lt;p&gt;There is no credible universal winner without measuring the same live matches and corrections across providers.&lt;/p&gt;

&lt;p&gt;Based on documented product fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API-FOOTBALL&lt;/strong&gt; is the broadest low-cost all-in-one option, especially when players, injuries, transfers, and lineups matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;football-data.org&lt;/strong&gt; is the cleanest focused option for a small number of prominent competitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sportmonks&lt;/strong&gt; has the deepest professional data model, with pricing that makes the most sense when each selected league earns its place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenLigaDB&lt;/strong&gt; is the most attractive open community source for German football and educational projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenFootAPI&lt;/strong&gt; is interesting when provenance, xG, live streaming, and webhooks matter, provided competition-level coverage is checked carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SportScore&lt;/strong&gt; offers exceptional free volume if the required dofollow attribution fits the publisher's policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TheSportsDB&lt;/strong&gt; is strongest as a multi-sport metadata and artwork layer rather than the fastest football feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5DollarFootballAPI&lt;/strong&gt; stands out for low-cost football coverage and odds depth: its $5 plan covers more than 130 competitions with live scores, fixtures, standings, and batch odds, while higher tiers add 19 bookmakers and full historical odds movements across match, corner, and card markets. It is best suited to live-score and odds-led products rather than player-profile or lineup-heavy applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The defensible next step is a parallel benchmark using the same competitions and fixtures.&lt;/p&gt;

&lt;p&gt;That benchmark should record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time from an on-pitch event to the first API response containing it&lt;/li&gt;
&lt;li&gt;Missing and later-corrected fixtures&lt;/li&gt;
&lt;li&gt;Score, status, and event corrections after full time&lt;/li&gt;
&lt;li&gt;Lineup availability before kickoff&lt;/li&gt;
&lt;li&gt;Coverage differences by league and season&lt;/li&gt;
&lt;li&gt;Requests required to render the same match centre&lt;/li&gt;
&lt;li&gt;Effective monthly cost at the intended polling schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Documentation can identify which providers are credible enough to benchmark. Only live observation can determine which one is reliable enough for a specific product.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI was used to assist with research organization and English editing. The linked official sources were checked before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>football</category>
      <category>data</category>
    </item>
    <item>
      <title>A Survey of Public APIs for Building a Football Live Score Site in 2026 — Part 2: Weather API Review</title>
      <dc:creator>Leo Marsh</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:52:09 +0000</pubDate>
      <link>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-2-weather-api-review-4g3k</link>
      <guid>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-2-weather-api-review-4g3k</guid>
      <description>&lt;p&gt;Weather is useful on a football live score site only when it answers a specific question: what conditions are expected at the venue around kickoff?&lt;/p&gt;

&lt;p&gt;A generic city forecast is not enough. A useful match card needs the venue coordinates, the kickoff time, an hourly forecast, a clear update time, and a graceful fallback when the forecast is unavailable.&lt;/p&gt;

&lt;p&gt;This is Part 2 of a survey of the public APIs needed to build a football live score site in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-1-news-api-review-5f87"&gt;News APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Weather APIs for match-day context&lt;/li&gt;
&lt;li&gt;Football data APIs&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Series scope and ground rules
&lt;/h2&gt;

&lt;p&gt;The following rules apply to every article in this series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Candidate source:&lt;/strong&gt; Every API considered comes from the &lt;a href="https://github.com/public-apis/public-apis" rel="noopener noreferrer"&gt;public-apis repository on GitHub&lt;/a&gt;. I chose it because it has more than 400,000 GitHub stars and offers a broad, well-organized set of categories and API options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review order:&lt;/strong&gt; APIs are considered in the same order in which they appear in the repository. The order is not a ranking, and providers have not paid for placement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shortlisting:&lt;/strong&gt; Every entry in the relevant category is screened, but only APIs that plausibly fit a football live score site receive a detailed review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No implied endorsement:&lt;/strong&gt; Inclusion in public-apis supplies the candidate pool; it does not mean that the repository or this series recommends an API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type of review:&lt;/strong&gt; This is a product-fit and documentation review, not a performance or forecast-accuracy benchmark. Provider claims are identified as documented capabilities rather than independent test results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot date:&lt;/strong&gt; Documentation, pricing, and public usage terms were checked on &lt;strong&gt;September 9, 2026&lt;/strong&gt;. They may change, so the linked official pages remain the final source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meaning of football:&lt;/strong&gt; In this series, “football” means association football, also called soccer, unless stated otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For Part 2, I screened all 39 enties in the repository's &lt;a href="https://github.com/public-apis/public-apis#weather" rel="noopener noreferrer"&gt;Weather section&lt;/a&gt;. The detailed shortlist contains eight global, general-purpose services that can plausibly provide current conditions or an hourly forecast for a match venue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a football weather card actually needs
&lt;/h2&gt;

&lt;p&gt;The core input should be a stadium's latitude and longitude, not just a club or city name. Clubs can move matches, share stadiums, play at neutral venues, or use grounds far from a city centre.&lt;/p&gt;

&lt;p&gt;For each fixture, the useful fields are small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hourly temperature and apparent temperature&lt;/li&gt;
&lt;li&gt;Precipitation probability and expected precipitation&lt;/li&gt;
&lt;li&gt;Wind speed, direction, and gusts&lt;/li&gt;
&lt;li&gt;Humidity and a compact weather condition code&lt;/li&gt;
&lt;li&gt;Forecast issue or update time&lt;/li&gt;
&lt;li&gt;The provider's required attribution&lt;/li&gt;
&lt;li&gt;A reliable mapping between kickoff time and the venue's local time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Minute-by-minute rain, air quality, pollen, marine conditions, and a 15-day outlook may be valuable in other products, but they are not the first requirements for a score site.&lt;/p&gt;

&lt;p&gt;The forecast horizon also changes the product. A three-day forecast covers most match-centre traffic close to kickoff. A seven- or fifteen-day forecast is more useful for fixture pages, but should be labelled as less certain when the match is still far away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shortlist at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Free allowance&lt;/th&gt;
&lt;th&gt;Relevant free coverage&lt;/th&gt;
&lt;th&gt;Commercial use on free access&lt;/th&gt;
&lt;th&gt;Initial fit for a football site&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weatherstack&lt;/td&gt;
&lt;td&gt;100 calls/month&lt;/td&gt;
&lt;td&gt;Current conditions only&lt;/td&gt;
&lt;td&gt;Not presented as a commercial plan&lt;/td&gt;
&lt;td&gt;Easy evaluation, but no free forecast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AccuWeather&lt;/td&gt;
&lt;td&gt;14-day trial, 500 calls/day&lt;/td&gt;
&lt;td&gt;Current, 12-hour and 5-day forecasts&lt;/td&gt;
&lt;td&gt;No; low tiers are trial or non-commercial&lt;/td&gt;
&lt;td&gt;Strong location model, including stadium POIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MET Norway&lt;/td&gt;
&lt;td&gt;No fixed account quota; fair-use rules apply&lt;/td&gt;
&lt;td&gt;Global forecast up to 9 days&lt;/td&gt;
&lt;td&gt;Yes, with attribution and traffic rules&lt;/td&gt;
&lt;td&gt;Excellent open-data option if caching is implemented correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-Meteo&lt;/td&gt;
&lt;td&gt;10,000 calls/day and 300,000/month&lt;/td&gt;
&lt;td&gt;Hourly forecast up to 16 days&lt;/td&gt;
&lt;td&gt;No on the hosted free endpoint&lt;/td&gt;
&lt;td&gt;The easiest keyless prototype in this shortlist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenWeather&lt;/td&gt;
&lt;td&gt;1,000 One Call requests/day free&lt;/td&gt;
&lt;td&gt;Current, 48-hour hourly, 8-day daily, alerts and history&lt;/td&gt;
&lt;td&gt;Yes under its open licence conditions&lt;/td&gt;
&lt;td&gt;Broad one-response feature set; licensing needs attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tomorrow.io&lt;/td&gt;
&lt;td&gt;500 requests/day, 25/hour&lt;/td&gt;
&lt;td&gt;Current, 5-day hourly/daily and 24 hours of history&lt;/td&gt;
&lt;td&gt;Contact the provider for a commercial plan&lt;/td&gt;
&lt;td&gt;Rich fields and useful short-range resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual Crossing&lt;/td&gt;
&lt;td&gt;First 1,000 records/day free&lt;/td&gt;
&lt;td&gt;Current, history and a 15-day forecast&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Particularly strong when historical match weather matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WeatherAPI.com&lt;/td&gt;
&lt;td&gt;100,000 calls/month&lt;/td&gt;
&lt;td&gt;Current, 3-day forecast and 1 day of history&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The clearest small commercial starting point&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Allowances are not directly comparable. One provider counts HTTP requests, while another counts returned records. A forecast request may cost one unit, while an hourly historical range may cost dozens or hundreds.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Weatherstack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://weatherstack.com/" rel="noopener noreferrer"&gt;Weatherstack&lt;/a&gt; provides global real-time weather through a conventional JSON API. Its free plan currently includes HTTPS and 100 calls per month, but only the real-time endpoint.&lt;/p&gt;

&lt;p&gt;That makes the free tier useful for inspecting the response shape, not for operating match pages. One hundred calls would be exhausted by checking ten venues ten times in an entire month.&lt;/p&gt;

&lt;p&gt;The current &lt;a href="https://weatherstack.com/pricing" rel="noopener noreferrer"&gt;pricing table&lt;/a&gt; puts 50,000 monthly calls, hourly data and historical data on the $9.99 Standard plan. The actual 7-day forecast does not appear until the $49.99 Professional plan. Commercial use is listed with paid access.&lt;/p&gt;

&lt;p&gt;For a football site, that product split matters more than the low entry price. Current conditions can describe what is happening now, but a pre-match page needs future hourly conditions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Evaluating a straightforward commercial weather API with a familiar response model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free tier has no forecast and is far too small for a live product.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AccuWeather
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developer.accuweather.com/documentation/overview" rel="noopener noreferrer"&gt;AccuWeather's developer platform&lt;/a&gt; offers current conditions, hourly and daily forecasts, government alerts, indices, and its separate MinuteCast precipitation product.&lt;/p&gt;

&lt;p&gt;The location model is especially relevant to football. Most weather calls use an AccuWeather location key. Its &lt;a href="https://developer.accuweather.com/documentation/locations-info" rel="noopener noreferrer"&gt;Locations API&lt;/a&gt; can search by coordinates and includes stadiums as a point-of-interest type. A site should resolve and store the location key when a venue is added, rather than spending an extra API call for every fixture-page request.&lt;/p&gt;

&lt;p&gt;The free Core Weather access is now described as a 14-day trial with 500 daily calls. The $2 Starter plan includes 15,000 monthly calls, current conditions, a 12-hour forecast, and a 5-day forecast, but AccuWeather describes Starter as personal or non-commercial. The $25 Standard plan is the more relevant self-service step for a commercial site.&lt;/p&gt;

&lt;p&gt;MinuteCast has separate allowances and pricing. A basic match page should not assume that minute-level precipitation is bundled with the normal forecast API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A product that values a mature location catalogue and may later add alerts or short-term precipitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The permanent low-cost tier is not a commercial production licence, and location lookup adds integration state.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. MET Norway
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://api.met.no/weatherapi/locationforecast/2.0/documentation" rel="noopener noreferrer"&gt;MET Norway Locationforecast API&lt;/a&gt; returns a global forecast for a latitude and longitude for up to nine days. The compact GeoJSON response contains the common temperature, precipitation, humidity, wind, pressure, and symbol fields needed for a match card.&lt;/p&gt;

&lt;p&gt;There is no API key or paid tier. The service is funded as a public service, and its data is generally available under &lt;a href="https://docs.api.met.no/doc/License.html" rel="noopener noreferrer"&gt;NLOD 2.0 and CC BY 4.0&lt;/a&gt;. Commercial reuse is possible, but attribution to MET Norway is required.&lt;/p&gt;

&lt;p&gt;“Free” does not mean “send unlimited traffic.” The &lt;a href="https://docs.api.met.no/doc/TermsOfService.html" rel="noopener noreferrer"&gt;terms of service&lt;/a&gt; require an identifying &lt;code&gt;User-Agent&lt;/code&gt; containing application and contact information. Clients must respect cache headers, avoid duplicate requests, limit coordinates to four decimal places, and contact MET Norway before exceeding 20 requests per second across an application. There is no SLA.&lt;/p&gt;

&lt;p&gt;These rules align well with a football site. Forecasts should be fetched by a backend job, cached by venue and forecast issue time, and reused across every page that refers to the same stadium.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A properly cached service that wants open, attributable global forecast data without an account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; There is no paid upgrade path or SLA if the public service does not meet production needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Open-Meteo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://open-meteo.com/en/docs" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; is the easiest API in the shortlist to explore. The hosted free endpoint needs no API key and can return selected hourly variables for one or multiple coordinates. The default forecast is seven days and can be extended to sixteen.&lt;/p&gt;

&lt;p&gt;Its weather variables map cleanly to football: temperature, apparent temperature, precipitation probability, rain, snowfall, weather code, visibility, wind, and gusts are all available in the main forecast endpoint. The service combines models from national weather agencies and can automatically choose a model for the requested coordinates.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://open-meteo.com/en/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; currently limits the free service to 600 calls per minute, 5,000 per hour, 10,000 per day, and 300,000 per month. The hosted free endpoint is for non-commercial use and has no uptime guarantee. A subscription moves requests to a dedicated customer endpoint, adds a commercial-use licence, and targets 99.9% uptime.&lt;/p&gt;

&lt;p&gt;Open-Meteo also requires attribution because its underlying weather data is made available under CC BY 4.0. Its historical, ensemble, and climate APIs are useful for research, but some require a higher commercial plan than the standard forecast service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A keyless prototype and a clean adapter for venue-coordinate forecasts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; A commercial website must not treat the generous public endpoint as a free production service.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OpenWeather
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://openweathermap.org/api" rel="noopener noreferrer"&gt;OpenWeather&lt;/a&gt; offers several overlapping products, so the first integration decision is choosing the correct one.&lt;/p&gt;

&lt;p&gt;The free-access APIs provide current weather and a 5-day forecast in 3-hour steps, with limits of 60 calls per minute and one million calls per month. The separate &lt;a href="https://openweathermap.org/api/one-call-3" rel="noopener noreferrer"&gt;One Call API 3.0&lt;/a&gt; is more convenient for a match page: a single coordinate request can include current conditions, a minute forecast for one hour, hourly forecasts for 48 hours, daily forecasts for eight days, government alerts, and historical endpoints. Its first 1,000 calls per day are free; further usage is pay-as-you-call and requires a separate subscription.&lt;/p&gt;

&lt;p&gt;The licence deserves as much attention as the quota. OpenWeather's &lt;a href="https://openweathermap.org/storage/app/media/documents/License_explainer_25_Feb_25.pdf" rel="noopener noreferrer"&gt;licence explanation&lt;/a&gt; says commercial use is permitted on the open licence, with attribution and share-alike conditions for derivative solutions. An operator should review how those conditions apply to the wider site and its distributed applications rather than assuming “commercial use allowed” settles every question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Receiving current, short-range, daily, alert, and historical data through one mature API family.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Product selection, subscription setup, and open-licence obligations make it less simple than the headline quota suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Tomorrow.io
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.tomorrow.io/weather-api/" rel="noopener noreferrer"&gt;Tomorrow.io&lt;/a&gt; provides current conditions, timelines, alerts, maps, and more than 80 weather and environmental fields. The free API supports hourly and daily data from 24 hours in the past to five days ahead. Shorter timesteps are available around the present, depending on the field.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://support.tomorrow.io/hc/en-us/articles/20273728362644-Free-API-Plan-Rate-Limits" rel="noopener noreferrer"&gt;free-plan limits&lt;/a&gt; are 500 requests per day, 25 per hour, and three per second. The hourly cap is the important one: an application cannot spend the daily allowance in large bursts.&lt;/p&gt;

&lt;p&gt;For a football site, 25 hourly requests can still be workable if calls are scheduled and cached by venue. It is not workable if every page view calls Tomorrow.io directly. The free plan includes core fields and a five-day forecast; premium fields and commercial scaling require a conversation with sales rather than a public self-service price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A short-range forecast service where rich fields and future alerting are important.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The hourly limit requires disciplined caching, and production pricing is not public.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Visual Crossing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.visualcrossing.com/weather-api" rel="noopener noreferrer"&gt;Visual Crossing&lt;/a&gt; exposes historical weather, current conditions, and forecasts through one Timeline API. A query can use coordinates, an address, a postal code, or a weather-station identifier. With no dates specified, the endpoint returns a 15-day forecast with daily and hourly data.&lt;/p&gt;

&lt;p&gt;The free account includes the first 1,000 records per day and explicitly permits commercial use. Its accounting model needs explanation: a standard forecast for one location counts as one record, but historical daily or hourly rows count individually. The response's &lt;code&gt;queryCost&lt;/code&gt; field should be logged so usage does not become a surprise.&lt;/p&gt;

&lt;p&gt;Above the free allowance, the metered price is currently $0.0001 per record, while monthly plans start at $35. The &lt;a href="https://www.visualcrossing.com/weather-data-editions/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; also distinguishes between using data directly, storing it for internal use, and redistributing stored data externally.&lt;/p&gt;

&lt;p&gt;This is the most attractive option in the shortlist when the product will later compare weather with match outcomes. The same adapter can request the forecast before kickoff, observed conditions after the match, and historical conditions for past fixtures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A commercial match-weather feature that may grow into historical analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; “Records” are not the same as requests, so historical hourly queries can consume the allowance quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. WeatherAPI.com
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.weatherapi.com/docs/" rel="noopener noreferrer"&gt;WeatherAPI.com&lt;/a&gt; combines realtime conditions, forecasts, history, location search, time-zone data, astronomy, and optional alerts behind a compact JSON or XML interface.&lt;/p&gt;

&lt;p&gt;Its &lt;a href="https://www.weatherapi.com/pricing.aspx" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; currently provides 100,000 calls per month, a 3-day forecast, one day of historical weather, and commercial use. The $7 Starter plan raises the allowance to three million monthly calls and extends the forecast and history windows to seven days.&lt;/p&gt;

&lt;p&gt;A three-day forecast is enough for the highest-traffic part of a football match page's lifecycle. The documented hourly object includes temperature, apparent temperature, condition, precipitation chance, humidity, wind, and gusts, so a useful display does not need premium environmental fields.&lt;/p&gt;

&lt;p&gt;WeatherAPI.com also exposes a time-zone endpoint, but a football data model should still store fixture times in UTC and maintain the venue's IANA time zone separately. A weather provider should not silently become the source of truth for the match schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A small commercial site that wants a generous allowance and a low-cost path to a seven-day forecast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free forecast horizon is shorter than most alternatives in the shortlist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the other 31 entries were not detailed
&lt;/h2&gt;

&lt;p&gt;The remaining APIs are not necessarily poor products. They answer different questions or make a less comparable starting point for this particular feature.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Regional public services:&lt;/strong&gt; Aemet, Euskalmet, Hong Kong Observatory, IPMA, US Weather, HG Weather, and Yandex Weather are useful when coverage is intentionally regional, but not as the only provider for a global football site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialized environmental feeds:&lt;/strong&gt; AQICN, AviationWeather, Hail History, OpenUV, RainViewer, Storm Glass, and WeatherTotals focus on air quality, aviation, hail, UV, radar, marine weather, or measured events rather than a basic global match forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal stations and utility interfaces:&lt;/strong&gt; openSenseMap, World Time &amp;amp; Weather, and wttr.in can be useful data or presentation tools, but they are not the most direct production comparison here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More complex or overlapping products:&lt;/strong&gt; Open-Meteo Ensemble, Meltema, Oikolab, Pirate Weather, QWeather, Rainbow Weather, Weatherbit, Foreca, and ColorfulClouds may fit specific requirements. They were left outside the detailed shortlist to keep the comparison centred on a simple global hourly forecast with clearly documented access and licensing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unclear, legacy, or prototype starting points:&lt;/strong&gt; 7Timer!, APIXU, Micro Weather, ODWeather, and the repository named &lt;code&gt;weather-api&lt;/code&gt; require more due diligence before being treated as a current production dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screening decision is about this product, not a universal ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integration should be venue-first
&lt;/h2&gt;

&lt;p&gt;A football API will normally provide a fixture and venue. The weather integration should enrich that data rather than accept arbitrary location searches from every visitor.&lt;/p&gt;

&lt;p&gt;A practical flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store each venue's latitude, longitude, elevation where available, and IANA time zone.&lt;/li&gt;
&lt;li&gt;Store every kickoff in UTC.&lt;/li&gt;
&lt;li&gt;Fetch an hourly forecast for a narrow window around kickoff, such as two hours before to three hours after.&lt;/li&gt;
&lt;li&gt;Cache the response by venue, forecast issue time, and requested time window.&lt;/li&gt;
&lt;li&gt;Refresh more often as kickoff approaches, while respecting the provider's update and cache rules.&lt;/li&gt;
&lt;li&gt;Show the provider attribution and a “forecast updated” timestamp.&lt;/li&gt;
&lt;li&gt;Hide the card or mark it unavailable when the data is stale; never substitute a distant city's conditions without saying so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design prevents a popular match from producing thousands of identical weather calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize weather before building the UI
&lt;/h2&gt;

&lt;p&gt;The website should depend on an internal object rather than a provider's field names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fixture_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"venue_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wembley-stadium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;51.556&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.2796&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kickoff_at_utc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-12T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forecast_for_utc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-12T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forecast_issued_at_utc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-12T10:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"temperature_c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;18.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"feels_like_c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;17.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"precipitation_probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wind_speed_kph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;16.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wind_gust_kph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;27.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"condition_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"light_rain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter should also preserve the provider's original condition code and units. Converting everything to a small internal vocabulary is useful for icons, but discarding the original value makes debugging much harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one would I choose?
&lt;/h2&gt;

&lt;p&gt;There is no credible way to declare the “most accurate” provider from documentation alone. Forecast accuracy varies by country, model, lead time, terrain, and weather event. That requires a separate measurement study using the same stadiums and observation sources.&lt;/p&gt;

&lt;p&gt;Based on product fit and public documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open-Meteo&lt;/strong&gt; is the cleanest keyless choice for building the adapter and testing the user interface, provided the prototype is non-commercial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MET Norway&lt;/strong&gt; is the strongest open-data option when attribution, caching, and fair-use rules are acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WeatherAPI.com&lt;/strong&gt; has the clearest free commercial starting point for a small site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual Crossing&lt;/strong&gt; is the strongest candidate when historical match weather and commercial use are important.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenWeather&lt;/strong&gt; provides the broadest convenient one-call bundle, but its subscription and licence structure deserves careful review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tomorrow.io&lt;/strong&gt; becomes interesting when richer short-range fields and alerting matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AccuWeather&lt;/strong&gt; offers a mature location model and stadium POIs, but permanent commercial access is not free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weatherstack&lt;/strong&gt; is simple to evaluate, although forecast access starts well above its free tier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a first production shortlist, I would compare WeatherAPI.com, Visual Crossing, MET Norway, and OpenWeather against the same set of stadiums. The comparison should record missing hourly values, forecast issue times, response stability, and licensing obligations. Accuracy should be evaluated separately against observed conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next: football data APIs
&lt;/h2&gt;

&lt;p&gt;Part 3 will move to the central dependency: fixtures, live scores, match status, competitions, teams, lineups, and events. Weather and news are useful context, but the football data API determines whether the site can function at all.&lt;/p&gt;

&lt;p&gt;No weather API provider sponsored or reviewed this article before publication.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI was used to assist with research organization and English editing. The linked official sources were checked before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>football</category>
      <category>weather</category>
    </item>
    <item>
      <title>A Survey of Public APIs for Building a Football Live Score Site in 2026 — Part 1: News API Review</title>
      <dc:creator>Leo Marsh</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:34:14 +0000</pubDate>
      <link>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-1-news-api-review-5f87</link>
      <guid>https://dev.to/leomarsh886/a-survey-of-public-apis-for-building-a-football-live-score-site-in-2026-part-1-news-api-review-5f87</guid>
      <description>&lt;p&gt;A football live score site needs more than scores.&lt;/p&gt;

&lt;p&gt;The match centre may bring a visitor in, but news gives that visitor a reason to explore a team page before kickoff, return after the final whistle, and follow a competition between matchdays.&lt;/p&gt;

&lt;p&gt;This is the first part of a survey of the public APIs needed to build a football live score site in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;News APIs&lt;/li&gt;
&lt;li&gt;Weather APIs for match-day context&lt;/li&gt;
&lt;li&gt;Football data APIs&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Series scope and ground rules
&lt;/h2&gt;

&lt;p&gt;The following rules apply to every article in this series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Candidate source:&lt;/strong&gt; Every API considered comes from the &lt;a href="https://github.com/public-apis/public-apis" rel="noopener noreferrer"&gt;public-apis repository on GitHub&lt;/a&gt;. I chose it because it has more than 400,000 GitHub stars and offers a broad, well-organized set of categories and API options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review order:&lt;/strong&gt; APIs are considered in the same order in which they appear in the repository. The order is not a ranking, and providers have not paid for placement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shortlisting:&lt;/strong&gt; Every entry in the relevant category is screened, but only APIs that plausibly fit a football live score site receive a detailed review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No implied endorsement:&lt;/strong&gt; Inclusion in public-apis supplies the candidate pool; it does not mean that the repository or this series recommends an API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type of review:&lt;/strong&gt; This is a product-fit and documentation review, not a performance benchmark. Provider claims are identified as documented capabilities rather than independent test results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot date:&lt;/strong&gt; Documentation, pricing, and public usage terms were checked on &lt;strong&gt;September 5, 2026&lt;/strong&gt;. They may change, so the linked official pages remain the final source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meaning of football:&lt;/strong&gt; In this series, “football” means association football, also called soccer, unless stated otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Using the same source, order, and rules keeps the selection process consistent across all three parts.&lt;/p&gt;

&lt;p&gt;For Part 1, I started with the repository's &lt;a href="https://github.com/public-apis/public-apis#news" rel="noopener noreferrer"&gt;News section&lt;/a&gt;. It currently contains 23 entries, but most are not realistic candidates for this use case. Some are historical archives, RSS readers, single-topic feeds, or APIs for finance, spaceflight, and regional news.&lt;/p&gt;

&lt;p&gt;After screening the full list in repository order, I shortlisted seven APIs that could plausibly supply news to football team, competition, or match pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a football news feed actually needs
&lt;/h2&gt;

&lt;p&gt;A generic &lt;code&gt;sports&lt;/code&gt; category is useful, but it is not enough. Depending on the country, it may mix association football with American football, basketball, cricket, Formula 1, and other sports.&lt;/p&gt;

&lt;p&gt;For a live score site, I would look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keyword and exact-phrase search for teams, competitions, and players&lt;/li&gt;
&lt;li&gt;Source and domain filters&lt;/li&gt;
&lt;li&gt;Country and language filters&lt;/li&gt;
&lt;li&gt;Publication timestamps in a consistent format&lt;/li&gt;
&lt;li&gt;Enough freshness for pre-match and post-match coverage&lt;/li&gt;
&lt;li&gt;Stable pagination and predictable rate limits&lt;/li&gt;
&lt;li&gt;Clear rights for displaying headlines, summaries, and images&lt;/li&gt;
&lt;li&gt;A realistic path from a prototype to a public website&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last point matters more than the advertised request count. A free plan with 100 requests per day is not useful in production if its license only permits local development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shortlist at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Free allowance&lt;/th&gt;
&lt;th&gt;Free freshness&lt;/th&gt;
&lt;th&gt;Free production use&lt;/th&gt;
&lt;th&gt;Lowest listed paid plan&lt;/th&gt;
&lt;th&gt;Initial fit for a football site&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mediastack&lt;/td&gt;
&lt;td&gt;100 requests/month&lt;/td&gt;
&lt;td&gt;Free tier is mainly an evaluation tier&lt;/td&gt;
&lt;td&gt;Commercial use is listed on paid plans&lt;/td&gt;
&lt;td&gt;$24.99/month&lt;/td&gt;
&lt;td&gt;Broad coverage, but a very small free quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currents&lt;/td&gt;
&lt;td&gt;250 requests/day&lt;/td&gt;
&lt;td&gt;No artificial plan delay&lt;/td&gt;
&lt;td&gt;Metadata and link-out within stated terms; broader republication needs separate rights&lt;/td&gt;
&lt;td&gt;$69/month&lt;/td&gt;
&lt;td&gt;Strong candidate for a fresh headline feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GNews&lt;/td&gt;
&lt;td&gt;100 requests/day, up to 10 articles per request&lt;/td&gt;
&lt;td&gt;12-hour delay&lt;/td&gt;
&lt;td&gt;No; development and testing only&lt;/td&gt;
&lt;td&gt;€49.99/month&lt;/td&gt;
&lt;td&gt;Clean search API for prototypes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News API&lt;/td&gt;
&lt;td&gt;100 requests/day&lt;/td&gt;
&lt;td&gt;24-hour delay&lt;/td&gt;
&lt;td&gt;No; development and testing only&lt;/td&gt;
&lt;td&gt;$449/month&lt;/td&gt;
&lt;td&gt;Excellent query model, expensive production step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NewsData.io&lt;/td&gt;
&lt;td&gt;200 credits/day, up to 10 articles per credit&lt;/td&gt;
&lt;td&gt;12-hour delay&lt;/td&gt;
&lt;td&gt;Vendor says limited article metadata may be used commercially&lt;/td&gt;
&lt;td&gt;$199.99/month&lt;/td&gt;
&lt;td&gt;Strong multilingual coverage, but delayed on free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Guardian&lt;/td&gt;
&lt;td&gt;500 requests/day&lt;/td&gt;
&lt;td&gt;Current and historical Guardian content&lt;/td&gt;
&lt;td&gt;Developer access is non-commercial&lt;/td&gt;
&lt;td&gt;Custom quote&lt;/td&gt;
&lt;td&gt;Excellent editorial source, but not an aggregator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The News API&lt;/td&gt;
&lt;td&gt;100 requests/day, 3 articles per request&lt;/td&gt;
&lt;td&gt;Listed as real-time&lt;/td&gt;
&lt;td&gt;Not sufficiently clear for me to assume&lt;/td&gt;
&lt;td&gt;$19/month&lt;/td&gt;
&lt;td&gt;Low paid entry price; clarify rights first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prices above are the displayed monthly starting prices before taxes, custom licensing, overages, or annual discounts. “Free production use” is deliberately conservative: technical API access does not automatically grant permission to republish third-party articles or images.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Mediastack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mediastack.com/" rel="noopener noreferrer"&gt;Mediastack&lt;/a&gt; advertises coverage across more than 7,500 sources and 50 countries. Its source directory and country filters are useful for building regional football feeds rather than showing one global stream to everyone.&lt;/p&gt;

&lt;p&gt;The current &lt;a href="https://mediastack.com/signup/free" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; lists only 100 requests per month on the free plan. The Standard plan begins at $24.99 per month with 10,000 monthly requests and lists historical data and commercial use among its paid capabilities.&lt;/p&gt;

&lt;p&gt;One hundred requests per month is enough to inspect the response and build an initial adapter, but not enough to refresh a live website regularly. Even one request every hour would require roughly 720 requests in a 30-day month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Evaluating a broad, conventional REST news feed with a relatively low paid entry point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free quota is much too small for an operating news module.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Currents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://currentsapi.services/en/docs/" rel="noopener noreferrer"&gt;Currents&lt;/a&gt; provides latest-news and search endpoints with keyword, category, country, language, date, and source filters. Its current V2 search also supports cursor pagination, which is useful for avoiding unstable page-number traversal as new stories arrive.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://currentsapi.services/en/product/price" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; provides 250 requests per day, up to 20 results per request, and up to 30 days of recent history. Currents says newly indexed stories are available to every tier without an artificial plan delay.&lt;/p&gt;

&lt;p&gt;For a football site, that is more useful than a large but delayed quota. A job running every ten minutes would consume 144 requests per day, leaving some capacity for team-specific searches.&lt;/p&gt;

&lt;p&gt;The important qualification is usage rights. Currents describes self-service access around metadata, attribution, processing, and links to original sources. Republishing articles, retaining a long-term archive, or producing customer-facing derivative content may require separate terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A prototype or small headline module that links readers to the original publishers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Do not interpret API access as blanket content syndication rights.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. GNews
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.gnews.io/endpoints/search-endpoint" rel="noopener noreferrer"&gt;GNews&lt;/a&gt; has a compact API with search and top-headline endpoints. Its search syntax supports exact phrases and &lt;code&gt;AND&lt;/code&gt;, &lt;code&gt;OR&lt;/code&gt;, and &lt;code&gt;NOT&lt;/code&gt;, along with filters for language, country, date, and searched fields.&lt;/p&gt;

&lt;p&gt;That query syntax is helpful for football. A search for &lt;code&gt;City&lt;/code&gt;, &lt;code&gt;United&lt;/code&gt;, or &lt;code&gt;Inter&lt;/code&gt; by itself will produce noisy results. Boolean expressions make it possible to combine club names with competitions, locations, or known aliases.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://gnews.io/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; provides 100 requests per day, up to 10 articles per response, 30 days of history, and a 12-hour delay. It is explicitly limited to non-commercial development and testing. Paid production access starts at €49.99 per month and removes the artificial delay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Evaluating search relevance and building a non-commercial prototype.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free tier cannot power a public commercial live score product.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. News API
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://newsapi.org/docs" rel="noopener noreferrer"&gt;News API&lt;/a&gt; has one of the clearest query models in this group. Its &lt;code&gt;/v2/everything&lt;/code&gt; endpoint supports phrases, exclusions, Boolean operators, domains, languages, date ranges, and sorting by freshness or relevance. Its top-headlines endpoint also includes a &lt;code&gt;sports&lt;/code&gt; category.&lt;/p&gt;

&lt;p&gt;The problem is the gap between experimentation and production. The &lt;a href="https://newsapi.org/pricing" rel="noopener noreferrer"&gt;Developer plan&lt;/a&gt; provides 100 requests per day, but articles are delayed by 24 hours, CORS is restricted to localhost, and the plan is limited to development and testing. The displayed Business plan starts at $449 per month.&lt;/p&gt;

&lt;p&gt;This does not make News API a poor product. It means the free plan is a sandbox, not a launch plan. That distinction is easy to miss when comparing “free API” lists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; Testing sophisticated search rules and source selection during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; A substantial price jump is required before publishing a production integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. NewsData.io
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://newsdata.io/" rel="noopener noreferrer"&gt;NewsData.io&lt;/a&gt; exposes filters for keywords, publishers, countries, languages, categories, and regions. Its documented coverage is especially broad, which makes it interesting for a football product serving leagues and readers outside the English-speaking market.&lt;/p&gt;

&lt;p&gt;The vendor's &lt;a href="https://newsdata.io/blog/pricing-plan-in-newsdata-io/" rel="noopener noreferrer"&gt;2026 pricing explanation&lt;/a&gt; lists 200 credits per day on the free plan, with up to 10 articles returned per credit. Free results are delayed by 12 hours and do not include full article content.&lt;/p&gt;

&lt;p&gt;NewsData.io says the free plan can be used commercially, but that statement comes with an important boundary: its public guidance permits fields such as titles, short descriptions, publisher names, authors, dates, and times, while excluding images and full article content from republication.&lt;/p&gt;

&lt;p&gt;That can still work for a football site if the product displays a compact headline card and sends the reader to the publisher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A multilingual football news index where a delay is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; The free feed is too delayed for breaking team news, and the first paid tier is a large jump.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The Guardian Open Platform
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://open-platform.theguardian.com/" rel="noopener noreferrer"&gt;The Guardian Open Platform&lt;/a&gt; is different from the other candidates because it provides one publisher's journalism rather than aggregating many publishers.&lt;/p&gt;

&lt;p&gt;Its &lt;a href="https://open-platform.theguardian.com/access/" rel="noopener noreferrer"&gt;Developer access&lt;/a&gt; includes up to 500 calls per day, article text, and access to an archive containing more than 1.9 million pieces of content. The Guardian also has a dedicated football section and structured tags, which can produce a more coherent feed than searching an uncurated global index.&lt;/p&gt;

&lt;p&gt;Developer access is for non-commercial use. Commercial organizations and developers who want to use Guardian journalism in a product must request commercial access. Attribution and content-use requirements also apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A high-quality single-publisher football feed or a non-commercial research project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; It cannot provide the breadth of a news aggregator, and commercial use requires an agreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The News API
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.thenewsapi.com/documentation" rel="noopener noreferrer"&gt;The News API&lt;/a&gt; offers news search, top stories, source filters, locale filters, language filters, and UTC timestamps. Its free plan requires no billing details.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.thenewsapi.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; lists 100 requests per day and three articles per request on the free tier. It describes the data as real-time and historical. The Basic plan starts at $19 per month and raises the allowance to 2,500 daily requests with up to 25 articles per request.&lt;/p&gt;

&lt;p&gt;There is also a documentation inconsistency worth checking before implementation: the pricing table lists headlines and top stories on the free plan, while the endpoint documentation labels Headlines as available on the Standard plan and above.&lt;/p&gt;

&lt;p&gt;On price alone, this is one of the easiest services in the shortlist to move beyond a demo. However, I would ask the provider to confirm both the available endpoints and the intended production and content-display rights before launch. Its public terms are older and use broad non-commercial language, while the product pages describe paid application access. Those ambiguities should not be resolved by assumption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; A budget-conscious prototype with a plausible low-cost upgrade path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main limitation:&lt;/strong&gt; Confirm commercial display and redistribution rights in writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candidates I did not shortlist
&lt;/h2&gt;

&lt;p&gt;The public-apis list is a directory, not a pre-filtered product comparison. I excluded several groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Historical archives:&lt;/strong&gt; Chronicling America and Trove are valuable research collections, but not sources for current football coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSS products:&lt;/strong&gt; Feedbin and The Old Reader solve feed-reading workflows rather than broad news discovery for this use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-topic feeds:&lt;/strong&gt; DataCube AI, Florida Man, Graphs for Coronavirus, MarketAux, and Spaceflight News do not match football.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regional or format-specific products:&lt;/strong&gt; NPR One and Inshorts News are too narrow for a general football site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise or unclear-access candidates:&lt;/strong&gt; Associated Press may be valuable, but it is not a comparable self-service starting point. I also left out entries whose current documentation or production terms were not clear enough for a fair table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An API appearing in a public directory should be the beginning of due diligence, not the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query design matters more than the provider name
&lt;/h2&gt;

&lt;p&gt;Football creates unusually ambiguous searches. Consider these club names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manchester City is frequently shortened to City.&lt;/li&gt;
&lt;li&gt;Manchester United may appear as Man United or Man Utd.&lt;/li&gt;
&lt;li&gt;Inter Milan may appear as Inter or Internazionale.&lt;/li&gt;
&lt;li&gt;Paris Saint-Germain may appear as PSG.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A production integration needs a small alias dictionary and query templates. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;("Manchester United" OR "Man United" OR "Man Utd")
AND (football OR "Premier League" OR UEFA)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a match page, include both teams and the competition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;("Arsenal" AND "Liverpool") AND (football OR "Premier League")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not rely only on &lt;code&gt;category=sports&lt;/code&gt;. Retrieve a slightly broader candidate set, then rank or filter it using known team, competition, player, and venue aliases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize the response before building the UI
&lt;/h2&gt;

&lt;p&gt;Each provider uses different field names, but the website should depend on one internal shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider:article-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example headline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Short provider-supplied description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://publisher.example/article"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Publisher"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"published_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-05T12:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping a provider adapter between the API response and this model makes it possible to change suppliers without rewriting every team and match page.&lt;/p&gt;

&lt;p&gt;It also creates one place to implement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Canonical URL normalization&lt;/li&gt;
&lt;li&gt;Duplicate-title detection&lt;/li&gt;
&lt;li&gt;Missing-image fallbacks&lt;/li&gt;
&lt;li&gt;UTC timestamp parsing&lt;/li&gt;
&lt;li&gt;Source attribution&lt;/li&gt;
&lt;li&gt;Provider-specific cache rules&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A note on article images and full text
&lt;/h2&gt;

&lt;p&gt;An image URL in an API response is not automatically a license to copy or permanently host that image. The same applies to full article content.&lt;/p&gt;

&lt;p&gt;For a football live score site, the safest default product is often:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Display the headline.&lt;/li&gt;
&lt;li&gt;Display only the summary or thumbnail allowed by the provider's terms.&lt;/li&gt;
&lt;li&gt;Credit the publisher.&lt;/li&gt;
&lt;li&gt;Link directly to the original article.&lt;/li&gt;
&lt;li&gt;Store only what the applicable agreement permits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before launch, review both the API provider's terms and the underlying publisher rights. This is a product requirement, not a detail to postpone until after integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one would I choose?
&lt;/h2&gt;

&lt;p&gt;There is no honest overall winner without testing live result relevance and confirming the intended license.&lt;/p&gt;

&lt;p&gt;Based on the public documentation alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Currents&lt;/strong&gt; has the most interesting free-tier shape for a fresh, lightweight headline feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NewsData.io&lt;/strong&gt; is attractive for multilingual discovery and limited commercial metadata use, if a 12-hour delay is acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The News API&lt;/strong&gt; has the lowest displayed paid entry price, but I would clarify its usage rights first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GNews&lt;/strong&gt; has a clean query language for testing football search logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;News API&lt;/strong&gt; offers mature search controls but has the largest visible jump to production pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Guardian&lt;/strong&gt; is the strongest option when editorial consistency matters more than source breadth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mediastack&lt;/strong&gt; is easy to understand, although its free quota is an evaluation allowance rather than a usable site quota.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My practical shortlist for a prototype would be Currents, GNews, NewsData.io, and The Guardian. Before choosing one for production, I would run the same team-and-competition query set against each candidate, measure freshness and duplicates for at least seven days, and obtain explicit confirmation of the intended display rights.&lt;/p&gt;

&lt;p&gt;That live comparison is a separate benchmark. This survey answers the question that comes first: which APIs are credible enough to test?&lt;/p&gt;

&lt;h2&gt;
  
  
  Next: weather APIs for match-day context
&lt;/h2&gt;

&lt;p&gt;Part 2 will review weather APIs from the same public-apis repository. The goal is not to turn a score site into a weather app. It is to determine whether temperature, rain, wind, and historical match-day conditions can be added reliably without introducing an expensive or fragile dependency.&lt;/p&gt;

&lt;p&gt;No news API provider sponsored or reviewed this article before publication.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: AI was used to assist with research organization and English editing. The linked official sources were checked before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>football</category>
      <category>news</category>
    </item>
    <item>
      <title>How I Track Anonymous-to-Signed-In User Journeys Without Third-Party Cookies</title>
      <dc:creator>Leo Marsh</dc:creator>
      <pubDate>Thu, 03 Sep 2026 10:38:47 +0000</pubDate>
      <link>https://dev.to/leomarsh886/how-i-track-anonymous-to-signed-in-user-journeys-without-third-party-cookies-529p</link>
      <guid>https://dev.to/leomarsh886/how-i-track-anonymous-to-signed-in-user-journeys-without-third-party-cookies-529p</guid>
      <description>&lt;p&gt;Most analytics tools can tell you that someone visited &lt;code&gt;/pricing&lt;/code&gt; and that someone else created an account.&lt;/p&gt;

&lt;p&gt;The useful question is whether they were the same person.&lt;/p&gt;

&lt;p&gt;I wanted to answer questinos like these in a Laravel application:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which landing page led to a real signup?&lt;/li&gt;
&lt;li&gt;Did the visitor read the documentation before registering?&lt;/li&gt;
&lt;li&gt;Was the first visit from search, a referral, or a campaign?&lt;/li&gt;
&lt;li&gt;Which pages appaer in successful journeys?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not need cross-site tracking, advertising profiles, or a full recording of everything a visitor did. I also did not want a third-party analytics script controlling the data model.&lt;/p&gt;

&lt;p&gt;So I built a small first-party journey tracker. Public pages send a deliberately limited page-view beacon. When the visitor later signs in, the application links the recent anonymous journey to the authenticated account.&lt;/p&gt;

&lt;p&gt;This article explains the design, the parts I intentionally left out, and one important warning: &lt;strong&gt;not using cookies does not make analytics consent-free or automatically privacy-compliant&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: two random IDs
&lt;/h2&gt;

&lt;p&gt;The browser keeps two opaque identifiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visitor_id  -&amp;gt; persists between visits
session_id  -&amp;gt; lasts for the current browser session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visitor ID lives in &lt;code&gt;localStorage&lt;/code&gt;. The session ID lives in &lt;code&gt;sessionStorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Neither contains an email address, database ID, IP address, or encoded user information. They are simply random strings used to join events.&lt;/p&gt;

&lt;p&gt;Here is a simplified version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;randomId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;storedId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{24}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;journey_session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;storedId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;journey_visitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;storedId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;journey_session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existingSession&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; lets the browser recognize a later visit without adding a cookie to every HTTP request. &lt;code&gt;sessionStorage&lt;/code&gt; gives each browsing session a separate boundary.&lt;/p&gt;

&lt;p&gt;That distinction matters. A person may first arrive from search, return from a newsletter a week later, and finally register. I want both the first-touch source and the most recent acquisition source, not one value constantly overwriting the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send less data than you can
&lt;/h2&gt;

&lt;p&gt;The browser knows far more than this system needs. The easiest way to reduce privacy and security risk is not to collect unnecessary fields in the first place.&lt;/p&gt;

&lt;p&gt;My page-view payload contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The two random IDs&lt;/li&gt;
&lt;li&gt;The page path and route name&lt;/li&gt;
&lt;li&gt;The page title&lt;/li&gt;
&lt;li&gt;Whether this is the first view in the session&lt;/li&gt;
&lt;li&gt;The external referrer host and path&lt;/li&gt;
&lt;li&gt;An allowlist of standard UTM parameters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IP addresses stored by the application&lt;/li&gt;
&lt;li&gt;Full user-agent strings&lt;/li&gt;
&lt;li&gt;Browser fingerprints&lt;/li&gt;
&lt;li&gt;Full URLs with arbitrary query strings&lt;/li&gt;
&lt;li&gt;Email addresses or account details on anonymous requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The query-string rule is particularly important. URLs often contain password-reset tokens, checkout session IDs, email addresses, search terms, and other values that should never be copied into an analytics table by accident.&lt;/p&gt;

&lt;p&gt;Instead of sending &lt;code&gt;location.href&lt;/code&gt;, I send &lt;code&gt;location.pathname&lt;/code&gt; and explicitly select the campaign parameters I understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visitorId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isEntry&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;um&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_campaign&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/analytics/hit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sendBeacon()&lt;/code&gt; works well here because the browser can transmit the small payload without delaying navigation. The endpoint returns &lt;code&gt;204 No Content&lt;/code&gt; and never needs to render anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep public page views stateless
&lt;/h2&gt;

&lt;p&gt;My public marketing pages are edge-cacheable. Creating a Laravel session for every anonymous page view would add a &lt;code&gt;Set-Cookie&lt;/code&gt; header and make that caching model harder to reason about.&lt;/p&gt;

&lt;p&gt;The page-view endpoint therefore sits outside the session middleware group. It accepts the random browser identifiers but does not start a Laravel session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/analytics/hit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AnalyticsHitController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'throttle:analytics'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics.hit'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route is rate-limited independently from login and contact forms. Analytics traffic should not consume a visitor's allowance for an unrelated feature.&lt;/p&gt;

&lt;p&gt;The server validates every field even though the script generated it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;validId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^[a-z0-9]{24}$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cleanPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$withoutFragment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$withoutFragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;mb_substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client-side filtering is a convenience. Server-side filtering is the boundary.&lt;/p&gt;

&lt;p&gt;I also normalize referrer hosts, reject malformed values, cap string lengths, and treat redirects from authentication and payment providers as part of the existing journey rather than a new acquisition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tables are enough
&lt;/h2&gt;

&lt;p&gt;The first table stores one row per random visitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_visitors'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'first_seen_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_seen_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'first_landing_path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'first_source'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'first_medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_touch_at'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_landing_path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_source'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second table is the event stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_page_views'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'path'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'route'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_entry'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'source'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'campaign'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;191&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'session_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visitor row makes acquisition reports cheap. The page-view rows preserve the actual sequence.&lt;/p&gt;

&lt;p&gt;I keep  first touch and last touch separate. First touch answers "How did this person originally find the site?" Last touch answers "What brought them back before conversion?" Mixing the two produces confident-looking reports that answer neither question well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Link the journey only after authentication
&lt;/h2&gt;

&lt;p&gt;Anonymous page views keep &lt;code&gt;user_id&lt;/code&gt; as &lt;code&gt;null&lt;/code&gt;. An authenticated page includes a second beacon to a session-protected route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/analytics/identify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AnalyticsIdentifyController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics.identify'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser sends the same visitor and session IDs, plus the normal CSRF token. The server gets the account from the authenticated Laravel session. It never accepts a user ID supplied by JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$visitorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_visitors'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$visitorId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'last_seen_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_page_views'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$visitorId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_page_views'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'visitor_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$visitorId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'session_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 30-day boundary is deliberate. A permanent visitor ID on a shared computer is weak evidence that activity from six months ago belongs to the person signing in today.&lt;/p&gt;

&lt;p&gt;Even within 30 days, this association is best-effort attribution, not identity proof. I would never use it for authorization, fraud decisions, security alerts, or anything that materially affects a person.&lt;/p&gt;

&lt;p&gt;For a product with frequent shared-device use, I would avoid retroactive visitor-level linking entirely or introduce an explicit &lt;code&gt;visitor_user_links&lt;/code&gt; table with time ranges. The data model should admit uncertainty rather than hide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle race conditions explicitly
&lt;/h2&gt;

&lt;p&gt;The identify request and the first page-view beacon can arrive in either order.&lt;/p&gt;

&lt;p&gt;If identification arrives first, the visitor row may exist before its acquisition fields are populated. If the page view arrives first, it initially has no user ID.&lt;/p&gt;

&lt;p&gt;The two endpoints therefore need to be idempotent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both may create the visitor row with &lt;code&gt;insertOrIgnore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The hit endpoint fills first-touch fields only when they are still empty.&lt;/li&gt;
&lt;li&gt;The identify endpoint backfills recent anonymous views.&lt;/li&gt;
&lt;li&gt;New page views copy the user ID already associated with the visitor.&lt;/li&gt;
&lt;li&gt;Updates run inside transactions where ordering matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the kind of race that almost never appears in manual testing and eventually appears in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention is part of the feature
&lt;/h2&gt;

&lt;p&gt;Analytics tables quietly become permanent archives unless deletion is designed from the start.&lt;/p&gt;

&lt;p&gt;I use a scheduled task to delete old page views and remove stale anonymous visitor rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_page_views'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'viewed_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'analytics_visitors'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'last_seen_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct duration depends on the product and the questions the data is meant to answer. The important part is choosing a duration rather than inheriting "forever" as an accidental default.&lt;/p&gt;

&lt;p&gt;Deletion and access workflows should also account for journey data once it has been associated with a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookie-free does not mean consent-free
&lt;/h2&gt;

&lt;p&gt;This architecture avoids third-party cookies and third-party analytics scripts. It does &lt;strong&gt;not&lt;/strong&gt; avoid browser storage: &lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;sessionStorage&lt;/code&gt; are storage technologies too.&lt;/p&gt;

&lt;p&gt;Rules vary by jurisdiction and purpose. For example, the UK Information Commissioner's Office states that storage-and-access rules can apply to web storage, including &lt;code&gt;localStorage&lt;/code&gt;, whether it is used in a first-party or third-party context.&lt;/p&gt;

&lt;p&gt;Reference: &lt;a href="https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/what-are-storage-and-access-technologies/" rel="noopener noreferrer"&gt;ICO guidance on storage and access technologies&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So "we do not use cookies" is not a legal strategy. Document what is stored, why it is stored, how long it lasts, whether consent is required, and how a person can exercise applicable choices and rights. Get advice appropriate to the regions you serve.&lt;/p&gt;

&lt;p&gt;Privacy-conscious engineering is not about finding a different browser API and declaring victory. It is about purpose limitation, data minimization, retention, access control, and honest communication.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this small system gives me
&lt;/h2&gt;

&lt;p&gt;With two identifiers, two tables, and two endpoints, I can reconstruct a journey like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;organic search
  -&amp;gt; landing page
  -&amp;gt; pricing
  -&amp;gt; documentation
  -&amp;gt; sign in
  -&amp;gt; dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can connect acquisition to real product outcomes without sending browsing data to an advertising network or copying sensitive query strings into a generic analytics platform.&lt;/p&gt;

&lt;p&gt;The system is intentionally boring. It does not record the mouse, fingerprint the browser, or guess who somebody is across devices. It collects enough information to answer a small set of product questions and stops there.&lt;/p&gt;

&lt;p&gt;That constraint is the feature.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This article was written with AI assistance and edited from a real Laravel implementation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>laravel</category>
      <category>webdev</category>
      <category>analytics</category>
    </item>
    <item>
      <title>The first thing I check before trusting any dataset</title>
      <dc:creator>Leo Marsh</dc:creator>
      <pubDate>Sun, 30 Aug 2026 07:42:02 +0000</pubDate>
      <link>https://dev.to/leomarsh886/the-first-thing-i-check-before-trusting-any-dataset-5bm3</link>
      <guid>https://dev.to/leomarsh886/the-first-thing-i-check-before-trusting-any-dataset-5bm3</guid>
      <description>&lt;p&gt;Every dataset I've ever received came with a description. Almost none of them matched the data.&lt;/p&gt;

&lt;p&gt;Not because anyone was lying. The person who wrote the README usually described the dataset as it was &lt;em&gt;supposed&lt;/em&gt; to be, or as it was six months ago. The data itself has been quietly drifting since then.&lt;/p&gt;

&lt;p&gt;So before I compute a single mean, I run the same short ritual. It takes five minutes and has saved me from shipping wrong conclusions more times than I can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count the nulls, then count the fake nulls
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;df.isna().sum()&lt;/code&gt; is the obvious step. The less obvious one is looking for values that &lt;em&gt;mean&lt;/em&gt; null but aren't encoded as null:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
suspects = ["", " ", "-", "N/A", "NA", "null", "None", "0", "-1", "9999"]
for col in df.select_dtypes("object"):
    hits = df[col].isin(suspects).sum()
    if hits:
        print(f"{col}: {hits} suspicious values")

A column with 0% nulls and 30% dashes is a 30%-null column. The mean you'd compute on it is fiction.

2. Look at the distribution, not the summary

df.describe() tells you the mean is 42. It does not tell you that half the rows are exactly 0 and the other half are around 84. Those are very different datasets with the same summary statistics.

df["value"].value_counts().head(10)
df["value"].plot.hist(bins=50)

The value_counts() call is the underrated one. If a continuous column has a single value appearing thousands of times, that value is almost always a default, a cap, or a placeholder — not a measurement.

3. Check the time column against your assumptions

If there's a timestamp, I always ask three questions:

- What timezone? The README says UTC. Is it? Check whether "daily" events cluster around midnight or around 8am — that tells you more than the docs.
- Is it complete? Group by day and count rows. Gaps and sudden drops usually mean an ingestion failure, not a real-world quiet day.
- Is it monotonic where it should be? created_at should never be later than updated_at. Yet it often is.

daily = df.groupby(df["ts"].dt.date).size()
print(daily.describe())
print(daily[daily &amp;lt; daily.median() * 0.5])  # suspiciously thin days

Why this matters

None of this is sophisticated. That's the point. The expensive mistakes in analysis rarely come from choosing the wrong model — they come from feeding a correct model data that doesn't mean what you think it means.

Five minutes of paranoia up front is much cheaper than explaining to someone why last month's numbers were wrong.

What's your first check when a new dataset lands on your desk? I'd genuinely like to add to this list.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>datascience</category>
      <category>python</category>
      <category>pandas</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
