Fail the build when the site regresses
Lighthouse + on-page checks on every deploy.
Two of these steps are seofetch API calls that feed a gate script your CI runs; the third step is that same data wrapped in a threshold check so CI fails before a regression ships. The fourth step is a separate, scheduled call — auditing the whole site is a different job than gating a deploy, and it is priced differently too.
Export your key once.
export SEOFETCH_KEY=sof_live_… # the scripts below use jq -- brew install jq / apt-get install jq
export SEOFETCH_KEY=sof_live_… pip install requests
export SEOFETCH_KEY=sof_live_… # Node 18+ -- built-in fetch, no npm install needed
Run Lighthouse on your key pages from CI.
curl https://api.seofetch.com/v1/page/lighthouse \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yoursite.com/", "device": "mobile"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/page/lighthouse",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"url": "https://yoursite.com/", "device": "mobile"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/page/lighthouse", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://yoursite.com/", device: "mobile" }),
});
const { data } = await resp.json();
{
"id": "ligh_jiysu3vwl7r47nyuoqchn7wj",
"request_id": "req_qa4aq5dha5dnbdsvusgr4wjhwm",
"object": "lighthouse",
"created_at": "2026-08-09T07:29:28Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 2,
"balance": 9857
},
"data": {
"url": "https://yoursite.com/",
"device": "mobile",
"scores": {
"performance": 100,
"accessibility": 96,
"best_practices": 96,
"seo": 80
},
"metrics": {
"lcp_ms": 762,
"fcp_ms": 611,
"cls": 0.02,
"tbt_ms": 40,
"si_ms": 900,
"tti_ms": 1100
},
"fetched_at": "2026-07-26T10:15:00Z",
"audits": {
"largest-contentful-paint": {
"id": "largest-contentful-paint",
"title": "Largest Contentful Paint",
"description": "Largest Contentful Paint marks the time at which the largest text or image is painted.",
"score": 1,
"scoreDisplayMode": "numeric",
"numericValue": 762.4,
"numericUnit": "millisecond",
"displayValue": "0.8 s",
"scoringOptions": {
"p10": 2500,
"median": 4000
}
},
"…": "…"
},
"screenshots": {
"full_page": {
"data": "data:image/webp;base64,…",
"width": 412,
"height": 6200
},
"final": {
"data": "data:image/webp;base64,…"
},
"thumbnails": [
{
"data": "data:image/webp;base64,…",
"timing": 375
},
"…"
]
}
}
}
Performance/SEO/best-practices scores as plain JSON — 2 credits per page.
Check the on-page basics didn't regress: title, meta description, canonical.
curl https://api.seofetch.com/v1/page/crawl \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/page/crawl",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"url": "https://example.com/"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/page/crawl", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com/" }),
});
const { data } = await resp.json();
{
"id": "craw_5s4mkqqnlvdzo3gcxrzsj5t6",
"request_id": "req_qixb25mzs5diznoatyr6x36alq",
"object": "crawl",
"created_at": "2026-08-09T07:29:28Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 2,
"balance": 9857
},
"data": {
"url": "https://example.com/",
"final_url": "https://example.com/",
"http_status": 200,
"title": "Example Domain",
"meta_description": "An example page used for documentation.",
"canonical": "https://example.com/",
"headings": {
"h1_count": 1,
"h2_count": 2
},
"links": {
"internal": 2,
"external": 1
},
"images": {
"total": 2,
"missing_alt": 1
},
"is_html": true,
"load_ms": 812
}
}
The response exposes data.title, data.meta_description, and data.canonical — assert they're present AND non-empty (a deploy that ships an empty title passes a null-check), and the regression fails in CI, not in next month's traffic report.
Gate the pipeline: the same lighthouse call from step 1, wrapped in a threshold — exit non-zero when a score crosses your floor.
score=$(curl -s https://api.seofetch.com/v1/page/lighthouse \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-d '{"url": "https://yoursite.com/"}' | jq '.data.scores.seo')
(( $(echo "$score >= 90" | bc -l) )) || exit 1
import os
import requests
resp = requests.post(
"https://api.seofetch.com/v1/page/lighthouse",
headers={"Authorization": f"Bearer {os.environ['SEOFETCH_KEY']}"},
json={"url": "https://yoursite.com/"},
)
score = resp.json()["data"]["scores"]["seo"]
if score < 90:
raise SystemExit(f"SEO score {score} < 90")
const resp = await fetch("https://api.seofetch.com/v1/page/lighthouse", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SEOFETCH_KEY}`,
},
body: JSON.stringify({ url: "https://yoursite.com/" }),
});
const { data } = await resp.json();
if (data.scores.seo < 90) {
console.error(`SEO score ${data.scores.seo} < 90`);
process.exit(1);
}
The gate goes red when the score crosses your floor. Add curl --fail-with-body so a network or API error also fails the job instead of comparing against an empty score.
Once a week — a separate scheduled job — audit the whole site, not just key pages.
curl https://api.seofetch.com/v1/site/audit \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com", "max_pages": 5, "checks": ["crawl", "lighthouse"], "device": "mobile"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/site/audit",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "example.com", "max_pages": 5, "checks": ["crawl", "lighthouse"], "device": "mobile"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/site/audit", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "example.com", max_pages: 5, checks: ["crawl", "lighthouse"], device: "mobile" }),
});
const { data } = await resp.json();
{
"id": "site_k5ub56j4w7abd4ptv5wa4xqc",
"request_id": "req_y6nrt4q3ebbnhiraizcfs4iacy",
"object": "site_audit",
"created_at": "2026-08-09T07:29:28Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 12,
"balance": 9857
},
"data": {
"domain": "example.com",
"pages_requested": 5,
"pages_crawled": 4,
"pages_delivered": 3,
"credits_charged": 12,
"pages": [
{
"url": "https://example.com/",
"status": "ok",
"crawl": {
"http_status": 200,
"links": {
"internal": 2,
"external": 0
}
},
"lighthouse": {
"device": "mobile",
"scores": {
"performance": 90,
"accessibility": 91,
"best_practices": 93,
"seo": 80
}
}
},
"…"
],
"summary": {
"score": {
"value": 82,
"band": "excellent",
"grade": "A"
},
"axes": [
{
"key": "speed",
"score": 73,
"band": "good"
}
],
"issues": {
"critical": 2,
"major": 1,
"minor": 1
},
"broken_links": 1,
"pages_with_errors": 1
}
}
}
Priced per delivered page × enabled checks — see the response's credits block for the exact charge; never budget it as flat.
Run the whole thing.
Each language below is the full gate — save it, run it after exporting SEOFETCH_KEY. It exits non-zero the moment a score or an on-page check crosses your floor, so any CI that runs a script can wire it in.
#!/usr/bin/env bash
# seo-gate.sh -- deploy gate: lighthouse floor + on-page basics. Run from CI
# on every push (SEOFETCH_KEY as a CI secret, exported into the job's env).
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
: "${GATE_URL:?point GATE_URL at your preview/staging deploy}"
URL="$GATE_URL"
# 1. lighthouse floor -- scores wobble a little between runs, keep headroom under yours
score=$(curl -sS --fail-with-body https://api.seofetch.com/v1/page/lighthouse \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -cn --arg u "$URL" '{url: $u, device: "mobile"}')" | jq '.data.scores.seo')
[ "$score" != "null" ] && [ "$score" -ge 90 ] || { echo "SEO score $score < 90"; exit 1; }
# 2. on-page basics still there -- present AND non-empty (a deploy that ships
# an empty title would pass a bare null-check)
curl -sS --fail-with-body https://api.seofetch.com/v1/page/crawl \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -cn --arg u "$URL" '{url: $u}')" |
jq -e '(.data.title | type == "string" and length > 0)
and (.data.meta_description | type == "string" and length > 0)
and (.data.canonical | type == "string" and length > 0)' > /dev/null \
|| { echo "missing or empty title, meta description, or canonical"; exit 1; }
echo "gate passed: $URL"
# Optional -- once a week, audit the whole site (priced per delivered page,
# not per gate run; call this from a separate weekly cron job, not every push):
# curl -sS --max-time 600 https://api.seofetch.com/v1/site/audit \
# -H "Authorization: Bearer $SEOFETCH_KEY" \
# -H "Content-Type: application/json" \
# -d '{"domain": "yoursite.com", "max_pages": 25, "checks": ["crawl", "lighthouse"], "device": "mobile"}' \
# | jq '.data.summary'
#!/usr/bin/env python3
"""seo-gate.py -- deploy gate: lighthouse floor + on-page basics. Run from CI
on every push (SEOFETCH_KEY as a CI secret, exported into the job's env).
"""
import os
import sys
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
URL = os.environ.get("GATE_URL")
if not URL:
raise SystemExit("point GATE_URL at your preview/staging deploy")
def api(path, payload):
resp = requests.post(
f"https://api.seofetch.com{path}",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json=payload,
)
resp.raise_for_status()
return resp.json()["data"]
# 1. lighthouse floor -- scores wobble a little between runs, keep headroom under yours
scores = api("/v1/page/lighthouse", {"url": URL, "device": "mobile"})["scores"]
if scores["seo"] < 90:
sys.exit(f"SEO score {scores['seo']} < 90")
# 2. on-page basics still there -- present AND non-empty (a deploy that ships
# an empty title would pass a bare null-check)
def _present(value):
return isinstance(value, str) and len(value) > 0
page = api("/v1/page/crawl", {"url": URL})
if not (_present(page["title"]) and _present(page["meta_description"]) and _present(page["canonical"])):
sys.exit("missing or empty title, meta description, or canonical")
print(f"gate passed: {URL}")
# Optional -- once a week, audit the whole site (priced per delivered page,
# not per gate run; call this from a separate weekly cron job, not every push):
# audit = api("/v1/site/audit", {"domain": "yoursite.com", "max_pages": 25,
# "checks": ["crawl", "lighthouse"], "device": "mobile"})
# print(audit["summary"])
#!/usr/bin/env node
// seo-gate.mjs -- deploy gate: lighthouse floor + on-page basics. Run from CI
// on every push (SEOFETCH_KEY as a CI secret, exported into the job's env).
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const URL = process.env.GATE_URL;
if (!URL) throw new Error("point GATE_URL at your preview/staging deploy");
async function api(path, payload) {
const resp = await fetch(`https://api.seofetch.com${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${SEOFETCH_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`${path} -> ${resp.status}`);
return (await resp.json()).data;
}
// 1. lighthouse floor -- scores wobble a little between runs, keep headroom under yours
const { scores } = await api("/v1/page/lighthouse", { url: URL, device: "mobile" });
if (scores.seo < 90) {
console.error(`SEO score ${scores.seo} < 90`);
process.exit(1);
}
// 2. on-page basics still there -- present AND non-empty (a deploy that ships
// an empty title would pass a bare null-check)
const present = (v) => typeof v === "string" && v.length > 0;
const page = await api("/v1/page/crawl", { url: URL });
if (!(present(page.title) && present(page.meta_description) && present(page.canonical))) {
console.error("missing or empty title, meta description, or canonical");
process.exit(1);
}
console.log(`gate passed: ${URL}`);
// Optional -- once a week, audit the whole site (priced per delivered page,
// not per gate run; call this from a separate weekly cron job, not every push):
// const audit = await api("/v1/site/audit", { domain: "yoursite.com", max_pages: 25,
// checks: ["crawl", "lighthouse"], device: "mobile" });
// console.log(audit.summary);