Back to articles

Put an SEO Audit in Your Deploy Pipeline: API, Exit Codes, and CI Recipes

SEOReport Team·
apici-cdautomationgithub-actionstechnical-seodevops

A working recipe for auditing your site from CI: three REST endpoints, the score fields a build gate can read, and the difference between a real regression and an infrastructure blip.

The technical regressions that cost the most visibility are invisible in a code review. A canonical tag that starts pointing at the wrong host, an hreflang block that loses its self-reference, a Content-Security-Policy header dropped from a reverse-proxy config, a route that moves its content behind hydration — every one of those ships in a diff that looks fine and passes every test you have. They surface weeks later, in a traffic chart, long after the commit that caused them scrolled out of view.

The fix is structural: run the audit on the deploy, not on the day someone remembers to. Every SEOReport report is available over REST, the response carries a numeric score block, and a shell script can turn that block into an exit code. That is the whole mechanism.

Four regression classes that only a machine catches on time

Our engine's checks map cleanly onto the failure modes that survive human review, and it is worth naming them concretely, because they are what a gate is actually protecting.

Hreflang. The check set covers self-reference, return links, x-default correctness, well-formed URLs, whether each alternate target is indexable, and whether the target's canonical agrees. A CMS migration that rewrites URL patterns can break return links across every locale at once, and no locale looks broken in isolation.

Canonicalization. Presence, target indexability, redirect-free targets, and off-site canonicals. The worst version of this failure is a staging canonical shipped to production — a page that quietly nominates a host you do not want indexed.

Security headers. Content-Security-Policy, HSTS, Referrer-Policy, X-Frame-Options, X-Content-Type-Options, and Permissions-Policy. These live in infrastructure config, not application code, which is exactly why they disappear during a proxy or edge-config change and nobody notices.

Render parity. Every audit fetches the homepage twice — plain HTTP, then a real browser — and compares title, H1s, canonical, meta description, JSON-LD, and body-text volume across the two. When our published render-parity data reached a verdict, most sites were serving a materially thinner page to anything that reads HTML as delivered. One ssr: false in a config file is enough to move a site into that group.

All four are deterministic, all four are cheap to check, and all four are the kind of thing a person only inspects when already suspicious.

Three endpoints and one loop

The API surface a pipeline needs is small. Authentication is a bearer token: create an account, issue a key from the dashboard, and send it as Authorization: Bearer sr__live_your_api_key. The same key opens REST and MCP; the full reference lives on the developers page.

graph TD A[Deploy completes] --> B["POST /api/v1/reports"] B --> C["GET /api/v1/reports/:id/ready"] C -->|"ready: false"| C C -->|"error.code present"| D[Infrastructure signal, exit 75] C -->|"ready: true"| E["GET /api/v1/reports/:id"] E --> F{Score block within budget?} F -->|Yes| G[exit 0] F -->|No| H[exit 1, build fails]

Submitting a report is one POST:

bash
JOB_ID=$(curl -sS -X POST https://seoreport.dev/api/v1/reports \
-H "Authorization: Bearer $SEOREPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "forceRerun": true}' \
| jq -r '.report.jobId')

Two things about that body matter in CI specifically.

forceRerun exists because the API reuses an existing snapshot when one is available — sensible for a person clicking a button, wrong for a deploy gate, which would otherwise grade the previous release. The response tells you which happened in submission.reusedSnapshot. Forced fresh runs are a paid-account capability; a key without that entitlement receives a 403 naming the reason rather than silently returning stale data.

The audit scope is inferred from the URL path. A bare origin runs a full-site audit; a URL with a path audits that page in isolation. So a pipeline can gate the homepage on every deploy and add the specific route a change touched, without any extra parameter.

Then poll. GET /api/v1/reports/:id/ready is the cheap status endpoint — it returns ready, status, stage, pollAfterMs, and, when a run fails, an error object:

bash
for _ in $(seq 1 60); do
READY=$(curl -sS -H "Authorization: Bearer $SEOREPORT_API_KEY" \
"https://seoreport.dev/api/v1/reports/$JOB_ID/ready")
CODE=$(echo "$READY" | jq -r '.error.code // empty')
if [ -n "$CODE" ]; then
echo "audit did not complete: $CODE"
exit 75
fi
[ "$(echo "$READY" | jq -r '.ready')" = "true" ] && break
sleep 5
done

That exit 75 is deliberate, and it is the part most integrations get wrong.

Distinguish a regression from a run that never happened

error.code carries a machine-readable reason and a retryable boolean. DNS_FAILURE, CRAWLER_BLOCKED, REDIRECT_LOOP, TLS_ERROR, NO_CONTENT, and STALE_ENGINE_VERSION are marked non-retryable, because retrying produces the identical answer. Most of that list describes a condition on the site, and several entries — a redirect loop, a TLS error, a bot rule that now blocks crawlers — are genuine deploy defects worth failing a build over. Codes outside that set are transient and worth another attempt.

A gate that collapses "the site regressed" and "the audit could not run" into the same red build gets disabled within a month. Keep them apart in your exit codes: 1 for a verdict you asked the gate to enforce, 75 — the conventional EX_TEMPFAIL — for a run that produced no verdict at all. Most CI systems can be configured to retry the second and page a human for the first.

Reading the score block

Once ready is true, GET /api/v1/reports/:id returns the report object. The score block is the part a gate reads:

bash
REPORT=$(curl -sS -H "Authorization: Bearer $SEOREPORT_API_KEY" \
"https://seoreport.dev/api/v1/reports/$JOB_ID")
echo "$REPORT" | jq '.report.score
| {overall, totalChecks, passedChecks, failedChecks,
warnChecks, inconclusiveChecks, failingCriticalChecks}'

Three of those fields carry most of the signal.

failingCriticalChecks is the count of failures the engine classes as critical — the ones capable of removing pages from an index or hiding content from a crawler entirely. For a first gate, this is the only number you need, and > 0 is a defensible threshold on day one.

overall is the composite score, useful as a ratchet: store the previous deploy's value and fail when the new one drops by more than a tolerance you choose. This catches slow erosion that no single check flags.

domainScores breaks the score out by seo, ai, performance, security, and brand, each with its own pass, fail, and warn counts. Per-domain floors let different teams own different budgets — the security score is a meaningful gate for whoever owns the edge config, independent of anything the content team ships.

inconclusiveChecks deserves its own rule: never gate on it. A check reports inconclusive when the engine could not reach a defensible verdict, and treating that as failure trains everyone to ignore the gate.

The score block comes from the report's free hero section, so a build gate reads it without unlocking the full findings. When you want the complete payload for archival, GET /api/v1/reports/:id/result returns the full results for an unlocked report, and GET /api/v1/reports/:id/download?format=json returns the same object as a downloadable artifact — worth attaching to the build so the diff between two deploys is inspectable later.

Wiring it into a workflow

Nothing above is CI-vendor-specific. In GitHub Actions the whole gate is one step calling the script you already have:

yaml
- name: SEO audit gate
env:
SEOREPORT_API_KEY: ${{ secrets.SEOREPORT_API_KEY }}
AUDIT_URL: https://example.com
run: ./scripts/seo-gate.sh

Where seo-gate.sh submits, polls, and ends with the decision:

bash
CRITICAL=$(echo "$REPORT" | jq -r '.report.score.failingCriticalChecks // 0')
SECURITY=$(echo "$REPORT" | jq -r \
'.report.score.domainScores[] | select(.domain == "security") | .score')
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::$CRITICAL critical checks failing"
exit 1
fi
echo "clean — security domain at $SECURITY"

Run it after the deploy finishes rather than against a preview environment. A preview URL is usually behind basic auth or a bot challenge, and an audit that cannot fetch the page reports CRAWLER_BLOCKED rather than a score. Post-deploy against the real origin is both simpler and closer to what a crawler experiences.

Two adjacent shapes are worth knowing. If your orchestration already lives on a scraping platform, our Apify actor runs the same engine on a schedule without any of this glue. And for agents, the same bearer key authenticates an MCP connection at https://seoreport.dev/mcp, where the report tools are advertised on connect — an agent investigating a traffic drop can run the audit and read the findings itself, which is documented alongside the REST surface on the developers page.

What the gate does not cover

A deploy gate answers one question: did this release introduce a regression. It is silent about everything that changes without a deploy — an expiring certificate, a CDN configuration edited in a dashboard, a third-party script that starts blocking render, a competitor's migration that changes what your canonical collides with. Per-domain monitoring is the always-on complement to the gate, and it reads from the same account and the same key described on the developers page.

Start with the narrow version. One endpoint, one field, failingCriticalChecks > 0, and an honest exit 75 when the audit could not run at all. A gate that fires twice a year and is trusted both times is worth more than a comprehensive one everyone learned to skip.

See How Your Site Ranks

Get a free AI-powered SEO report with actionable findings and priority fixes for your website.

No signup required.