Automate SEO Audits in FastAPI with the SEOPeek API
FastAPI has become the go-to framework for building high-performance Python APIs—from machine learning inference endpoints to content management backends and headless CMS services. Every new route, every template change, every dynamically generated page is an opportunity for SEO regressions to slip through unnoticed. Missing meta descriptions, broken canonical tags, empty Open Graph images—these problems accumulate silently until your organic traffic drops. This guide shows you how to wire the SEOPeek API into your FastAPI project with httpx, async/await, BackgroundTasks, Pydantic models, dependency injection, SQLModel for audit history, and pytest for CI/CD enforcement—so every page is audited automatically and every regression is caught before it reaches production.
- Why FastAPI devs need automated SEO auditing
- Quick start: single GET request with httpx
- FastAPI endpoint that wraps SEOPeek for your clients
- Background tasks for batch SEO auditing
- Async batch scanning with asyncio.gather
- Pydantic models for SEOPeek responses
- Dependency injection for API key management
- Storing audit history with SQLModel
- CI/CD: pytest tests that assert minimum SEO scores
- FAQ
1. Why FastAPI Devs Need Automated SEO Auditing
Most Python teams treat SEO as a frontend concern. The marketing team writes meta tags during the initial launch, and nobody touches them again. But modern FastAPI applications are dynamic. Server-side rendered templates change. New routes appear weekly. Content management systems push pages daily. Each change can silently break on-page SEO signals that Google relies on to rank your pages.
The Python ecosystem has lacked a clean, async-native SEO auditing solution. Running Lighthouse from Python means spawning a Node.js subprocess, managing headless Chrome, and parsing stdout. It is fragile, resource-heavy, and painful to maintain—especially in async applications where blocking calls are a cardinal sin.
The SEOPeek API changes this. It is a single HTTP GET endpoint that returns a structured JSON response with an SEO score, grade, and detailed check results. If your FastAPI app can make an HTTP request—and of course it can—it can audit any URL for SEO in under two seconds, fully async, without blocking your event loop.
Here is what an automated FastAPI SEO audit pipeline gives you:
- Instant regression detection — catch missing titles, broken canonical URLs, and empty meta descriptions before they reach production
- Async-native performance — audit dozens of URLs concurrently with
asyncio.gatherandhttpx.AsyncClient - Background task processing — kick off batch audits without blocking API responses using
BackgroundTasks - Type-safe responses — model every field of the SEOPeek response with Pydantic for validation and IDE autocompletion
- CI/CD enforcement — fail pytest runs if any page scores below your SEO threshold
2. Quick Start: Single GET Request with httpx
Install httpx—the async-capable HTTP client that pairs perfectly with FastAPI:
pip install httpx
Now make your first SEOPeek audit call. This works in any Python script, notebook, or FastAPI route:
import httpx
SEOPEEK_URL = (
"https://us-central1-todd-agent-prod.cloudfunctions.net"
"/seopeekApi/api/v1/audit"
)
async def audit_url(url: str) -> dict:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(SEOPEEK_URL, params={"url": url})
response.raise_for_status()
return response.json()
# Example usage
import asyncio
result = asyncio.run(audit_url("https://example.com"))
print(f"Score: {result['score']}/100 (Grade: {result['grade']})")
That is it. One GET request, one JSON response. No browser dependencies, no Node.js subprocess, no API key required for the free tier. You get 50 audits per day on the free plan—enough for development and small-scale monitoring.
Tip: The free tier requires no API key. Just pass the url query parameter and you are up and running. For higher volumes, the Starter plan ($9/month) provides 1,000 audits/day and the Pro plan ($29/month) gives you 10,000.
3. FastAPI Endpoint That Wraps SEOPeek
A common pattern is to expose your own /api/seo-audit endpoint that proxies SEOPeek. This lets you add authentication, rate limiting, caching, and custom logic on top of the raw audit data. Your clients call your API, and you call SEOPeek behind the scenes:
# main.py
from fastapi import FastAPI, HTTPException, Query
import httpx
app = FastAPI(title="My SEO Service")
SEOPEEK_URL = (
"https://us-central1-todd-agent-prod.cloudfunctions.net"
"/seopeekApi/api/v1/audit"
)
@app.get("/api/seo-audit")
async def seo_audit(url: str = Query(..., description="URL to audit")):
"""Proxy SEOPeek audit with custom error handling."""
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.get(SEOPEEK_URL, params={"url": url})
response.raise_for_status()
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="SEOPeek API timed out")
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"SEOPeek API error: {e.response.text}"
)
data = response.json()
return {
"url": url,
"score": data.get("score", 0),
"grade": data.get("grade", "F"),
"checks": data.get("checks", {}),
"passing": data.get("score", 0) >= 70,
}
Run it with uvicorn main:app --reload and hit http://localhost:8000/api/seo-audit?url=https://example.com. Your clients get a clean, authenticated endpoint. You control caching, rate limits, and response shaping. FastAPI automatically generates interactive API docs at /docs with your endpoint fully documented.
4. Background Tasks for Batch SEO Auditing
FastAPI's BackgroundTasks let you kick off work after returning a response to the client. This is ideal for batch SEO auditing where you want to accept a list of URLs, immediately return a 202 Accepted, and process the audits asynchronously:
from fastapi import BackgroundTasks
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# In-memory store for demo (use a database in production)
audit_jobs: dict[str, dict] = {}
async def run_batch_audit(job_id: str, urls: list[str]):
"""Background task that audits multiple URLs."""
results = []
async with httpx.AsyncClient(timeout=30.0) as client:
for url in urls:
try:
response = await client.get(
SEOPEEK_URL, params={"url": url}
)
response.raise_for_status()
data = response.json()
results.append({
"url": url,
"score": data.get("score", 0),
"grade": data.get("grade", "F"),
"passing": data.get("score", 0) >= 70,
})
logger.info(
f"[{job_id}] Audited {url}: "
f"{data.get('score')}/100"
)
except Exception as e:
results.append({"url": url, "error": str(e)})
logger.error(f"[{job_id}] Failed {url}: {e}")
audit_jobs[job_id]["status"] = "complete"
audit_jobs[job_id]["results"] = results
audit_jobs[job_id]["completed_at"] = (
datetime.utcnow().isoformat()
)
@app.post("/api/seo-audit/batch", status_code=202)
async def batch_audit(
urls: list[str],
background_tasks: BackgroundTasks,
):
"""Accept URLs and audit them in the background."""
job_id = f"job_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
audit_jobs[job_id] = {
"status": "processing",
"submitted": len(urls),
}
background_tasks.add_task(run_batch_audit, job_id, urls)
return {
"job_id": job_id,
"status": "processing",
"urls_submitted": len(urls),
}
@app.get("/api/seo-audit/batch/{job_id}")
async def get_batch_status(job_id: str):
"""Check the status of a batch audit job."""
if job_id not in audit_jobs:
raise HTTPException(status_code=404, detail="Job not found")
return audit_jobs[job_id]
Submit a batch with POST /api/seo-audit/batch and a JSON body of URLs. Poll GET /api/seo-audit/batch/{job_id} for results. The client never waits for the audits to finish—they get an immediate 202 response with a job ID to check later.
5. Async Batch Scanning with asyncio.gather
When you need results immediately rather than polling, asyncio.gather with httpx.AsyncClient lets you audit many URLs concurrently. A semaphore prevents you from overwhelming the SEOPeek API:
import asyncio
async def audit_urls_concurrent(
urls: list[str],
max_concurrent: int = 5,
min_score: int = 70,
) -> list[dict]:
"""Audit multiple URLs concurrently with rate limiting."""
semaphore = asyncio.Semaphore(max_concurrent)
async def audit_one(
client: httpx.AsyncClient, url: str
) -> dict:
async with semaphore:
try:
response = await client.get(
SEOPEEK_URL, params={"url": url}
)
response.raise_for_status()
data = response.json()
score = data.get("score", 0)
return {
"url": url,
"score": score,
"grade": data.get("grade", "F"),
"passing": score >= min_score,
"failing_checks": [
k for k, v in data.get("checks", {}).items()
if not v.get("pass", True)
],
}
except Exception as e:
return {"url": url, "error": str(e)}
async with httpx.AsyncClient(timeout=30.0) as client:
tasks = [audit_one(client, url) for url in urls]
results = await asyncio.gather(*tasks)
return list(results)
@app.get("/api/seo-audit/scan")
async def scan_urls(urls: list[str] = Query(...)):
"""Audit multiple URLs concurrently and return results."""
results = await audit_urls_concurrent(urls)
passing = sum(1 for r in results if r.get("passing"))
return {
"total": len(results),
"passing": passing,
"failing": len(results) - passing,
"results": results,
}
With five concurrent requests, you can audit 50 URLs in under 30 seconds. The asyncio.Semaphore ensures you never overwhelm the SEOPeek API, and each result is returned in a structured dictionary. On the Starter plan with 1,000 daily audits, you can run this scan 20 times a day across your entire sitemap.
Performance note: Reuse a single httpx.AsyncClient instance across requests for connection pooling. Creating a new client per request wastes TCP connections and adds latency. In production, inject the client via FastAPI's dependency injection system, as shown in section 7.
6. Pydantic Models for SEOPeek Responses
FastAPI and Pydantic are inseparable. Modeling the SEOPeek response with Pydantic gives you automatic validation, IDE autocompletion, and clean serialization. Define these models once and use them everywhere:
from pydantic import BaseModel, Field
from datetime import datetime
class SeoCheck(BaseModel):
"""Individual SEO check result."""
passed: bool = Field(alias="pass")
message: str = ""
class Config:
populate_by_name = True
class SeoAuditResponse(BaseModel):
"""Structured SEOPeek API response."""
url: str
score: int = Field(ge=0, le=100)
grade: str
checks: dict[str, SeoCheck] = {}
audited_at: datetime = Field(
default_factory=datetime.utcnow
)
@property
def passing(self) -> bool:
return self.score >= 70
@property
def failing_checks(self) -> list[str]:
return [
name for name, check in self.checks.items()
if not check.passed
]
@property
def passing_checks(self) -> list[str]:
return [
name for name, check in self.checks.items()
if check.passed
]
class BatchAuditRequest(BaseModel):
"""Request body for batch auditing."""
urls: list[str] = Field(..., min_length=1, max_length=100)
min_score: int = Field(default=70, ge=0, le=100)
class BatchAuditSummary(BaseModel):
"""Summary of a batch audit run."""
total: int
passing: int
failing: int
average_score: float
results: list[SeoAuditResponse]
Now your endpoint signatures carry full type information. FastAPI uses these models to validate incoming requests, serialize outgoing responses, and generate accurate OpenAPI documentation automatically:
@app.post(
"/api/seo-audit/batch-sync",
response_model=BatchAuditSummary,
)
async def batch_audit_sync(request: BatchAuditRequest):
"""Audit multiple URLs and return typed results."""
results: list[SeoAuditResponse] = []
async with httpx.AsyncClient(timeout=30.0) as client:
for url in request.urls:
response = await client.get(
SEOPEEK_URL, params={"url": url}
)
response.raise_for_status()
data = response.json()
data["url"] = url
results.append(SeoAuditResponse(**data))
passing = [
r for r in results
if r.score >= request.min_score
]
avg = (
sum(r.score for r in results) / len(results)
if results else 0
)
return BatchAuditSummary(
total=len(results),
passing=len(passing),
failing=len(results) - len(passing),
average_score=round(avg, 1),
results=results,
)
Every field is validated, every response is serialized cleanly, and your IDE gives you autocompletion on every property. Your API consumers get a fully typed OpenAPI schema they can use to generate client libraries in any language.
7. Dependency Injection for API Key Management
FastAPI's dependency injection system is the cleanest way to manage configuration and shared resources. Create a dependency that provides a configured httpx client and API settings without hardcoding anything:
from fastapi import Depends
from functools import lru_cache
from pydantic_settings import BaseSettings
class SeopeekSettings(BaseSettings):
"""Configuration loaded from environment variables."""
seopeek_base_url: str = (
"https://us-central1-todd-agent-prod.cloudfunctions.net"
"/seopeekApi/api/v1/audit"
)
seopeek_api_key: str = "" # Empty for free tier
seopeek_timeout: float = 30.0
seopeek_min_score: int = 70
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> SeopeekSettings:
return SeopeekSettings()
async def get_seopeek_client(
settings: SeopeekSettings = Depends(get_settings),
):
"""Provide a configured httpx client."""
headers = {}
if settings.seopeek_api_key:
headers["Authorization"] = (
f"Bearer {settings.seopeek_api_key}"
)
client = httpx.AsyncClient(
timeout=settings.seopeek_timeout,
headers=headers,
)
try:
yield client
finally:
await client.aclose()
@app.get("/api/seo-audit/v2")
async def seo_audit_v2(
url: str = Query(...),
client: httpx.AsyncClient = Depends(get_seopeek_client),
settings: SeopeekSettings = Depends(get_settings),
):
"""SEO audit endpoint using dependency injection."""
response = await client.get(
settings.seopeek_base_url,
params={"url": url},
)
response.raise_for_status()
data = response.json()
data["url"] = url
result = SeoAuditResponse(**data)
return {
"result": result,
"meets_threshold": (
result.score >= settings.seopeek_min_score
),
"threshold": settings.seopeek_min_score,
}
Configure via environment variables or a .env file:
# .env
SEOPEEK_API_KEY=your_api_key_here
SEOPEEK_MIN_SCORE=75
SEOPEEK_TIMEOUT=20.0
This pattern keeps your API key out of source code, makes configuration testable with dependency overrides, and ensures the httpx client is properly closed after each request. In tests, you can override get_seopeek_client with app.dependency_overrides to return a mock client—no real HTTP calls needed.
Get 50 Free SEO Audits Per Day
The SEOPeek API is free for up to 50 audits/day. No API key required. Drop it into your FastAPI app in minutes.
View Pricing & Get Started →8. Storing Audit History with SQLModel
To track SEO trends over time, persist audit results in a database. SQLModel—created by the same author as FastAPI—combines SQLAlchemy and Pydantic into a single model definition. It feels native to FastAPI because it is designed by the same person. Define your table model and wire it into your audit flow:
from sqlmodel import SQLModel, Field, Session
from sqlmodel import create_engine, select
from datetime import datetime
from typing import Optional
class SeoAuditRecord(SQLModel, table=True):
"""Database table for SEO audit history."""
__tablename__ = "seo_audits"
id: Optional[int] = Field(
default=None, primary_key=True
)
url: str = Field(index=True, max_length=2048)
score: int
grade: str = Field(max_length=4)
failing_checks: int = 0
checks_json: Optional[str] = None
created_at: datetime = Field(
default_factory=datetime.utcnow, index=True
)
DATABASE_URL = "sqlite:///./seo_audits.db"
engine = create_engine(DATABASE_URL)
def create_tables():
SQLModel.metadata.create_all(engine)
@app.on_event("startup")
def on_startup():
create_tables()
def get_session():
with Session(engine) as session:
yield session
@app.post("/api/seo-audit/save")
async def audit_and_save(
url: str = Query(...),
client: httpx.AsyncClient = Depends(get_seopeek_client),
settings: SeopeekSettings = Depends(get_settings),
session: Session = Depends(get_session),
):
"""Audit a URL and persist the result."""
response = await client.get(
settings.seopeek_base_url,
params={"url": url},
)
response.raise_for_status()
data = response.json()
import json
record = SeoAuditRecord(
url=url,
score=data.get("score", 0),
grade=data.get("grade", "F"),
failing_checks=sum(
1 for v in data.get("checks", {}).values()
if not v.get("pass", True)
),
checks_json=json.dumps(data.get("checks", {})),
)
session.add(record)
session.commit()
session.refresh(record)
return {
"id": record.id,
"score": record.score,
"grade": record.grade,
}
@app.get("/api/seo-audit/history")
async def audit_history(
url: Optional[str] = None,
limit: int = Query(default=25, le=100),
session: Session = Depends(get_session),
):
"""Retrieve stored audit history."""
statement = select(SeoAuditRecord).order_by(
SeoAuditRecord.created_at.desc()
)
if url:
statement = statement.where(
SeoAuditRecord.url == url
)
statement = statement.limit(limit)
results = session.exec(statement).all()
return {"count": len(results), "audits": results}
Every audit is now persisted with a timestamp. Query the /api/seo-audit/history endpoint to see how SEO scores trend over time for any URL. Swap SQLite for PostgreSQL in production by changing the DATABASE_URL connection string—SQLModel handles the rest transparently.
Tip: Combine this with the background tasks from section 4. Run nightly batch audits that save results to the database, then build a dashboard that visualizes score trends per URL. The stored checks_json field lets you drill into exactly which checks are failing over time.
9. CI/CD: pytest Tests That Assert Minimum SEO Scores
The most impactful place to catch SEO regressions is in your CI/CD pipeline. Write a pytest test that calls the SEOPeek API against your staging environment and asserts minimum scores. If any page fails, the build fails:
# tests/test_seo_audit.py
import httpx
import pytest
SEOPEEK_URL = (
"https://us-central1-todd-agent-prod.cloudfunctions.net"
"/seopeekApi/api/v1/audit"
)
MIN_SCORE = 70
PAGES_TO_AUDIT = [
"https://staging.yourapp.com",
"https://staging.yourapp.com/pricing",
"https://staging.yourapp.com/features",
"https://staging.yourapp.com/blog",
"https://staging.yourapp.com/docs",
]
@pytest.mark.parametrize("url", PAGES_TO_AUDIT)
@pytest.mark.asyncio
async def test_page_meets_minimum_seo_score(url: str):
"""Each page must score at least MIN_SCORE."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
SEOPEEK_URL, params={"url": url}
)
response.raise_for_status()
data = response.json()
score = data.get("score", 0)
grade = data.get("grade", "F")
assert score >= MIN_SCORE, (
f"{url} scored {score}/100 (grade: {grade}). "
f"Minimum required: {MIN_SCORE}."
)
@pytest.mark.asyncio
async def test_homepage_has_critical_seo_elements():
"""Homepage must pass title, description, and H1."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
SEOPEEK_URL,
params={"url": "https://staging.yourapp.com"},
)
response.raise_for_status()
checks = response.json().get("checks", {})
assert checks.get("title", {}).get("pass"), \
"Homepage must have a title tag"
assert checks.get("meta_description", {}).get("pass"), \
"Homepage must have a meta description"
assert checks.get("h1", {}).get("pass"), \
"Homepage must have an H1 tag"
Install the test dependencies and run:
pip install pytest pytest-asyncio httpx
# Run the SEO audit tests
pytest tests/test_seo_audit.py -v
In a GitHub Actions workflow:
# .github/workflows/seo-audit.yml
name: SEO Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
seo-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install pytest pytest-asyncio httpx
- name: Run SEO audit tests
run: pytest tests/test_seo_audit.py -v --tb=short
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: seo-test-results
path: pytest-results/
Every pull request now runs SEO checks against staging. If a developer accidentally removes a meta description or breaks a canonical URL, the build fails with a clear message showing exactly which page and which check failed. No SEO regression reaches production unnoticed.
Pro tip: Use pytest markers to separate SEO tests from unit tests. Add @pytest.mark.seo to your test functions and run them selectively with pytest -m seo. This lets you run SEO audits on a different schedule or only in certain CI stages without slowing down your main test suite.
Need More Than 50 Audits Per Day?
The Starter plan ($9/month) gives you 1,000 audits/day. The Pro plan ($29/month) gives you 10,000. Perfect for batch scanning entire sitemaps in CI/CD.
See All Plans →FAQ
Does the SEOPeek API require an API key for FastAPI integration?
No. The free tier includes 50 audits per day with no API key required. Just send a GET request with a url query parameter. For higher volumes, the Starter plan provides 1,000 audits/day at $9/month and the Pro plan provides 10,000 audits/day at $29/month.
Should I use httpx or requests to call the SEOPeek API from FastAPI?
Use httpx with its AsyncClient. FastAPI is built on async Python, and using synchronous libraries like requests blocks the event loop. httpx provides a nearly identical API to requests but supports async/await natively, making it the ideal HTTP client for FastAPI applications.
Can I use FastAPI BackgroundTasks for batch SEO auditing?
Yes. FastAPI's BackgroundTasks let you kick off SEO audits after returning a response to the client. This is perfect for batch scanning where you do not need results immediately. For real-time concurrent results, use asyncio.gather with httpx.AsyncClient to run multiple audits in parallel.
How does SEOPeek compare to running Lighthouse from Python?
Lighthouse requires a headless Chrome instance and Node.js subprocess management, which is fragile and resource-heavy in Python. SEOPeek is a single HTTP GET request that returns JSON in under 2 seconds. No browser dependencies, no subprocess.run() hacks. Just pip install httpx and call the API.
Can I run SEOPeek audits in my pytest CI pipeline?
Absolutely. Write a pytest test that calls the SEOPeek API with httpx and asserts minimum scores. Run it with pytest in your CI workflow. If any page falls below your threshold, the test fails and the build breaks. The guide above includes a complete test file you can copy directly into your project.
The Peek Suite
SEOPeek is part of a family of developer-focused audit tools. Explore the full suite at todd-agent-prod.web.app/suite.