SEO report API with cURL: audit a site and read the findings
A bash, curl and jq script that audits one site with the SEOReport API, keeps the key out of argv, 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 passes the key to curl as a header file through bash process substitution, the same habit as the quickstart on the developers page. The key therefore never appears in curl's argument list, where other users on the machine could read it with ps. [1][3][6]
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]
bash, jq, and curl 7.55.0 or later, the release that let --header read its values from a file. The script reads the key from the SEOREPORT_API_KEY environment variable, so it never sits in the script file. [4][3][5]
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.sh and run bash recipe.sh https://your-site.example with SEOREPORT_API_KEY set. [2]
#!/usr/bin/env bash
# Audit one site with the SEOReport API and print its top three fixes.
# Usage: SEOREPORT_API_KEY=... bash recipe.sh [https://example.com]
# Needs bash, curl 7.55 or later (for -H @file) and jq.
set -euo pipefail
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
TARGET="${1:-https://example.com}"
if [ -z "${SEOREPORT_API_KEY:-}" ]; then
echo "Set SEOREPORT_API_KEY to an API key from your SEOReport plan." >&2
exit 2
fi
# Send the key as a header file so it never sits in curl's argv, where ps shows
# it to every local user. printf is a shell builtin, so it spawns no process.
auth() { printf 'Authorization: Bearer %s\n' "$SEOREPORT_API_KEY"; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# call METHOD URL [curl args...]: body to $WORK/body, headers to $WORK/headers,
# and the HTTP status on stdout.
call() {
local method=$1 url=$2
shift 2
curl -sS -X "$method" "$url" -H @<(auth) -H "Accept: application/json" \
-A "seoreport-recipe-curl/1" --max-time 60 \
-o "$WORK/body" -D "$WORK/headers" -w '%{http_code}' "$@"
}
# refuse STATUS: print the API's own code and message, then stop.
refuse() {
if ! jq -e . "$WORK/body" >/dev/null 2>&1; then
echo "SEOReport: HTTP $1: not JSON: $(head -c 200 "$WORK/body")" >&2
exit 1
fi
local line retry
line=$(jq -r --arg s "$1" '"\(.code // .errorCode // ("HTTP_" + $s)) (HTTP \($s)): \(.error // "no message")"
+ (if .ctaUrl then " -> https://seoreport.dev\(.ctaUrl)" else "" end)' "$WORK/body")
retry=$(awk 'tolower($1) == "retry-after:" { print $2 }' "$WORK/headers" | tr -d '\r')
if [ -n "$retry" ]; then line="$line [retry after ${retry}s]"; fi
echo "SEOReport: $line" >&2
exit 1
}
# 1. Create the report (or reuse the one this account already has).
STATUS=$(call POST "$API" -H "Content-Type: application/json" \
--data-binary "$(jq -nc --arg url "$TARGET" '{url: $url}')")
if [ "$STATUS" -ge 400 ] || [ "$(jq -r '.success' "$WORK/body" 2>/dev/null)" != true ]; then
refuse "$STATUS"
fi
JOB_ID=$(jq -r '.report.jobId' "$WORK/body")
STATE=$(jq -r '.report.status' "$WORK/body")
POLL_MS=$(jq -r --argjson d "$DEFAULT_POLL_MS" 'if (.pollAfterMs // 0) > 0 then .pollAfterMs else $d end' "$WORK/body")
echo "job $JOB_ID: $STATE"
# 2. Poll politely until the job leaves queued/running, with a hard cap.
DEADLINE=$((SECONDS + MAX_WAIT_SECONDS))
while [ "$STATE" = queued ] || [ "$STATE" = running ]; do
WAIT=$(((POLL_MS + 999) / 1000))
if [ $((SECONDS + WAIT)) -gt "$DEADLINE" ]; then
echo "SEOReport: gave up after ${MAX_WAIT_SECONDS}s; job $JOB_ID is still $STATE" >&2
exit 1
fi
sleep "$WAIT"
STATUS=$(call GET "$API/$JOB_ID")
if [ "$STATUS" -ge 400 ] || [ "$(jq -r '.success' "$WORK/body" 2>/dev/null)" != true ]; then
refuse "$STATUS"
fi
STATE=$(jq -r '.report.status' "$WORK/body")
if [ "$(jq -r '.stuck // false' "$WORK/body")" = true ]; then
echo "SEOReport: job $JOB_ID stopped making progress; start a new report" >&2
exit 1
fi
POLL_MS=$(jq -r --argjson d "$DEFAULT_POLL_MS" 'if (.pollAfterMs // 0) > 0 then .pollAfterMs else $d end' "$WORK/body")
done
if [ "$STATE" != completed ]; then
jq -r --arg st "$STATE" '"SEOReport: report \($st): \(.error.code // $st): \(.error.message // "")"
+ (if .error.retryable then " (retryable)" else "" end)' "$WORK/body" >&2
exit 1
fi
# 3. Download the JSON diagnosis and keep a copy.
STATUS=$(call GET "$API/$JOB_ID/download?format=json")
if [ "$STATUS" -ge 400 ]; then refuse "$STATUS"; fi
OUT="seoreport-$JOB_ID.json"
cp "$WORK/body" "$OUT"
# 4. Print the score, the band and the first three open findings.
jq -r '
"\(.targetUrl): \(.score.overall)/100 (\(.score.band))",
"open issues: \(.openIssues.total), pages analysed: \(.pagesAnalyzed)",
([.findings[] | select(.status == "fail" or .status == "warn")][:3][]
| "\n#\(.rank) [\(.severity // .status)] \(.title) (\(.area.name))\n Fix: \(.fix)"),
(.message // empty)
' "$OUT"
echo
echo "full diagnosis saved to $OUT"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]
One helper serves every request: -o writes the body to a temporary file, -D the response headers, and -w '%{http_code}' prints the status. jq builds the request body with --arg, so a URL with quotes in it cannot break the JSON, and reads code and error out of a refusal with the // alternative operator. [3][5]
The deadline uses bash's SECONDS, which counts the seconds since the shell started, so the 5-minute cap needs no date arithmetic. [7]
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]
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.jsonThe 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
- SEOReport: SEO Report API for developers and AI agents, verified on 2026-10-18
- SEOReport: API and MCP reference, verified on 2026-10-18
- curl: the curl man page, verified on 2026-10-18
- curl: changes in 7.55.0, verified on 2026-10-18
- jq: the jq manual, verified on 2026-10-18
- GNU Bash manual: Process Substitution, verified on 2026-10-18
- GNU Bash manual: Bash Variables (SECONDS), 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.