example

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.

language
prerequisites

Export your key once.

$ setupcurl
export SEOFETCH_KEY=sof_live_…
# the scripts below use jq -- brew install jq / apt-get install jq
step 1

Run Lighthouse on your key pages from CI.

→ requestcurl
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"}'
← response200
{
  "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.

step 2

Check the on-page basics didn't regress: title, meta description, canonical.

→ requestcurl
curl https://api.seofetch.com/v1/page/crawl \
  -H "Authorization: Bearer $SEOFETCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}'
← response200
{
  "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.

step 3

Gate the pipeline: the same lighthouse call from step 1, wrapped in a threshold — exit non-zero when a score crosses your floor.

$ localrun locally
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

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.

step 4

Once a week — a separate scheduled job — audit the whole site, not just key pages.

→ requestcurl
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"}'
← response200
{
  "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.

script

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.

→ runseo-gate.sh
#!/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'