Skip to main content

Confirm

Are you sure?

cURL · Code recipe

Run a Lighthouse audit in cURL

Score any page for performance, accessibility, best practices and SEO, in any shell with curl with curl. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To run a Lighthouse audit in cURL, POST a URL and a device to https://urlpipe.dev/lighthouse and read the JSON with jq: four category scores from 0 to 1 and the lab metrics, LCP, CLS and TBT among them. An audit takes around 15 seconds, so the async variant with a webhook suits production. It costs 2 credits.

Free plan, no credit card. 1,000 credits a month.

One POST to /lighthouse runs a real Lighthouse audit in Chrome and answers with the four category scores — performance, accessibility, best practices, SEO — and the lab metrics behind the performance score. device picks mobile (the default, throttled CPU and network) or desktop.

In cURL the report is read with jq. Scores run from 0 to 1, so the program multiplies by 100 to print what the Lighthouse report shows; metrics carry a ready-formatted displayValue. The guide to reading a Lighthouse audit explains what each number means.

Setup

Before you start

curl is on every Mac and almost every Linux box. jq reads the JSON this endpoint answers with; install it from your package manager if jq --version finds nothing.

Terminal
curl --version | head -1
jq --version        # brew install jq / apt install jq
export URLPIPE_API_KEY="your_api_key"

The request

Print the four scores, LCP, CLS and TBT

"sync": true keeps the request open until the result is ready. -w '%{http_code}' hands you the status and -o parks the body in a file, so an error message is printed, not decoded or saved as if it were the result. --max-time stops a page that never finishes loading from hanging the script. One jq program prints the lot; round needs jq 1.6 or later.

lighthouse_audit.sh
#!/usr/bin/env bash
set -euo pipefail

response=$(mktemp)
trap 'rm -f "$response"' EXIT

# -w prints the status; -o keeps the body out of the way until the status is known.
status=$(curl -sS --max-time 90 -o "$response" -w '%{http_code}' \
  https://urlpipe.dev/lighthouse \
  -H "Authorization: Bearer $URLPIPE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "device": "mobile", "sync": true}')

if [ "$status" != 200 ]; then
  echo "URLpipe answered $status: $(cat "$response")" >&2
  exit 1
fi

jq -r '
  (["performance", "accessibility", "best-practices", "seo"][] as $name
    | "\($name): \(.categories[$name].score | if . == null then "n/a" else . * 100 | round end)"),
  "LCP: \(.metrics["largest-contentful-paint"].displayValue // "n/a")",
  "CLS: \(.metrics["cumulative-layout-shift"].displayValue // "n/a")",
  "TBT: \(.metrics["total-blocking-time"].displayValue // "n/a")"
' "$response"

Run it: bash lighthouse_audit.sh

Given the example response on the docs page, it prints:

Output
performance: 95
accessibility: 88
best-practices: 92
seo: 90
LCP: 2.5 s
CLS: 0.05
TBT: 150 ms

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away; jq -r .token pulls it out without quotes. The loop polls every two seconds and the case covers the four answers GET /result/:token can give.

lighthouse_audit_async.sh
#!/usr/bin/env bash
set -euo pipefail

response=$(mktemp)
trap 'rm -f "$response"' EXIT

API=https://urlpipe.dev
AUTH="Authorization: Bearer $URLPIPE_API_KEY"

# No "sync": the request is accepted at once and the work carries on without you.
status=$(curl -sS -o "$response" -w '%{http_code}' "$API/lighthouse" \
  -H "$AUTH" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "device": "mobile",
    "report_to": "https://your-app.com/webhooks/urlpipe",
    "labels": {"customer": "acme"}
  }')
if [ "$status" != 200 ]; then
  echo "URLpipe answered $status: $(cat "$response")" >&2
  exit 1
fi
token=$(jq -r .token "$response")
echo "Accepted $token"

# The result is POSTed to report_to when it is ready. Polling by token is the
# other way to collect it: no endpoint needed, and a backup for the webhook.
for _ in $(seq 60); do
  status=$(curl -sS -o "$response" -w '%{http_code}' "$API/result/$token" -H "$AUTH")
  [ "$status" = 202 ] || break # 202 means still processing
  sleep 2
done

case "$status" in
  200) ;;
  202) echo "Still processing after two minutes; try the token again later." >&2; exit 1 ;;
  422) echo "The analysis failed: $(jq -r .error "$response")" >&2; exit 1 ;;
  410) echo "The result is past the 30-day window; send the request again." >&2; exit 1 ;;
  *) echo "URLpipe answered $status: $(cat "$response")" >&2; exit 1 ;;
esac

jq -r '
  (["performance", "accessibility", "best-practices", "seo"][] as $name
    | "\($name): \(.categories[$name].score | if . == null then "n/a" else . * 100 | round end)"),
  "LCP: \(.metrics["largest-contentful-paint"].displayValue // "n/a")",
  "CLS: \(.metrics["cumulative-layout-shift"].displayValue // "n/a")",
  "TBT: \(.metrics["total-blocking-time"].displayValue // "n/a")"
' "$response"

Run it: bash lighthouse_audit_async.sh

Errors

Handle errors and retries

-D writes the response headers to a file, which is where Retry-After is read from. A 401 answers in plain text, so it is decided on the status alone; jq reads the error code of everything else.

lighthouse_audit_errors.sh
#!/usr/bin/env bash
set -euo pipefail

response=$(mktemp)
headers=$(mktemp)
trap 'rm -f "$response" "$headers"' EXIT

# urlpipe PATH JSON: POST a sync request, leaving the result in $response.
# Retries the two 429s that clear by themselves; fails with a message otherwise.
urlpipe() {
  local attempt status code detail retry_after
  for attempt in 0 1 2 3 4; do
    status=$(curl -sS --max-time 90 -o "$response" -D "$headers" -w '%{http_code}' \
      "https://urlpipe.dev$1" \
      -H "Authorization: Bearer $URLPIPE_API_KEY" \
      -H "Content-Type: application/json" \
      -d "$2")
    case "$status" in
      200) return 0 ;;
      401) echo "URLpipe: 401: the API key is missing or wrong. Check URLPIPE_API_KEY." >&2; return 1 ;;
    esac

    # A body that is not JSON leaves both empty.
    code=$(jq -r '.error // ""' "$response" 2>/dev/null || true)
    detail=$(jq -r 'if .message then "\(.error): \(.message)" else .error end' "$response" 2>/dev/null || true)

    if [ "$status" = 429 ] && [ "$code" = rate_limited ]; then
      # Sending too fast: Retry-After says how long the window has left.
      retry_after=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' "$headers")
      sleep "${retry_after:-1}"
    elif [ "$status" = 429 ] && [ "$code" = concurrency_limit ]; then
      # Every parallel slot on your plan is busy with your own requests.
      sleep $((2 ** attempt))
    elif [ "$status" = 504 ]; then
      # Still running on our side; the token collects it from GET /result/:token.
      echo "URLpipe: 504 processing_timeout: collect it later with token $(jq -r .token "$response")" >&2
      return 1
    else
      # 403 email_unverified, 422 (a bad parameter, or a page that would not load),
      # 429 quota_exceeded: sending the same request again gets the same answer.
      echo "URLpipe: $status: $detail" >&2
      return 1
    fi
  done
  echo "URLpipe: 429: still refused after 5 attempts" >&2
  return 1
}

urlpipe /lighthouse '{"url": "https://example.com", "device": "mobile", "sync": true}' || exit 1

jq -r '
  (["performance", "accessibility", "best-practices", "seo"][] as $name
    | "\($name): \(.categories[$name].score | if . == null then "n/a" else . * 100 | round end)"),
  "LCP: \(.metrics["largest-contentful-paint"].displayValue // "n/a")",
  "CLS: \(.metrics["cumulative-layout-shift"].displayValue // "n/a")",
  "TBT: \(.metrics["total-blocking-time"].displayValue // "n/a")"
' "$response"

Run it: bash lighthouse_audit_errors.sh

Details

What to know about /lighthouse

  • Lighthouse 13 reports four categories; the pwa key is always null, kept so the shape does not change.
  • The performance score weights TBT 30%, LCP 25%, CLS 25%, FCP 10% and Speed Index 10%.
  • INP needs real users and cannot be measured in a lab run; TBT is its lab stand-in.
  • "include_audits": "true" adds the full list of audits, with the elements to fix. Mobile and desktop are cached separately.
  • Want the audit run on a schedule, with a history and alerts? Full Stack Audit sells that as a monitored report; URLpipe is the primitive underneath.

Other languages

Run a Lighthouse audit in another language

More cURL: every cURL recipe

FAQ

Frequently asked questions

Mobile or desktop — which device should I audit?
Mobile, unless you know your traffic is desktop. It emulates a 360×640 phone with a 4× slower CPU and a slow 4G network, which gives the lower, more telling scores. Pass device: "desktop" for a 1350×940 window with no throttling.
Why isn't INP in the results?
INP is measured from real users interacting with the page, which a lab audit cannot do. Total Blocking Time is the lab metric that tracks it.
Why use the async variant for Lighthouse?
An audit takes around 15 seconds, longer on a heavy page. Async hands you a token at once and delivers the report to your webhook, so nothing holds a connection open waiting.
Do I need an SDK to call URLpipe from cURL?
Nothing beyond curl and jq, which most machines already have.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.