You don't need a SaaS observability product to know when your site's SEO score regresses. With the SEO Score API and ~100 lines of Python, you can stand up a self-hosted dashboard that watches a list of URLs, surfaces score changes the moment they happen, and renders nicely on a TV in the office.
Rather than show you a screenshot, we built the dashboard into the site so you can use it before you write a line of code.
See it running, then take the source
→ Open the live dashboard — every card is a real audit through this API. You can add your own URLs, watch the scores update on a 60-second cycle, and the cards turn green/yellow/red as the score moves. Ours is preloaded with
seoscoreapi.com,github.com,stackoverflow.com,fastapi.tiangolo.com, andnews.ycombinator.com.↓ Download
dashboard.py— the same dashboard as a single Python file you can run locally.pip install flask seoscoreapi, two env vars,python dashboard.py. That's the whole setup.
The rest of this post is a quick tour of how the file is organized, the three or four parts that are interesting, and where you'd extend it for your own use.
What the dashboard actually is
A Flask app with one route, one HTML template, and a background polling thread. About 95 lines of Python including blank lines and the inline template:
| Responsibility | Lines | What it does |
|---|---|---|
refresh_one(url) |
~20 | Calls audit() + history() and writes the result into a shared state dict |
poll_loop() + thread |
6 | Walks the URL list every N seconds, sequential to be polite to the API |
TEMPLATE (HTML+CSS) |
~50 | Renders cards from state — score, grade, delta, category breakdown, SVG sparkline |
home() route |
3 | Renders the template with a snapshot of state |
| Setup (env vars, lock, app) | ~10 | Reads SEOSCORE_API_KEY and DASHBOARD_URLS, starts the polling thread |
The whole thing is intentionally one file. No database, no Redis, no Celery. State lives in a Python dict guarded by a lock; that's enough for up to ~100 URLs comfortably.
The interesting bit: the polling loop
Every other piece of the file is template or boilerplate. The one thing worth reading is how the dashboard avoids blocking the page while audits are running:
state = {url: {"loading": True} for url in URLS}
state_lock = threading.Lock()
def refresh_one(url):
result = audit(url, api_key=API_KEY)
hist = history(url, api_key=API_KEY, limit=30).get("series", [])
prev = state.get(url, {}).get("score")
delta = (result["score"] - prev) if prev is not None else None
with state_lock:
state[url] = {
"score": result["score"],
"grade": result["grade"],
"categories": {k: v.get("score") for k, v in result.get("audit", {}).items()},
"delta": delta,
"spark": [h["score"] for h in hist[-30:]],
"fetched_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
...
}
def poll_loop():
while True:
for url in URLS:
refresh_one(url)
time.sleep(POLL_INTERVAL)
threading.Thread(target=poll_loop, daemon=True).start()
The Flask route just reads state under the lock and renders. The user always gets an instant page — even when audit() is mid-flight in the background.
The sparkline math
Sparklines are an inline SVG <polyline> whose points are computed in the Jinja template, so the dashboard works without any JavaScript framework:
<svg viewBox="0 0 100 30" preserveAspectRatio="none">
<polyline fill="none" stroke="#60a5fa" stroke-width="1.5"
points="{% for s in data.spark %}{{ loop.index0 * (100 / (data.spark|length - 1)) }},{{ 30 - (s * 0.3) }} {% endfor %}" />
</svg>
viewBox="0 0 100 30" plus preserveAspectRatio="none" lets the line stretch to fill any width. The y-coordinate 30 - score * 0.3 works because scores are 0–100; for a different range, adjust the multiplier or normalize values first.
How does the dashboard handle API rate limits?
The default 15-minute poll interval means each URL is audited 4 times an hour. With 25 URLs that's 100 audits/hour, comfortably within the Pro plan's 60 RPM limit (3,600/hour). On Starter (10 RPM) with more than a handful of URLs, raise POLL_INTERVAL_SECONDS to 1800 (30 min) or 3600 (60 min).
Can I get alerts on score drops?
Yes — two options. The simplest: extend refresh_one() to call a webhook when delta <= -5. The cleaner option is to use the API's built-in add_monitor(url, webhook_url=...), which polls server-side and posts to a Slack-formatted webhook on threshold breaches without you running the dashboard at all. The dashboard is for at-a-glance status; monitors are for paging.
What about deploying this somewhere?
A 100-line Flask app drops into a Dockerfile in three lines: FROM python:3.12-slim, RUN pip install flask seoscoreapi, CMD python dashboard.py. Run it on any VPS, Fly.io, Railway, a homelab Pi connected to a hallway TV, or as a single Kubernetes pod. There is no state to migrate, no DB to back up.
What's the minimum tier needed?
Free tier (5 audits/day) works for a single URL with hourly polls. For multiple URLs and 15-minute polling you'll want Starter ($5/mo, 200 audits/mo, 10 RPM) at minimum. Pro is the comfortable tier for an agency dashboard watching 25 client URLs every 15 minutes — that's roughly 2,400 audits a month against a 5,000 limit.
Where does it go from here?
Three obvious extensions, in order of how often we get asked:
- Per-URL alert thresholds. Add a config dict mapping each URL to its own
critical_dropandwarning_drop. A homepage might warrant a 3-point alert; a blog post can tolerate 8. - Slack/Discord posts on regression. When
data["delta"] <= -5, fire a webhook with the URL, old/new scores, and a link toreport_url(domain)for the full diff. - Multi-team views. A
?team=growthquery param that filters the URL list to a subset gives every team their own page on the same dashboard instance.
Each is 5–10 lines on top of the baseline because the data is already in state.
If you build something interesting on top of this, send it in. Try the live demo and grab the source to start.