SEO report API
Call the API from code

SEO report API in Python: audit a site and read the findings

A standard-library Python script that audits one site with the SEOReport API, waits for the report, and prints the score, band and top three fixes.

This script runs one SEOReport audit end to end: it creates the report, polls it until it finishes, downloads the JSON diagnosis, and prints the overall score, its band and the first three open findings with their fixes. [1][2]

It uses only Python's standard library, urllib.request and urllib.error for HTTP plus json, os, sys and time, so there is nothing to pip install. [3][4][5]

What you need

A paid SEOReport plan and an API key created in your account settings. API access is account-linked and key-gated: every request carries the key as a Bearer token, which the API reference shows as Authorization: Bearer sr__live_your_api_key. [2][1]

Python 3. The script reads the key from the SEOREPORT_API_KEY environment variable through os.environ, so the key never sits in the source file or in version control. [7][2]

Each new site uses one of your plan's credits; running a site you already have a report for again does not. Credits reset on the first day of each month (UTC) and never roll over. The script audits https://example.com unless you pass another URL as its first argument. [1]

The full script

Save it as recipe.py and run python3 recipe.py https://your-site.example with SEOREPORT_API_KEY set. [2]

recipe.pypython
#!/usr/bin/env python3
"""Audit one site with the SEOReport API and print its top three fixes.

Usage: SEOREPORT_API_KEY=... python3 recipe.py [https://example.com]
Standard library only: nothing to install.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://seoreport.dev/api/v1/reports"
MAX_WAIT_SECONDS = 300  # stop waiting after five minutes
DEFAULT_POLL_MS = 5000  # used only when the API sends no pollAfterMs
TERMINAL_FAILURES = {"failed", "dead_lettered", "cancelled"}


class ApiRefusal(Exception):
    pass


def request(method, url, key, body=None):
    """Send one request; return (status, headers, raw bytes) for any HTTP status."""
    data = json.dumps(body).encode("utf-8") if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Authorization", "Bearer " + key)
    req.add_header("Accept", "application/json")
    req.add_header("User-Agent", "seoreport-recipe-python/1")
    if data is not None:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return resp.status, resp.headers, resp.read()
    except urllib.error.HTTPError as err:
        return err.code, err.headers, err.read()


def parse(status, raw):
    try:
        return json.loads(raw.decode("utf-8"))
    except ValueError:
        raise ApiRefusal(f"HTTP {status}: not JSON: {raw[:200]!r}")


def refuse(status, headers, payload):
    """Turn an error response into one line that names the API's own code."""
    code = payload.get("code") or payload.get("errorCode") or f"HTTP_{status}"
    line = f"{code} (HTTP {status}): {payload.get('error', 'no message')}"
    if payload.get("ctaUrl"):
        line += f" -> https://seoreport.dev{payload['ctaUrl']}"
    retry_after = headers.get("Retry-After") if headers else None
    if retry_after:
        line += f" [retry after {retry_after}s]"
    raise ApiRefusal(line)


def main():
    key = os.environ.get("SEOREPORT_API_KEY", "").strip()
    if not key:
        print("Set SEOREPORT_API_KEY to an API key from your SEOReport plan.", file=sys.stderr)
        sys.exit(2)
    target = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"

    # 1. Create the report (or reuse the one this account already has).
    status, headers, raw = request("POST", API, key, {"url": target})
    created = parse(status, raw)
    if status >= 400 or not created.get("success"):
        refuse(status, headers, created)
    job_id = created["report"]["jobId"]
    state = created["report"]["status"]
    poll_ms = created.get("pollAfterMs") or DEFAULT_POLL_MS
    print(f"job {job_id}: {state}")

    # 2. Poll politely until the job leaves queued/running, with a hard cap.
    deadline = time.monotonic() + MAX_WAIT_SECONDS
    job = created
    while state in ("queued", "running"):
        if time.monotonic() + poll_ms / 1000 > deadline:
            raise ApiRefusal(f"gave up after {MAX_WAIT_SECONDS}s; job {job_id} is still {state}")
        time.sleep(poll_ms / 1000)
        status, headers, raw = request("GET", f"{API}/{job_id}", key)
        job = parse(status, raw)
        if status >= 400 or not job.get("success"):
            refuse(status, headers, job)
        state = job["report"]["status"]
        if job.get("stuck"):
            raise ApiRefusal(f"job {job_id} stopped making progress; start a new report")
        poll_ms = job.get("pollAfterMs") or DEFAULT_POLL_MS

    if state != "completed":
        err = job.get("error") or {}
        detail = f"{err.get('code', state)}: {err.get('message', '')}".strip()
        retry = " (retryable)" if err.get("retryable") else ""
        raise ApiRefusal(f"report {state}: {detail}{retry}")

    # 3. Download the JSON diagnosis and keep a copy.
    status, headers, raw = request("GET", f"{API}/{job_id}/download?format=json", key)
    report = parse(status, raw)
    if status >= 400:
        refuse(status, headers, report)
    path = f"seoreport-{job_id}.json"
    with open(path, "wb") as fh:
        fh.write(raw)

    # 4. Print the score, the band and the first three open findings.
    score = report["score"]
    print(f"{report['targetUrl']}: {score['overall']}/100 ({score['band']})")
    print(f"open issues: {report['openIssues']['total']}, pages analysed: {report['pagesAnalyzed']}")
    open_findings = [f for f in report["findings"] if f["status"] in ("fail", "warn")]
    for finding in open_findings[:3]:
        severity = finding["severity"] or finding["status"]
        print(f"\n#{finding['rank']} [{severity}] {finding['title']} ({finding['area']['name']})")
        print(f"   Fix: {finding['fix']}")
    if report.get("message"):
        print(report["message"])
    print(f"\nfull diagnosis saved to {path}")


if __name__ == "__main__":
    try:
        main()
    except ApiRefusal as refusal:
        sys.exit(f"SEOReport: {refusal}")
    except urllib.error.URLError as err:
        sys.exit(f"network error: {err.reason}")

Three endpoints carry the whole flow. POST /api/v1/reports creates the report, or returns the one you already have, and answers with its jobId. GET /api/v1/reports/:id reports the status until it is no longer queued or running. GET /api/v1/reports/:id/download?format=json exports the finished diagnosis. [1][2]

Every successful create and status response carries pollAfterMs, the wait the API asks for before the next status read, and 0 once the report is finished. The script waits at least that long, falls back to 5 seconds when the field is missing or 0, and stops waiting after 5 minutes rather than polling forever. [1][2]

urlopen raises HTTPError for an error status, and an HTTPError can be read like a response, with code and headers. The script catches it and reads the body, because that is where the API puts its refusal code. The deadline uses time.monotonic, which a clock change cannot move. [4][3][6]

What it prints

The example below has illustrative values; the finding titles and fixes are the report's own wording for those checks. The first line can read completed instead of queued when the API returns a report you already have. [1]

python3 recipe.py https://example.comtext
job 3f6c2a1e-8d4b-4c7a-9e21-5b0d7f4a6c18: queued
https://example.com: 64/100 (mixed)
open issues: 9, pages analysed: 24

#1 [high] Canonical tag missing (URLs & Redirects)
   Fix: Add a canonical link element or Link HTTP header pointing to the preferred URL for this page. This is a strong canonicalization signal that helps search systems understand which version represents duplicate or very similar content; the declared URL remains a preference, not a guarantee.

#2 [high] Meta description missing (On-Page SEO)
   Fix: Add a concise, page-specific meta description that accurately describes the visible content. Search systems may use it for a result snippet when it is more helpful than text taken from the page.

#3 [high] Render parity mismatch (Crawl & Index)
   Fix: Ensure raw HTML and rendered DOM contain the same critical SEO elements (title, H1, canonical, meta description, structured data). If content is injected by JavaScript, move it to server-side rendering so crawlers see it immediately.

full diagnosis saved to seoreport-3f6c2a1e-8d4b-4c7a-9e21-5b0d7f4a6c18.json

The export lists every finding in fix order, passed and informational results included, so the script keeps the failing and warning ones and prints the first three. The saved JSON also holds each area's score, open issues by severity, and for every finding why it matters, how to verify the fix, and the evidence and pages behind it. [1]

Handling refusals and limits

A refusal is JSON with success set to false and an error sentence, and the refusals listed under Errors and limits on the developers page also carry a machine-readable code. The script prints the code, or the HTTP status when there is none, adds the plan or settings link when the API sends one, and exits with status 1 instead of retrying. [1]

402 PAYMENT_REQUIRED means the request did not reach a paid plan: the key is missing, revoked or mistyped, or the account has no active plan. POST /api/v1/reports treats a key it does not recognise as no account at all, so it answers 402, not 401. 402 REPORT_CREDITS_EXHAUSTED means this month's credits are spent. [1][2]

429 FRESH_RUN_LIMIT applies only when you add "forceRerun": true to scan a site again instead of reusing its report; the API's own message caps fresh scans at 30 an hour per account and sends a Retry-After header, which the script prints. These recipes never set forceRerun. [1][2]

A report that ends failed or dead_lettered carries an error object when its cause was recorded: a code such as DNS_FAILURE or CRAWLER_BLOCKED, a message, and retryable, which says whether running it again can help. cancelled ends the run too. A 409 with errorCode ZOMBIE_JOB_DETECTED means the report could not be completed because of a synchronization issue: start a new report. [1]

Next steps

For an AI agent, skip the script: point any MCP-capable client at https://seoreport.dev/mcp with the same Bearer key, and its report tools take the same steps: start a report, poll it until it is ready, read the findings and get a download link. [1]

For a list of sites, call the script once per URL, one at a time: each new site spends a credit and a site you already have is reused for free. To re-audit the same domains every week, register them with PUT /api/v1/monitors instead; monitoring is included with paid plans and does not spend report credits. [1]

The same download endpoint also returns the report as Markdown, text or PDF; the API reference lists the endpoints and documents the result fields. [2]

Every endpoint, the MCP server and the plans are on the SEO report API page. API and MCP access come with every paid plan.

Sources

  1. SEOReport: SEO Report API for developers and AI agents, verified on 2026-10-18
  2. SEOReport: API and MCP reference, verified on 2026-10-18
  3. Python docs: urllib.request, extensible library for opening URLs, verified on 2026-10-18
  4. Python docs: urllib.error, exception classes raised by urllib.request, verified on 2026-10-18
  5. Python docs: json, JSON encoder and decoder, verified on 2026-10-18
  6. Python docs: time.monotonic, verified on 2026-10-18
  7. Python docs: os.environ, verified on 2026-10-18

Get the complete diagnosis of your site

An evidence-backed report and a prioritized action plan, on a plan with monthly credits.