PageSpeedAudit Developers · REST API v1.0.0 · production

Run the audit from your own product.

Hold an audit, point it at one URL, start a run, poll until it finishes, then pull a customer-ready HTML report and its machine-readable twin — the pagespeed-audit/findings@1 findings document. One long-running job type, JSON in and out, no SDK to install.

An audit is the /audits resource: an entitlement to 3 runs of one URL within 30 days of the first. An order receipt calls the same thing a credit.

  • Server-to-server: your backend calls it, never the browser
  • Scoped keys — purchase is never implicit
  • Problem+json errors with a stable code
  • 3 runs per entitlement, valid 30 days from the first

Base URL https://pagespeedaudit.com/api/v1 · OpenAPI 3.1 at /openapi/v1.json

The first request

read-only · cannot cost money

cURL

curl https://pagespeedaudit.com/api/v1/audits \
  -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY"

TypeScript

const response = await fetch("https://pagespeedaudit.com/api/v1/audits", {
  headers: { "X-Api-Key": process.env.PAGESPEEDAUDIT_API_KEY! },
});
if (!response.ok) throw new Error((await response.json()).code);
const audits = await response.json();

C#

using var http = new HttpClient { BaseAddress = new Uri("https://pagespeedaudit.com/api/v1/") };
http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("PAGESPEEDAUDIT_API_KEY"));
var audits = await http.GetFromJsonAsync<List<JsonElement>>("audits");

Python

import os, requests
r = requests.get("https://pagespeedaudit.com/api/v1/audits",
                 headers={"X-Api-Key": os.environ["PAGESPEEDAUDIT_API_KEY"]}, timeout=30)
r.raise_for_status()
audits = r.json()

200 OK one entitlement per element — url: null means the URL is still unlocked

[ { "id": "0199…", "url": null, "status": "Active",
    "runsAllowed": 3, "runsUsed": 0, "runsRemaining": 3,
    "hasActiveRun": false, "urlLockedAtUtc": null, "expiresAtUtc": null } ]

Choose the job

Three integrations share one API. Start from yours.

01 · Product

Integrate audits

Hold entitlements, start runs, poll, and pull the HTML report or the findings JSON into your own workflow.

  1. GET /audits
  2. POST /audits/{id}/runs
  3. GET /runs/{id}
  4. GET /runs/{id}/report
Follow the quickstart

02 · Agency

Automate reseller delivery

Order audits at your wholesale rate with a safe quote first, assign each to a client URL, collect both deliverables under your brand.

  1. POST /orders dryRun, then real
  2. POST /audits/{id}/runs
  3. GET /runs/{id}/report?format=html
The white-label programme

03 · Partner

Read affiliate performance

Referral link and stats, conversions and payouts, recruiting, and a server-to-server postback you configure once.

  1. GET /affiliate/me
  2. GET /affiliate/stats
  3. PUT /affiliate/postback
The affiliate programme

Five-minute quickstart

From API key to downloaded report.

The shortest safe path begins with a read. Nothing here creates an order; step 3 spends one run of an entitlement you already hold.

Step 1 of 5 · dashboard session

Create a key that cannot buy anything

Keys are created at /account/api-keys, in a signed-in browser — never by another key. Choose read and run; add purchase only for an integration that orders. The secret is shown once; store it in your secret manager.

export PAGESPEEDAUDIT_API_KEY="psa_live_…"   # from /account/api-keys, scopes: read, run
Can fail with
Nothing yet — but a key pasted into browser code is a key that will leak. See the boundary.
Next
Send the read-only request in step 2.

Step 2 of 5 · read · safe

Find an entitlement with runs remaining

curl https://pagespeedaudit.com/api/v1/audits -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY"

Each element is one entitlement: runsRemaining, url (null until the first run locks it), and expiresAtUtc (set when the window starts).

Empty list?
You hold no entitlement. Buy one on pricing, send a customer to POST /checkout/sessions, or order on the account — see purchasing.
Not sure which URL?
While the entitlement is unlocked, the Target Finder recommends one page and alternates.
Can fail with
insufficient_scope if the key has no read; 401 if the key is wrong.

Step 3 of 5 · run · spends one included run

Start the first run — this locks the URL

curl -X POST https://pagespeedaudit.com/api/v1/audits/$AUDIT_ID/runs \
  -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/"}'
# 202 Accepted  { "runId": "0199…", "auditId": "…", "runNumber": 1, "status": "Queued" }

Two things happen that cannot be undone: the URL is locked to this entitlement, and the 30-day window starts. The 2 re-runs must reuse this URL. A failed or timed-out run does not consume one of the 3.

Retry safe?
Yes — a second POST while a run is active answers 409 run_in_progress, never a duplicate run.

Step 4 of 5 · read · safe

Poll the run until it is terminal

curl https://pagespeedaudit.com/api/v1/runs/$RUN_ID -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY"
# { "status": "Running", … }   then   { "status": "Succeeded", "availableReportFormats": ["json","html"], … }

status moves QueuedStartingRunning and ends in Succeeded, Failed, TimedOut or Canceled. Poll every few seconds; there are no completion webhooks in v1. The product's delivery promise is less than 24 hours; most of that is queue and method, so poll, do not wait on a timer. GET /runs/{id}/events is a human-readable timeline if you want to show progress.

Do not
Restart the run because it is still running. run_in_progress is the API refusing to let you.
Failed or TimedOut?
No report, and no run consumed — start again with the same URL.

Step 5 of 5 · read · safe

Download the report and its findings document

curl -sS --fail "https://pagespeedaudit.com/api/v1/runs/$RUN_ID/report?format=json" -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY" -o report.findings.json.part && mv report.findings.json.part report.findings.json
curl -sS --fail "https://pagespeedaudit.com/api/v1/runs/$RUN_ID/report?format=html" -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY" -o report.html.part && mv report.html.part report.html
# --fail turns a 404 report_not_ready into an error instead of a file: without it curl saves the problem body as your report

The HTML is self-contained — one file, no external assets, ready to send to a client. The JSON is the same audit in the pagespeed-audit/findings@1 shape: every verdict, finding, prediction and plan item as data. Add inline=true to stream without an attachment disposition.

Can fail with
report_not_ready before the run has succeeded; invalid_format. A non-2xx is never a report — hence --fail and the rename.
Then
After the fix ships, start a re-run with the same URL; the dashboard compares runs finding by finding. See when verifying change.
The whole path as one scriptbash + curl + jq · copy-only
#!/usr/bin/env bash
# Stops on the first failure: every request must return 2xx, every value it reads must exist.
set -euo pipefail
BASE=https://pagespeedaudit.com/api/v1
KEY="X-Api-Key: ${PAGESPEEDAUDIT_API_KEY:?is unset or empty. Step 1 exports it: a key from /account/api-keys with the read and run scopes}"

# One helper for every request. Prints the body on 2xx. Otherwise prints the problem body (its
# "code" is the thing to branch on) to stderr and fails: status 75 for a failure worth retrying
# (no connection, 429, 5xx), status 1 for everything else.
api() {
  local out code
  out=$(curl -sS -w '\n%{http_code}' -H "$KEY" "$@") || { echo "request failed: $*" >&2; return 75; }
  code=${out##*$'\n'}; out=${out%$'\n'*}
  if [ "$code" -lt 400 ]; then printf '%s' "$out"; return 0; fi
  echo "${out:-HTTP $code}" >&2
  case "$code" in 429|5??) return 75;; *) return 1;; esac
}

# 2. An active entitlement with runs remaining and no run in flight (created by a purchase or an order).
AUDIT_ID=$(api "$BASE/audits" | jq -r '[.[] | select(.status == "Active" and .runsRemaining > 0 and (.hasActiveRun | not))][0].id // empty')
[ -n "$AUDIT_ID" ] || { echo "no active audit with runs remaining and no run in flight — buy one, or POST /orders" >&2; exit 1; }

# 3. The first run locks the URL and starts the validity window. 202 + runId.
#    A 409 run_in_progress stops here, with its code — not a poll of /runs/null.
RUN_ID=$(api -X POST "$BASE/audits/$AUDIT_ID/runs" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/"}' | jq -r '.runId // empty')
[ -n "$RUN_ID" ] || { echo "the start response carried no runId" >&2; exit 1; }
# The key is never printed: the hint names the variable, not its value.
echo "run $RUN_ID started — if this script stops, resume with: curl $BASE/runs/$RUN_ID -H \"X-Api-Key: \$PAGESPEEDAUDIT_API_KEY\"" >&2

# 4. Poll until the run is terminal. A transient failure (a deploy, a 429) is retried; a
#    permanent one (a revoked key, a 404) stops the script; the delivery promise is the deadline.
DEADLINE=$(( $(date +%s) + 24 * 3600 ))
while :; do
  if STATUS=$(api "$BASE/runs/$RUN_ID" | jq -r '.status // empty'); then
    case "$STATUS" in Succeeded|Failed|TimedOut|Canceled) break;; esac
  elif [ $? -eq 75 ]; then
    echo "poll failed, retrying" >&2
  else
    exit 1
  fi
  [ "$(date +%s)" -lt "$DEADLINE" ] || { echo "run $RUN_ID still '${STATUS:-unknown}' after 24 h" >&2; exit 1; }
  sleep 5
done

# 5. Only a Succeeded run has a report. Failed / TimedOut did not consume a run — start again.
#    Each file is written under a .part name and renamed only once the response was 2xx; a
#    failed download stops the script and leaves the .part file behind.
[ "$STATUS" = Succeeded ] || { echo "run $RUN_ID ended $STATUS — there is no report to download" >&2; exit 1; }
api "$BASE/runs/$RUN_ID/report?format=json" > report.findings.json.part && mv report.findings.json.part report.findings.json || exit 1
api "$BASE/runs/$RUN_ID/report?format=html" > report.html.part && mv report.html.part report.html || exit 1

Architecture boundary

Keep the key behind your server.

A psa_live_… key grants access to your account's audits — and, with purchase, to its money. Call the API from your backend, a serverless function or a trusted automation environment. Cross-origin browser access is intentionally not enabled: there is no CORS policy, so a page calling the API directly would fail before it leaked the key.

Building a customer-facing UI? Put a backend-for-frontend between it and the API and keep the key server-side.

Core concept

The entitlement is the state machine.

One purchase, one URL, 3 runs, 30 days from the first. Every conflict the API can answer is one of these transitions refusing to happen twice.

Ready state

URL-unlocked entitlement

Created by a purchase, a Checkout session or a settled order. url is null, runsRemaining is 3 and the 30-day clock has not started — it starts on the first run, not on the purchase.

See it
GET /auditsstatus: Active, urlLockedAtUtc: null
Only now
The Target Finder can still steer the choice of URL.
Open GET /audits in the reference →

Asynchronous

One resource tells the truth

Poll GET /runs/{runId} every few seconds. status moves QueuedStartingRunning. A second POST meanwhile is answered 409 run_in_progress — a retried request can never start a duplicate.

Progress
GET /runs/{runId}/events — human-readable timeline
Delivery
the product promises less than 24 hours
Open GET /runs/{runId} →

Terminal · consumed

Succeeded: the deliverables exist

availableReportFormats lists json and html. One of the 3 runs is consumed. GET /runs/{runId}/report?format=json|html streams either twin.

Refuses with
report_not_ready only before this state
Open GET /runs/{runId}/report →

Terminal · not consumed

Failed or TimedOut: nothing is lost

No report, and the run is not counted against the 3. errorCode on the run says why. Start again with the same URL; the entitlement's window keeps counting from the first attempt.

Also terminal
Canceled — a run stopped before completion; it consumes nothing either
Start again →

Optional branch

Target Finder: choose before you lock

POST /target-finder/analyses with { "url": "example.com" } reads robots and sitemap, clusters URL templates, probes candidates and checks CrUX field data, then recommends one page with alternates. Typically under a minute; poll GET /target-finder/analyses/{id}.

Refuses with
url_already_locked once every entitlement is locked · customers_only without a purchase
How the funnel works →

Loop

Re-run the same URL after the fix ships

Same POST, same URL (or omit url). A different URL is 409 url_locked; the third success is the last — 409 runs_exhausted after that. The dashboard compares runs finding by finding, and field-metric moves against a stated noise floor.

Before you spend one
The sample-size calculator says how many lab runs a claimed change needs to be believed.
Open POST /audits/{auditId}/runs →

Ends

Expired or revoked

Expired: 30 days since the first run — 409 expired on any further run. Revoked: the purchase was refunded or charged back — the audit stays listed with status: Revoked, its runs' status, timeline and reports answer 404 not_found, and a run request is 409 revoked.

Fresh start
A new purchase or order creates a new entitlement.
Purchasing routes →

One request starts the work. One resource tells the truth.

  1. Your integrationPageSpeedAudit
  2. POST /audits/{id}/runs { url }
  3. 202 runId · status: Queued
  4. GET /runs/{runId}
  5. 200 status: Running
  6. wait a few seconds · repeat
  7. GET /runs/{runId}
  8. 200 status: Succeeded · availableReportFormats: ["json","html"]
  9. GET /runs/{runId}/report?format=json
  10. 200 the pagespeed-audit/findings@1 document
  11. Failed / TimedOut instead: no report, no run consumed — POST again with the same URL. 409 run_in_progress on a POST while running: poll, do not restart.

Money-moving operations

Choose the purchase route before you write code.

The same authenticated order endpoint returns a Checkout URL for one account and charges a saved card for another. That difference is decided by the account's billing state, never by the request — so quote first.

SituationOperationResult
A visitor is buying, without API authenticationPOST /checkout/sessionsHosted Stripe Checkout URL · $497
Account, unsure of price or routePOST /orders dryRun200 quote — nothing created or charged
Account, no saved cardPOST /orders201 order + checkoutUrl · paymentRoute: link
Account with a saved card under an off-session mandatePOST /orders202 charged immediately (status: paid) · credits arrive on settlement (completed) · paymentRoute: charge
Approved reseller, either routePOST /ordersWholesale applied server-side: $298.20 per audit instead of $497 (40% off)
Unused entitlement already heldPOST /audits/{id}/runsSkip purchasing

The safe default

Quote before you order

The interactive reference pre-fills the dry run, so the request it sends as written creates nothing and charges nothing. Remove dryRun — and add an Idempotency-Key — only when you mean to order: the console talks to production.

curl -X POST https://pagespeedaudit.com/api/v1/orders \
  -H "X-Api-Key: $PAGESPEEDAUDIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"quantity": 1, "dryRun": true}'

# 200 OK
{ "dryRun": true, "quantity": 1, "unitAmountCents": 49700, "estimatedTotalCents": 49700,
  "currency": "usd", "wholesale": false, "discountPercent": null, "paymentRoute": "link",
  "cardBrand": null, "cardLast4": null,
  "notice": "Dry run — nothing was created or charged. A real order with these parameters would answer 201 with a Checkout URL to pay in a browser." }

Production operation

A real order, and what guards it

  • Credential. Only an API key created with purchase can reach /orders at all — a key without the scope is insufficient_scope, and a signed-in browser session is api_key_required, so the console on the reference page cannot order while you are merely signed in.
  • Idempotency. Idempotency-Key is required. Replaying it returns the original order (200); reusing it with different parameters is 409. One key per intended order; a UUID is fine.
  • Bursts. 5 requests per minute to POST /orders per account, dry runs included.
  • Ceilings. Per-account daily maximums on orders placed and money committed — 429 daily_limit_reached; a platform-wide backstop — 503 ordering_paused; a kill switch — 503 ordering_disabled. Nothing is charged on any of them.
  • Refused cards. 402 authentication_required carries the recorded order's orderId and a checkoutUrl to finish in a browser; 402 card_declined carries the orderId and charged nothing — unless detail says the charge did not complete, in which case read GET /orders/{orderId} before retrying.

A saved card and its off-session mandate are set up once under Billing, in the dashboard.

Output contract

See how the human report maps to the JSON twin.

Build the whole integration before you spend on a run. Select a section of the report to highlight the lines of the findings document that carry it. Values are the framework's published worked example — 21 flags, 4 Real, 6 Wrong, 9 Trivial, 2 Locked, a field p75 TTFB of 1.6 s no flag named — abridged to six of the seven verdict rows the framework prints verbatim, with the site replaced by example.com.

Sample JSON Schema
PageSpeedAudit example.com · mobile · worked example

sample.findings.json · pagespeed-audit/findings@1 · highlighted lines belong to the selected section

{  "schema": "pagespeed-audit/findings@1",  "run": {    "run_id": "sample-2026-09-15",    "generated_at": "2026-09-15T22:13:39Z",    "skill_version": "sample",    "site": "example.com",    "url": "https://example.com/",    "final_url": "https://example.com/",    "strategy": "mobile",    "date": "2026-09-15",    "lighthouse_version": null,    "data_sources": ["PSI/Lighthouse", "CrUX field (28-day p75)"],    "field_available": true,    "binding_constraint": "field p75 TTFB 1.6 s — passing in the lab, absent from all 21 flags",    "missing_from_psi": "field p75 TTFB 1.6 s: the server-response-time audit passes in the lab (~1 ms from the datacenter)",    "adjudication_counts": {      "confirmed": 1,      "false_positive": 2,      "deprioritized": 2,      "platform_blocked": 1,      "readout": 0    },    "framework": "noise-silence/four-verdicts@1",    "noise_rate": 0.8333  },  "metrics": [    {      "metric": "TTFB",      "source": "field_page",      "value": 1600,      "unit": "ms",      "category": "needs-improvement",      "good": 800,      "poor": 1800    },    {      "metric": "LCP",      "source": "field_page",      "value": 2784,      "unit": "ms",      "category": "needs-improvement",      "good": 2500,      "poor": 4000    },    {      "metric": "CLS",      "source": "field_page",      "value": 0,      "unit": "",      "category": "good",      "good": 0.1,      "poor": 0.25    },    {      "metric": "TTFB",      "source": "lab",      "value": 1,      "unit": "ms",      "category": null,      "good": 800,      "poor": 1800    },    {      "metric": "CLS",      "source": "lab",      "value": 0.122,      "unit": "",      "category": null,      "good": 0.1,      "poor": 0.25    }  ],  "adjudications": [    {      "audit_id": "render-blocking-resources",      "disposition": "confirmed",      "claim": "Reduce render-blocking resources — save 2,330 ms",      "evidence": "Field p75 LCP = 2,784 ms. The chain is real, but the saving is a lab-graph counterfactual on a different baseline; impact recomputed against field data.",      "finding_ref": "F1",      "metric_savings": {        "LCP": 2330      }    },    {      "audit_id": "uses-responsive-images",      "disposition": "false_positive",      "claim": "Properly size images",      "evidence": "srcset / sizes present and correct; the flag fired on 2x density at the emulated DPR. Correct responsive art direction misread as oversizing."    },    {      "audit_id": "font-display",      "disposition": "false_positive",      "claim": "Ensure text remains visible during webfont load",      "evidence": "Icon font with font-display: block. Blocking is the correct behaviour for icon fonts; swap would flash raw ligature text."    },    {      "audit_id": "unsized-images",      "disposition": "deprioritized",      "claim": "Image elements do not have explicit width and height",      "evidence": "Lab CLS 0.122; field p75 CLS 0.00 with an essentially all-good CLS distribution. The shift appears under cold-cache lab conditions; no materially affected cohort in the field."    },    {      "audit_id": "unused-javascript",      "disposition": "deprioritized",      "claim": "Reduce unused JavaScript — 909 KiB",      "evidence": "The flag's own metricSavings are zero on the metrics modelled, and the bytes are attributed to reCAPTCHA: security-owned, interaction-path code.",      "metric_savings": {        "LCP": 0,        "FCP": 0      }    },    {      "audit_id": "uses-long-cache-ttl",      "disposition": "platform_blocked",      "claim": "Serve static assets with an efficient cache policy",      "evidence": "Assets served by the platform's CDN; TTLs not merchant-controllable on this plan. True and material — and owned by the platform.",      "finding_ref": "F3"    }  ],  "findings": [    {      "id": "F1",      "slug": "render-blocking-resources:critical-path",      "title": "Render-blocking resources on the critical render chain: the chain is real, the 2,330 ms saving is a lab counterfactual",      "severity": "HIGH",      "confidence": "HIGH",      "tags": ["lcp", "render-blocking"],      "evidence": "Lighthouse's render-blocking-resources flag models a 2,330 ms saving on a page whose real users' p75 LCP is 2,784 ms — a saving nearly the size of the entire field experience. The chain is real; the magnitude is not transferable.",      "scope": null,      "chain": null,      "impact": {        "narrative": "The chain is real. The saving is not 2,330 ms: that number is a lab-graph counterfactual, so the range is recomputed against the field p75 LCP of 2,784 ms rather than subtracted from it.",        "arithmetic": "2,784 ms field p75 − 2,330 ms lab saving would imply a ~450 ms LCP — arithmetic across incompatible baselines. The honest range is recomputed against field data.",        "metric": "LCP",        "unit": "ms",        "min": null,        "max": null,        "direction": "decrease",        "confidence": "MEDIUM-HIGH"      },      "sequencing": null    },    {      "id": "F2",      "slug": "origin-ttfb:field-p75",      "title": "Silent Bottleneck: real users wait 1.6 s for the first byte while the lab measures ~1 ms",      "severity": "CRITICAL",      "confidence": "HIGH",      "tags": ["ttfb", "silent-bottleneck", "field-vs-lab"],      "evidence": "Field p75 TTFB 1.6 s — double the 0.8 s guideline Google's documentation sets for server response — against a lab TTFB near 1 ms from the datacenter. The server-response-time audit passes; none of the 21 flags names it.",      "scope": null,      "impact": {        "narrative": "The binding field constraint appears nowhere in the flag list. Every millisecond of it precedes the first byte of every page.",        "arithmetic": null,        "metric": "TTFB",        "unit": "ms",        "min": null,        "max": null,        "direction": "decrease",        "confidence": "HIGH"      }    },    {      "id": "F3",      "slug": "cdn-cache-ttl:platform-owned",      "title": "Static-asset cache TTLs are set by the platform's CDN, not the merchant",      "severity": "MEDIUM",      "confidence": "HIGH",      "tags": ["caching", "locked"],      "evidence": "Assets are served by the platform's CDN; TTLs are not merchant-controllable on this plan.",      "impact": {        "narrative": "True and material — and owned by the platform.",        "metric": null,        "direction": null      },      "locked": true,      "redirect": "Effort redirected to the controllable equivalent on this plan; the flag is not a to-do for the merchant."    }  ],  "deliverables": [],  "plan": [    {      "bucket": "today",      "position": 1,      "item": "Take the render-blocking resources off the critical render chain (F1)"    },    {      "bucket": "sprint",      "position": 1,      "item": "Investigate origin response time against the 1.6 s field p75 TTFB (F2)"    }  ]}

The Four Verdicts · adjudications[].disposition

  • Realconfirmed — true, material, controllable; owed engineering effort.
  • Wrongfalse_positive — falsified by direct evidence.
  • Trivialdeprioritized — true, but owed no effort.
  • Lockedplatform_blocked — owned by the platform; a redirect, no fix.

readout is the fifth disposition: an informational row that is neither a claim nor a verdict, and stays out of the Noise Rate. The naming layer is recorded in run.framework. The framework explains each gate →

Every field of pagespeed-audit/findings@1, read from the schema the audit skill validates against. Required fields are marked; a type listing null may be null. Additive fields may appear on v1; nothing is removed or renamed without a new schema id.

schema 1 field
FieldTypeRequiredMeaning
schema"pagespeed-audit/findings@1"yes
run 22 fields
FieldTypeRequiredMeaning
runobjectyes
run.run_idstringyes
run.generated_atstringyes
run.skill_versionstringyes
run.sitestringyes
run.urlstringyes
run.final_urlstring | null
run.strategymobile | desktopyes
run.datestringyes
run.lighthouse_versionstring | null
run.data_sourcesstring[]
run.field_availablebooleanyes
run.binding_constraintstringyes
run.missing_from_psistring | null
run.adjudication_countsobjectyes
run.adjudication_counts.confirmedintegeryes
run.adjudication_counts.false_positiveintegeryes
run.adjudication_counts.deprioritizedintegeryes
run.adjudication_counts.platform_blockedintegeryes
run.adjudication_counts.readoutintegeryes
run.frameworkstringPublic naming layer applied to this run's report, e.g. 'noise-silence/four-verdicts@1' (Real=confirmed, Wrong=false_positive, Trivial=deprioritized, Locked=platform_blocked)
run.noise_ratenumber | nullNoise & Silence framework: (false_positive + deprioritized + platform_blocked) / adjudicated flags, readout excluded
metrics 8 fields
FieldTypeRequiredMeaning
metricsobject[]yes
metrics[].metricLCP | INP | CLS | FCP | TTFByes
metrics[].sourcefield_page | field_origin | labyes
metrics[].valuenumber | nullyes
metrics[].unitms |
metrics[].categorystring | null
metrics[].goodnumber | null
metrics[].poornumber | null
history 5 fields
FieldTypeRequiredMeaning
historyobject[]
history[].datestringyes
history[].metricLCP | INP | CLS | FCP | TTFByes
history[].p75number | nullyes
history[].unitms |
adjudications 12 fields
FieldTypeRequiredMeaning
adjudicationsobject[]yes
adjudications[].audit_idstringyes
adjudications[].dispositionconfirmed | false_positive | deprioritized | platform_blocked | readoutyes
adjudications[].claimstringyes
adjudications[].evidencestringyes
adjudications[].finding_refstring | null
adjudications[].metric_savingsobject | null
adjudications[].has_quick_fixboolean
adjudications[].fixobject | null
adjudications[].fix.langstring
adjudications[].fix.codestring
adjudications[].fix.notestring
findings 37 fields
FieldTypeRequiredMeaning
findingsobject[]yes
findings[].idstringyesPositional in-report anchor, re-assigned every run (F1, F2, …) and the target of adjudications[].finding_ref. NOT stable across runs — never join on it. Use slug for that.
findings[].slugstringyesStable cross-run identifier for the PROBLEM CLASS, not the wording: the same underlying problem on the same site produces the same slug in every future audit, even when title, prose, severity or measured numbers change. Never encodes run-specific data (no values, dates, run numbers, ordering, Lighthouse version). Unique within a run; a class that legitimately recurs is disambiguated with a stable discriminator derived from the resource itself, e.g. 'render-blocking-css:fonts.googleapis.com' — never a positional counter. Join findings across runs on run.url (or run.final_url) + findings[].slug. Authoring vocabulary and the fallback rule live in SKILL.md.
findings[].titlestringyes
findings[].severityCRITICAL | HIGH | MEDIUM | LOWyes
findings[].confidenceHIGH | MEDIUM-HIGH | MEDIUM | LOWyes
findings[].effortstring | null
findings[].tagsstring[]
findings[].evidencestring
findings[].scopestring | nullWhat the defect was VERIFIED to affect, in the words of what was tested: 'origin-wide (verified on / /pricing /blog)' or 'this route only (tested / only)'. Never an inference from a single route.
findings[].falsificationstring | nullHuman-readable falsification note. When the graded arrays below are present this is their flattened rendering ('Measured: … | Reasoned (inference): … | Verify before shipping: …'), so a v1 consumer that only reads this field keeps working.
findings[].falsification_measuredstring[] | nullClaims the audit OBSERVED — header, byte count, snippet, response. Optional; added additively to @1.
findings[].falsification_reasonedstring[] | nullClaims the audit INFERRED from those observations. Presenting an inference as a confirmed check overstates the evidence grade, so the two are carried apart. Optional; added additively to @1.
findings[].verify_before_shippingstring | nullThe concrete test that settles a reasoned inference. REQUIRED (validate_findings) whenever falsification_reasoned is non-empty on a finding that ships a fix.
findings[].couplingobject[] | nullInteractions with other findings in this run: one finding's fix changing a precondition another rests on, a prerequisite, or a co-requisite. Mirrored onto the other finding by the renderer and re-checked for symmetry by validate_findings — an interaction stated on only one side is an error. Optional; added additively to @1.
findings[].coupling[].findingstringyes
findings[].coupling[].kindinvalidates | invalidated_by | depends_on | required_by | co-requisite | interactsyes
findings[].coupling[].notestring | null
findings[].coupling[].mirroredbooleantrue = generated from the other finding's declaration, not authored here
findings[].chainstring | null
findings[].fixobject | null
findings[].fix.langstring
findings[].fix.beforestring | null
findings[].fix.afterstring
findings[].fix.notestring | null
findings[].impactobjectyes
findings[].impact.narrativestringyes
findings[].impact.arithmeticstring | null
findings[].impact.metricLCP | INP | CLS | FCP | TTFB | null
findings[].impact.unitms | | null
findings[].impact.minnumber | null
findings[].impact.maxnumber | null
findings[].impact.directiondecrease | increase | null
findings[].impact.confidenceHIGH | MEDIUM-HIGH | MEDIUM | LOW | null
findings[].sequencingstring | null
findings[].lockedboolean | nullNoise & Silence: true = platform-owned / outside operator control. A locked finding carries NO fix; the renderer shows the redirect instead, and validate_findings rejects a locked finding that has a fix.
findings[].redirectstring | nullFor a locked finding: the controllable equivalent (merchant-side lever) or an explicit stop-trying note shown in place of a fix.
deliverables 4 fields
FieldTypeRequiredMeaning
deliverablesobject[]
deliverables[].titlestringyes
deliverables[].langstring
deliverables[].codestringyes
done_well 3 fields
FieldTypeRequiredMeaning
done_wellobject[]
done_well[].itemstringyes
done_well[].evidencestring | null
plan 4 fields
FieldTypeRequiredMeaning
planobject[]
plan[].buckettoday | sprint | architecturalyes
plan[].positionintegeryes
plan[].itemstringyes

Generated from the schema on every request — a definition can never name a field the contract does not have. Deserialize the JSON straight into these; no SDK is required or offered.

findings.d.ts Findings.cs findings.py

TypeScript

// pagespeed-audit findings document v1 — generated from /docs/findings.schema.json. Do not edit by hand.
// Join runs on run.url + run.date + run.strategy; join findings across runs on findings[].slug, never on findings[].id.

export interface AdjudicationCounts {
  confirmed: number;
  false_positive: number;
  deprioritized: number;
  platform_blocked: number;
  readout: number;
}

export interface RunInfo {
  run_id: string;
  generated_at: string;
  skill_version: string;
  site: string;
  url: string;
  final_url?: string | null;
  strategy: "mobile" | "desktop";
  date: string;
  lighthouse_version?: string | null;
  data_sources?: string[];
  field_available: boolean;
  binding_constraint: string;
  missing_from_psi?: string | null;
  adjudication_counts: AdjudicationCounts;
  /** Public naming layer applied to this run's report, e.g. 'noise-silence/four-verdicts@1' (Real=confirmed, Wrong=false_positive, Trivial=deprioritized, Locked=platform_blocked) */
  framework?: string;
  /** Noise & Silence framework: (false_positive + deprioritized + platform_blocked) / adjudicated flags, readout excluded */
  noise_rate?: number | null;
}

export interface MetricRow {
  metric: "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
  source: "field_page" | "field_origin" | "lab";
  value: number | null;
  unit?: "ms" | "";
  category?: string | null;
  good?: number | null;
  poor?: number | null;
}

export interface HistoryRow {
  date: string;
  metric: "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
  p75: number | null;
  unit?: "ms" | "";
}

export interface QuickFix {
  lang?: string;
  code?: string;
  note?: string;
}

export interface Adjudication {
  audit_id: string;
  disposition: "confirmed" | "false_positive" | "deprioritized" | "platform_blocked" | "readout";
  claim: string;
  evidence: string;
  finding_ref?: string | null;
  metric_savings?: Record<string, unknown> | null;
  has_quick_fix?: boolean;
  fix?: QuickFix | null;
}

export interface Coupling {
  finding: string;
  kind: "invalidates" | "invalidated_by" | "depends_on" | "required_by" | "co-requisite" | "interacts";
  note?: string | null;
  /** true = generated from the other finding's declaration, not authored here */
  mirrored?: boolean;
}

export interface FindingFix {
  lang?: string;
  before?: string | null;
  after?: string;
  note?: string | null;
}

export interface Impact {
  narrative: string;
  arithmetic?: string | null;
  metric?: "LCP" | "INP" | "CLS" | "FCP" | "TTFB" | null;
  unit?: "ms" | "" | null;
  min?: number | null;
  max?: number | null;
  direction?: "decrease" | "increase" | null;
  confidence?: "HIGH" | "MEDIUM-HIGH" | "MEDIUM" | "LOW" | null;
}

export interface Finding {
  /** Positional in-report anchor, re-assigned every run (F1, F2, …) and the target of adjudications[].finding_ref. NOT stable across runs — never join on it. Use slug for that. */
  id: string;
  /** Stable cross-run identifier for the PROBLEM CLASS, not the wording: the same underlying problem on the same site produces the same slug in every future audit, even when title, prose, severity or measured numbers change. Never encodes run-specific data (no values, dates, run numbers, ordering, Lighthouse version). Unique within a run; a class that legitimately recurs is disambiguated with a stable discriminator derived from the resource itself, e.g. 'render-blocking-css:fonts.googleapis.com' — never a positional counter. Join findings across runs on run.url (or run.final_url) + findings[].slug. Authoring vocabulary and the fallback rule live in SKILL.md. */
  slug: string;
  title: string;
  severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";
  confidence: "HIGH" | "MEDIUM-HIGH" | "MEDIUM" | "LOW";
  effort?: string | null;
  tags?: string[];
  evidence?: string;
  /** What the defect was VERIFIED to affect, in the words of what was tested: 'origin-wide (verified on / /pricing /blog)' or 'this route only (tested / only)'. Never an inference from a single route. */
  scope?: string | null;
  /** Human-readable falsification note. When the graded arrays below are present this is their flattened rendering ('Measured: … | Reasoned (inference): … | Verify before shipping: …'), so a v1 consumer that only reads this field keeps working. */
  falsification?: string | null;
  /** Claims the audit OBSERVED — header, byte count, snippet, response. Optional; added additively to @1. */
  falsification_measured?: string[] | null;
  /** Claims the audit INFERRED from those observations. Presenting an inference as a confirmed check overstates the evidence grade, so the two are carried apart. Optional; added additively to @1. */
  falsification_reasoned?: string[] | null;
  /** The concrete test that settles a reasoned inference. REQUIRED (validate_findings) whenever falsification_reasoned is non-empty on a finding that ships a fix. */
  verify_before_shipping?: string | null;
  /** Interactions with other findings in this run: one finding's fix changing a precondition another rests on, a prerequisite, or a co-requisite. Mirrored onto the other finding by the renderer and re-checked for symmetry by validate_findings — an interaction stated on only one side is an error. Optional; added additively to @1. */
  coupling?: Coupling[] | null;
  chain?: string | null;
  fix?: FindingFix | null;
  impact: Impact;
  sequencing?: string | null;
  /** Noise & Silence: true = platform-owned / outside operator control. A locked finding carries NO fix; the renderer shows the redirect instead, and validate_findings rejects a locked finding that has a fix. */
  locked?: boolean | null;
  /** For a locked finding: the controllable equivalent (merchant-side lever) or an explicit stop-trying note shown in place of a fix. */
  redirect?: string | null;
}

export interface Deliverable {
  title: string;
  lang?: string;
  code: string;
}

export interface DoneWellItem {
  item: string;
  evidence?: string | null;
}

export interface PlanItem {
  bucket: "today" | "sprint" | "architectural";
  position: number;
  item: string;
}

/** Machine-readable twin of the audit report. Long-form arrays (metrics, history, adjudications, findings, plan) map 1:1 onto SQL tables; join runs on run.url + run.date + run.strategy, join flags across runs on adjudications[].audit_id (stable Lighthouse IDs), and join findings across runs on findings[].slug (stable problem-class ID; findings[].id is positional and re-assigned every run). */
export interface FindingsDocument {
  schema: "pagespeed-audit/findings@1";
  run: RunInfo;
  metrics: MetricRow[];
  history?: HistoryRow[];
  adjudications: Adjudication[];
  findings: Finding[];
  deliverables?: Deliverable[];
  done_well?: DoneWellItem[];
  plan?: PlanItem[];
}


C#

// pagespeed-audit findings document v1 — generated from /docs/findings.schema.json. Do not edit by hand.
// Deserialize with System.Text.Json: JsonSerializer.Deserialize<FindingsDocument>(json).
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace PageSpeedAudit.Findings;

public sealed record AdjudicationCounts
{
    [JsonPropertyName("confirmed")]
    public required int Confirmed { get; init; }
    [JsonPropertyName("false_positive")]
    public required int FalsePositive { get; init; }
    [JsonPropertyName("deprioritized")]
    public required int Deprioritized { get; init; }
    [JsonPropertyName("platform_blocked")]
    public required int PlatformBlocked { get; init; }
    [JsonPropertyName("readout")]
    public required int Readout { get; init; }
}

public sealed record RunInfo
{
    [JsonPropertyName("run_id")]
    public required string RunId { get; init; }
    [JsonPropertyName("generated_at")]
    public required string GeneratedAt { get; init; }
    [JsonPropertyName("skill_version")]
    public required string SkillVersion { get; init; }
    [JsonPropertyName("site")]
    public required string Site { get; init; }
    [JsonPropertyName("url")]
    public required string Url { get; init; }
    [JsonPropertyName("final_url")]
    public string? FinalUrl { get; init; }
    /// <remarks>One of: "mobile", "desktop".</remarks>
    [JsonPropertyName("strategy")]
    public required string Strategy { get; init; }
    [JsonPropertyName("date")]
    public required string Date { get; init; }
    [JsonPropertyName("lighthouse_version")]
    public string? LighthouseVersion { get; init; }
    [JsonPropertyName("data_sources")]
    public IReadOnlyList<string>? DataSources { get; init; }
    [JsonPropertyName("field_available")]
    public required bool FieldAvailable { get; init; }
    [JsonPropertyName("binding_constraint")]
    public required string BindingConstraint { get; init; }
    [JsonPropertyName("missing_from_psi")]
    public string? MissingFromPsi { get; init; }
    [JsonPropertyName("adjudication_counts")]
    public required AdjudicationCounts AdjudicationCounts { get; init; }
    /// <summary>Public naming layer applied to this run's report, e.g. 'noise-silence/four-verdicts@1' (Real=confirmed, Wrong=false_positive, Trivial=deprioritized, Locked=platform_blocked)</summary>
    [JsonPropertyName("framework")]
    public string? Framework { get; init; }
    /// <summary>Noise &amp; Silence framework: (false_positive + deprioritized + platform_blocked) / adjudicated flags, readout excluded</summary>
    [JsonPropertyName("noise_rate")]
    public double? NoiseRate { get; init; }
}

public sealed record MetricRow
{
    /// <remarks>One of: "LCP", "INP", "CLS", "FCP", "TTFB".</remarks>
    [JsonPropertyName("metric")]
    public required string Metric { get; init; }
    /// <remarks>One of: "field_page", "field_origin", "lab".</remarks>
    [JsonPropertyName("source")]
    public required string Source { get; init; }
    [JsonPropertyName("value")]
    public required double? Value { get; init; }
    /// <remarks>One of: "ms", "".</remarks>
    [JsonPropertyName("unit")]
    public string? Unit { get; init; }
    [JsonPropertyName("category")]
    public string? Category { get; init; }
    [JsonPropertyName("good")]
    public double? Good { get; init; }
    [JsonPropertyName("poor")]
    public double? Poor { get; init; }
}

public sealed record HistoryRow
{
    [JsonPropertyName("date")]
    public required string Date { get; init; }
    /// <remarks>One of: "LCP", "INP", "CLS", "FCP", "TTFB".</remarks>
    [JsonPropertyName("metric")]
    public required string Metric { get; init; }
    [JsonPropertyName("p75")]
    public required double? P75 { get; init; }
    /// <remarks>One of: "ms", "".</remarks>
    [JsonPropertyName("unit")]
    public string? Unit { get; init; }
}

public sealed record QuickFix
{
    [JsonPropertyName("lang")]
    public string? Lang { get; init; }
    [JsonPropertyName("code")]
    public string? Code { get; init; }
    [JsonPropertyName("note")]
    public string? Note { get; init; }
}

public sealed record Adjudication
{
    [JsonPropertyName("audit_id")]
    public required string AuditId { get; init; }
    /// <remarks>One of: "confirmed", "false_positive", "deprioritized", "platform_blocked", "readout".</remarks>
    [JsonPropertyName("disposition")]
    public required string Disposition { get; init; }
    [JsonPropertyName("claim")]
    public required string Claim { get; init; }
    [JsonPropertyName("evidence")]
    public required string Evidence { get; init; }
    [JsonPropertyName("finding_ref")]
    public string? FindingRef { get; init; }
    [JsonPropertyName("metric_savings")]
    public JsonElement? MetricSavings { get; init; }
    [JsonPropertyName("has_quick_fix")]
    public bool? HasQuickFix { get; init; }
    [JsonPropertyName("fix")]
    public QuickFix? Fix { get; init; }
}

public sealed record Coupling
{
    [JsonPropertyName("finding")]
    public required string Finding { get; init; }
    /// <remarks>One of: "invalidates", "invalidated_by", "depends_on", "required_by", "co-requisite", "interacts".</remarks>
    [JsonPropertyName("kind")]
    public required string Kind { get; init; }
    [JsonPropertyName("note")]
    public string? Note { get; init; }
    /// <summary>true = generated from the other finding's declaration, not authored here</summary>
    [JsonPropertyName("mirrored")]
    public bool? Mirrored { get; init; }
}

public sealed record FindingFix
{
    [JsonPropertyName("lang")]
    public string? Lang { get; init; }
    [JsonPropertyName("before")]
    public string? Before { get; init; }
    [JsonPropertyName("after")]
    public string? After { get; init; }
    [JsonPropertyName("note")]
    public string? Note { get; init; }
}

public sealed record Impact
{
    [JsonPropertyName("narrative")]
    public required string Narrative { get; init; }
    [JsonPropertyName("arithmetic")]
    public string? Arithmetic { get; init; }
    /// <remarks>One of: "LCP", "INP", "CLS", "FCP", "TTFB".</remarks>
    [JsonPropertyName("metric")]
    public string? Metric { get; init; }
    /// <remarks>One of: "ms", "".</remarks>
    [JsonPropertyName("unit")]
    public string? Unit { get; init; }
    [JsonPropertyName("min")]
    public double? Min { get; init; }
    [JsonPropertyName("max")]
    public double? Max { get; init; }
    /// <remarks>One of: "decrease", "increase".</remarks>
    [JsonPropertyName("direction")]
    public string? Direction { get; init; }
    /// <remarks>One of: "HIGH", "MEDIUM-HIGH", "MEDIUM", "LOW".</remarks>
    [JsonPropertyName("confidence")]
    public string? Confidence { get; init; }
}

public sealed record Finding
{
    /// <summary>Positional in-report anchor, re-assigned every run (F1, F2, …) and the target of adjudications[].finding_ref. NOT stable across runs — never join on it. Use slug for that.</summary>
    [JsonPropertyName("id")]
    public required string Id { get; init; }
    /// <summary>Stable cross-run identifier for the PROBLEM CLASS, not the wording: the same underlying problem on the same site produces the same slug in every future audit, even when title, prose, severity or measured numbers change. Never encodes run-specific data (no values, dates, run numbers, ordering, Lighthouse version). Unique within a run; a class that legitimately recurs is disambiguated with a stable discriminator derived from the resource itself, e.g. 'render-blocking-css:fonts.googleapis.com' — never a positional counter. Join findings across runs on run.url (or run.final_url) + findings[].slug. Authoring vocabulary and the fallback rule live in SKILL.md.</summary>
    [JsonPropertyName("slug")]
    public required string Slug { get; init; }
    [JsonPropertyName("title")]
    public required string Title { get; init; }
    /// <remarks>One of: "CRITICAL", "HIGH", "MEDIUM", "LOW".</remarks>
    [JsonPropertyName("severity")]
    public required string Severity { get; init; }
    /// <remarks>One of: "HIGH", "MEDIUM-HIGH", "MEDIUM", "LOW".</remarks>
    [JsonPropertyName("confidence")]
    public required string Confidence { get; init; }
    [JsonPropertyName("effort")]
    public string? Effort { get; init; }
    [JsonPropertyName("tags")]
    public IReadOnlyList<string>? Tags { get; init; }
    [JsonPropertyName("evidence")]
    public string? Evidence { get; init; }
    /// <summary>What the defect was VERIFIED to affect, in the words of what was tested: 'origin-wide (verified on / /pricing /blog)' or 'this route only (tested / only)'. Never an inference from a single route.</summary>
    [JsonPropertyName("scope")]
    public string? Scope { get; init; }
    /// <summary>Human-readable falsification note. When the graded arrays below are present this is their flattened rendering ('Measured: … | Reasoned (inference): … | Verify before shipping: …'), so a v1 consumer that only reads this field keeps working.</summary>
    [JsonPropertyName("falsification")]
    public string? Falsification { get; init; }
    /// <summary>Claims the audit OBSERVED — header, byte count, snippet, response. Optional; added additively to @1.</summary>
    [JsonPropertyName("falsification_measured")]
    public IReadOnlyList<string>? FalsificationMeasured { get; init; }
    /// <summary>Claims the audit INFERRED from those observations. Presenting an inference as a confirmed check overstates the evidence grade, so the two are carried apart. Optional; added additively to @1.</summary>
    [JsonPropertyName("falsification_reasoned")]
    public IReadOnlyList<string>? FalsificationReasoned { get; init; }
    /// <summary>The concrete test that settles a reasoned inference. REQUIRED (validate_findings) whenever falsification_reasoned is non-empty on a finding that ships a fix.</summary>
    [JsonPropertyName("verify_before_shipping")]
    public string? VerifyBeforeShipping { get; init; }
    /// <summary>Interactions with other findings in this run: one finding's fix changing a precondition another rests on, a prerequisite, or a co-requisite. Mirrored onto the other finding by the renderer and re-checked for symmetry by validate_findings — an interaction stated on only one side is an error. Optional; added additively to @1.</summary>
    [JsonPropertyName("coupling")]
    public IReadOnlyList<Coupling>? Coupling { get; init; }
    [JsonPropertyName("chain")]
    public string? Chain { get; init; }
    [JsonPropertyName("fix")]
    public FindingFix? Fix { get; init; }
    [JsonPropertyName("impact")]
    public required Impact Impact { get; init; }
    [JsonPropertyName("sequencing")]
    public string? Sequencing { get; init; }
    /// <summary>Noise &amp; Silence: true = platform-owned / outside operator control. A locked finding carries NO fix; the renderer shows the redirect instead, and validate_findings rejects a locked finding that has a fix.</summary>
    [JsonPropertyName("locked")]
    public bool? Locked { get; init; }
    /// <summary>For a locked finding: the controllable equivalent (merchant-side lever) or an explicit stop-trying note shown in place of a fix.</summary>
    [JsonPropertyName("redirect")]
    public string? Redirect { get; init; }
}

public sealed record Deliverable
{
    [JsonPropertyName("title")]
    public required string Title { get; init; }
    [JsonPropertyName("lang")]
    public string? Lang { get; init; }
    [JsonPropertyName("code")]
    public required string Code { get; init; }
}

public sealed record DoneWellItem
{
    [JsonPropertyName("item")]
    public required string Item { get; init; }
    [JsonPropertyName("evidence")]
    public string? Evidence { get; init; }
}

public sealed record PlanItem
{
    /// <remarks>One of: "today", "sprint", "architectural".</remarks>
    [JsonPropertyName("bucket")]
    public required string Bucket { get; init; }
    [JsonPropertyName("position")]
    public required int Position { get; init; }
    [JsonPropertyName("item")]
    public required string Item { get; init; }
}

/// <summary>Machine-readable twin of the audit report. Long-form arrays (metrics, history, adjudications, findings, plan) map 1:1 onto SQL tables; join runs on run.url + run.date + run.strategy, join flags across runs on adjudications[].audit_id (stable Lighthouse IDs), and join findings across runs on findings[].slug (stable problem-class ID; findings[].id is positional and re-assigned every run).</summary>
public sealed record FindingsDocument
{
    [JsonPropertyName("schema")]
    public required string Schema { get; init; }
    [JsonPropertyName("run")]
    public required RunInfo Run { get; init; }
    [JsonPropertyName("metrics")]
    public required IReadOnlyList<MetricRow> Metrics { get; init; }
    [JsonPropertyName("history")]
    public IReadOnlyList<HistoryRow>? History { get; init; }
    [JsonPropertyName("adjudications")]
    public required IReadOnlyList<Adjudication> Adjudications { get; init; }
    [JsonPropertyName("findings")]
    public required IReadOnlyList<Finding> Findings { get; init; }
    [JsonPropertyName("deliverables")]
    public IReadOnlyList<Deliverable>? Deliverables { get; init; }
    [JsonPropertyName("done_well")]
    public IReadOnlyList<DoneWellItem>? DoneWell { get; init; }
    [JsonPropertyName("plan")]
    public IReadOnlyList<PlanItem>? Plan { get; init; }
}


Python

# pagespeed-audit findings document v1 — generated from /docs/findings.schema.json. Do not edit by hand.
# Load with json.load(); the TypedDicts describe the shape for type checkers.
# Python 3.11+ (NotRequired); on older versions import NotRequired and TypedDict from typing_extensions.
from __future__ import annotations

from typing import Any, Literal, NotRequired, TypedDict

class AdjudicationCounts(TypedDict):
    confirmed: int
    false_positive: int
    deprioritized: int
    platform_blocked: int
    readout: int


class RunInfo(TypedDict):
    run_id: str
    generated_at: str
    skill_version: str
    site: str
    url: str
    final_url: NotRequired[str | None]
    strategy: Literal["mobile", "desktop"]
    date: str
    lighthouse_version: NotRequired[str | None]
    data_sources: NotRequired[list[str]]
    field_available: bool
    binding_constraint: str
    missing_from_psi: NotRequired[str | None]
    adjudication_counts: AdjudicationCounts
    # Public naming layer applied to this run's report, e.g. 'noise-silence/four-verdicts@1' (Real=confirmed, Wrong=false_positive, Trivial=deprioritized, Locked=platform_blocked)
    framework: NotRequired[str]
    # Noise & Silence framework: (false_positive + deprioritized + platform_blocked) / adjudicated flags, readout excluded
    noise_rate: NotRequired[float | None]


class MetricRow(TypedDict):
    metric: Literal["LCP", "INP", "CLS", "FCP", "TTFB"]
    source: Literal["field_page", "field_origin", "lab"]
    value: float | None
    unit: NotRequired[Literal["ms", ""]]
    category: NotRequired[str | None]
    good: NotRequired[float | None]
    poor: NotRequired[float | None]


class HistoryRow(TypedDict):
    date: str
    metric: Literal["LCP", "INP", "CLS", "FCP", "TTFB"]
    p75: float | None
    unit: NotRequired[Literal["ms", ""]]


class QuickFix(TypedDict):
    lang: NotRequired[str]
    code: NotRequired[str]
    note: NotRequired[str]


class Adjudication(TypedDict):
    audit_id: str
    disposition: Literal["confirmed", "false_positive", "deprioritized", "platform_blocked", "readout"]
    claim: str
    evidence: str
    finding_ref: NotRequired[str | None]
    metric_savings: NotRequired[dict[str, Any] | None]
    has_quick_fix: NotRequired[bool]
    fix: NotRequired[QuickFix | None]


class Coupling(TypedDict):
    finding: str
    kind: Literal["invalidates", "invalidated_by", "depends_on", "required_by", "co-requisite", "interacts"]
    note: NotRequired[str | None]
    # true = generated from the other finding's declaration, not authored here
    mirrored: NotRequired[bool]


class FindingFix(TypedDict):
    lang: NotRequired[str]
    before: NotRequired[str | None]
    after: NotRequired[str]
    note: NotRequired[str | None]


class Impact(TypedDict):
    narrative: str
    arithmetic: NotRequired[str | None]
    metric: NotRequired[Literal["LCP", "INP", "CLS", "FCP", "TTFB"] | None]
    unit: NotRequired[Literal["ms", ""] | None]
    min: NotRequired[float | None]
    max: NotRequired[float | None]
    direction: NotRequired[Literal["decrease", "increase"] | None]
    confidence: NotRequired[Literal["HIGH", "MEDIUM-HIGH", "MEDIUM", "LOW"] | None]


class Finding(TypedDict):
    # Positional in-report anchor, re-assigned every run (F1, F2, …) and the target of adjudications[].finding_ref. NOT stable across runs — never join on it. Use slug for that.
    id: str
    # Stable cross-run identifier for the PROBLEM CLASS, not the wording: the same underlying problem on the same site produces the same slug in every future audit, even when title, prose, severity or measured numbers change. Never encodes run-specific data (no values, dates, run numbers, ordering, Lighthouse version). Unique within a run; a class that legitimately recurs is disambiguated with a stable discriminator derived from the resource itself, e.g. 'render-blocking-css:fonts.googleapis.com' — never a positional counter. Join findings across runs on run.url (or run.final_url) + findings[].slug. Authoring vocabulary and the fallback rule live in SKILL.md.
    slug: str
    title: str
    severity: Literal["CRITICAL", "HIGH", "MEDIUM", "LOW"]
    confidence: Literal["HIGH", "MEDIUM-HIGH", "MEDIUM", "LOW"]
    effort: NotRequired[str | None]
    tags: NotRequired[list[str]]
    evidence: NotRequired[str]
    # What the defect was VERIFIED to affect, in the words of what was tested: 'origin-wide (verified on / /pricing /blog)' or 'this route only (tested / only)'. Never an inference from a single route.
    scope: NotRequired[str | None]
    # Human-readable falsification note. When the graded arrays below are present this is their flattened rendering ('Measured: … | Reasoned (inference): … | Verify before shipping: …'), so a v1 consumer that only reads this field keeps working.
    falsification: NotRequired[str | None]
    # Claims the audit OBSERVED — header, byte count, snippet, response. Optional; added additively to @1.
    falsification_measured: NotRequired[list[str] | None]
    # Claims the audit INFERRED from those observations. Presenting an inference as a confirmed check overstates the evidence grade, so the two are carried apart. Optional; added additively to @1.
    falsification_reasoned: NotRequired[list[str] | None]
    # The concrete test that settles a reasoned inference. REQUIRED (validate_findings) whenever falsification_reasoned is non-empty on a finding that ships a fix.
    verify_before_shipping: NotRequired[str | None]
    # Interactions with other findings in this run: one finding's fix changing a precondition another rests on, a prerequisite, or a co-requisite. Mirrored onto the other finding by the renderer and re-checked for symmetry by validate_findings — an interaction stated on only one side is an error. Optional; added additively to @1.
    coupling: NotRequired[list[Coupling] | None]
    chain: NotRequired[str | None]
    fix: NotRequired[FindingFix | None]
    impact: Impact
    sequencing: NotRequired[str | None]
    # Noise & Silence: true = platform-owned / outside operator control. A locked finding carries NO fix; the renderer shows the redirect instead, and validate_findings rejects a locked finding that has a fix.
    locked: NotRequired[bool | None]
    # For a locked finding: the controllable equivalent (merchant-side lever) or an explicit stop-trying note shown in place of a fix.
    redirect: NotRequired[str | None]


class Deliverable(TypedDict):
    title: str
    lang: NotRequired[str]
    code: str


class DoneWellItem(TypedDict):
    item: str
    evidence: NotRequired[str | None]


class PlanItem(TypedDict):
    bucket: Literal["today", "sprint", "architectural"]
    position: int
    item: str


class FindingsDocument(TypedDict):
    """Machine-readable twin of the audit report. Long-form arrays (metrics, history, adjudications, findings, plan) map 1:1 onto SQL tables; join runs on run.url + run.date + run.strategy, join flags across runs on adjudications[].audit_id (stable Lighthouse IDs), and join findings across runs on findings[].slug (stable problem-class ID; findings[].id is positional and re-assigned every run)."""

    schema: Literal["pagespeed-audit/findings@1"]
    run: RunInfo
    metrics: list[MetricRow]
    history: NotRequired[list[HistoryRow]]
    adjudications: list[Adjudication]
    findings: list[Finding]
    deliverables: NotRequired[list[Deliverable]]
    done_well: NotRequired[list[DoneWellItem]]
    plan: NotRequired[list[PlanItem]]



Domain model

Versioning

The document identifier stays pagespeed-audit/findings@1 while changes are additive: new optional fields may appear, existing ones are never removed or renamed. A change that breaks a consumer ships as a new schema id and a new report format, and is announced on the changelog first.

The audited site's own evidence is quoted in the document — headers, byte counts, snippets — so a finding can be checked without trusting the finder. What a kilobyte costs, measured →

Authorization

One matrix, read from the routing table.

Every key carries scopes chosen at creation. A key created without a list gets read and run; purchase is never implicit, because it is the only scope that can cost money. API-key management is different on purpose: it needs the signed-in dashboard session, so a key can never mint or revoke keys.

OperationsreadrunpurchaseDashboard sessionNo credential
Checkout Start a checkout no no no no yes
Audits Who am I · List audits · Get run status · Get an audit · Get run timeline · Download the report · List runs of an audit yes no no no no
Audits Start an audit run yes yes no no no
Target Finder List analyses · Get an analysis yes no no no no
Target Finder Analyze your site to find the page to audit yes yes no no no
Orders List recent orders · Order audits (production — may charge a saved card) · Get an order yes yes yes no no
API keys List API keys · Create an API key · Revoke an API key no no no yes no
Affiliate Your affiliate profile · Your traffic & earnings stats · Your payout history · Configure your S2S postback · Your conversions · Your recruit-an-affiliate program no no no no no
Newsletter Subscribe to the newsletter no no no no yes
Contact Send a contact message no no no no no

A scoped refusal is 403 with insufficient_scope and the missing scope named in detail. A key on key management is 403 dashboard_session_required. Rate limits: 60 requests per minute per account on authenticated operations, 5 per minute on ordering, 10 per minute per IP on anonymous ones.

Target Finder

The recommendation explains itself before it asks to be trusted.

Available while an audit's URL is still unlocked, and only to accounts holding an active audit. Asynchronous because it reads the site: 202 with an analysis whose stage you can watch move; poll every few seconds. The worker fails an analysis that has not finished within its 90-second budget.

  1. 1Queued Accepted. Your site as a public URL or bare domain; the origin is established.
  2. 2Discovering robots.txt, then the sitemap where one exists, then homepage discovery as the fallback.
  3. 3Clustering URLs are grouped into templates — product, article, category and the rest — so a page can stand for many.
  4. 4Probing Candidates are fetched: reachability, HTTP status, navigation linkage, and whether Google CrUX holds field data at page or origin level.
  5. 5Adjudicating One primary recommendation with alternates, each with its reason and when to prefer it.
  6. 6Done status: Succeeded (or Failed with a failureCode). The recommendation is on the analysis.

The result, once status is Succeeded

recommendation.siteKind, one primary choice and its alternates, each with the URL, the template it represents and that template's share of the site's URLs, the reason, whether CrUX field data exists at page or origin level, the probe's HTTP status, whether navigation links to it, and when to prefer an alternate. homepageNote says what the homepage is in this site's structure. The counts — urlsSeen, templatesFound, candidatesProbed, sitemapUsed — say what the analysis actually read.

Refusals

Before locking a URL, the Speed → Revenue calculator puts a commercial value on the candidates the finder returns.

Error recovery

Branch on stable codes, not prose.

Every refusal the API makes on purpose is RFC 9457 problem+json with a machine-readable code, and a type that links to that code's entry in the catalog. The prose in detail is free to improve; the code is not. A bare 401, 429 or 500 carries no body.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://pagespeedaudit.com/docs/errors#runs_exhausted",
  "title": "Conflict",
  "status": 409,
  "detail": "All included runs have been used.",
  "code": "runs_exhausted"
}
StatusCodes
400 Bad request — the input is wrong url_requiredinvalid_urlinvalid_formatinvalid_quantityidempotency_key_requiredunknown_scopeunusable_emailinvalid_dateinvalid_group_byinvalid_statusinvalid_postback_eventsinvalid_postback_urlinvalid_emaildisposable_emailinvalid_message
402 Payment required — the saved card was refused authentication_requiredcard_declined
403 Forbidden — this credential may not do this insufficient_scopedashboard_session_requiredapi_key_requiredcustomers_onlynot_an_affiliatecross_site_request
404 Not found — or not yours not_foundreport_not_readyaccount_not_found
409 Conflict — the resource's state refuses this url_lockedrun_in_progressruns_exhaustedexpiredrevokedidempotency_key_reusedwholesale_unavailableaccount_email_missingurl_already_lockedduplicate_key_namekey_limit_reached
429 Too many requests — slow down daily_limit_reachedanalysis_limit_reached
502 Bad gateway — an upstream call failed checkout_unavailable
503 Service unavailable — switched off or paused ordering_disabledordering_pausedtarget_finder_disabled
401 Unauthorized — no usable credentialno code — The `X-Api-Key` header is missing, malformed, revoked, or the account is blocked. Empty body.
429 Too many requests — slow downno code — The rate limit is exceeded: 60 requests per minute per account on authenticated operations, 10 per minute per IP on anonymous ones, 5 per minute to `POST /orders` (dry runs included). Empty body.
500 HTTP 500no code — An unhandled failure — nothing the API refused on purpose. Empty body.

API reference

Every operation, with what it needs before the code.

Read from the routing table at render time — an operation appears here the moment it exists. The full reference with request and response schemas, examples and a production request console is at /docs/reference.

26 operations

Checkout

OperationSummaryCredentialScopeLimit
POST /api/v1/checkout/sessions Start a checkout None 10 / min per IP

Audits

OperationSummaryCredentialScopeLimit
GET /api/v1/me Who am I API key read 60 / min per account
GET /api/v1/audits List audits API key read 60 / min per account
GET /api/v1/runs/{runId} Get run status API key read 60 / min per account
GET /api/v1/audits/{auditId} Get an audit API key read 60 / min per account
GET /api/v1/runs/{runId}/events Get run timeline API key read 60 / min per account
GET /api/v1/runs/{runId}/report Download the report API key read 60 / min per account
GET /api/v1/audits/{auditId}/runs List runs of an audit API key read 60 / min per account
POST /api/v1/audits/{auditId}/runs Start an audit run API key run 60 / min per account

Target Finder

OperationSummaryCredentialScopeLimit
GET /api/v1/target-finder/analyses List analyses API key read 60 / min per account
POST /api/v1/target-finder/analyses Analyze your site to find the page to audit API key run 60 / min per account
GET /api/v1/target-finder/analyses/{analysisId} Get an analysis API key read 60 / min per account

Orders

OperationSummaryCredentialScopeLimit
GET /api/v1/orders List recent orders API key purchase 60 / min per account
POST /api/v1/orders Order audits (production — may charge a saved card)may charge API key purchase 5 / min per account
GET /api/v1/orders/{orderId} Get an order API key purchase 60 / min per account

API keys

OperationSummaryCredentialScopeLimit
GET /api/v1/apikeys List API keys Dashboard session 60 / min per account
POST /api/v1/apikeys Create an API key Dashboard session 60 / min per account
DELETE /api/v1/apikeys/{keyId} Revoke an API key Dashboard session 60 / min per account

Affiliate

OperationSummaryCredentialScopeLimit
GET /api/v1/affiliate/me Your affiliate profile API key 60 / min per account
GET /api/v1/affiliate/stats Your traffic & earnings stats API key 60 / min per account
GET /api/v1/affiliate/payouts Your payout history API key 60 / min per account
PUT /api/v1/affiliate/postback Configure your S2S postback API key 60 / min per account
GET /api/v1/affiliate/referrals Your conversions API key 60 / min per account
GET /api/v1/affiliate/recruiting Your recruit-an-affiliate program API key 60 / min per account

Newsletter

OperationSummaryCredentialScopeLimit
POST /api/v1/newsletter/subscribe Subscribe to the newsletter None 10 / min per IP

Contact

OperationSummaryCredentialScopeLimit
POST /api/v1/contact Send a contact message API key 60 / min per account

Client examples

Typed clients without pretending an SDK exists.

Standard HTTP clients, an explicit timeout, the exact header, the stable error code, and polling that stops on a terminal state. Starting points to paste into your own codebase — not packages.

TypeScript · Node 18+ · no dependencies

// Dependency-free: fetch, an explicit timeout, the stable error code, terminal-state polling.
type RunStatus = "Queued" | "Starting" | "Running" | "Succeeded" | "Failed" | "TimedOut" | "Canceled";
const TERMINAL: RunStatus[] = ["Succeeded", "Failed", "TimedOut", "Canceled"];

const base = "https://pagespeedaudit.com/api/v1";
const headers = { "X-Api-Key": process.env.PAGESPEEDAUDIT_API_KEY!, "Content-Type": "application/json" };

async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(base + path, { ...init, headers, signal: AbortSignal.timeout(30_000) });
  if (!response.ok) {
    const problem = await response.json().catch(() => ({}));
    throw new Error(problem.code ?? `HTTP ${response.status}`); // branch on code, never on detail
  }
  return response.json() as Promise<T>;
}

export async function auditOnce(auditId: string, url: string) {
  const { runId } = await api<{ runId: string }>(`/audits/${auditId}/runs`, { method: "POST", body: JSON.stringify({ url }) });
  const deadline = Date.now() + 24 * 3_600_000; // the delivery promise; a run never claimed must not poll forever
  for (;;) {
    const run = await api<{ status: RunStatus; errorCode?: string }>(`/runs/${runId}`);
    if (TERMINAL.includes(run.status)) {
      // Failed / TimedOut / Canceled have no report and consumed no run: surface them, never swallow them.
      if (run.status !== "Succeeded") throw new Error(`run ${runId} ended ${run.status}${run.errorCode ? `: ${run.errorCode}` : ""}`);
      return api(`/runs/${runId}/report?format=json`);
    }
    if (Date.now() > deadline) throw new Error(`run ${runId} still ${run.status} after 24 h`);
    await new Promise((r) => setTimeout(r, 5_000));
  }
}

C# · .NET 8+ · System.Net.Http only

// System.Net.Http only. The problem body's "code" is the thing to branch on.
using System.Net.Http.Json;
using System.Text.Json;

var http = new HttpClient { BaseAddress = new Uri("https://pagespeedaudit.com/api/v1/"), Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Add("X-Api-Key", Environment.GetEnvironmentVariable("PAGESPEEDAUDIT_API_KEY"));

async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
    using var request = new HttpRequestMessage(method, path) { Content = body is null ? null : JsonContent.Create(body) };
    using var response = await http.SendAsync(request);
    var json = await response.Content.ReadFromJsonAsync<JsonElement>();
    if (!response.IsSuccessStatusCode)
        throw new InvalidOperationException(json.TryGetProperty("code", out var c) ? c.GetString() : $"HTTP {(int)response.StatusCode}");
    return json;
}

var run = await ApiAsync(HttpMethod.Post, $"audits/{auditId}/runs", new { url = "https://example.com/" });
var runId = run.GetProperty("runId").GetGuid();
var deadline = DateTime.UtcNow.AddHours(24); // the delivery promise; a run never claimed must not poll forever
JsonElement state;
do
{
    await Task.Delay(TimeSpan.FromSeconds(5));
    state = await ApiAsync(HttpMethod.Get, $"runs/{runId}");
    if (DateTime.UtcNow > deadline)
        throw new TimeoutException($"run {runId} still {state.GetProperty("status")} after 24 h");
} while (state.GetProperty("status").GetString() is "Queued" or "Starting" or "Running");

// Failed / TimedOut / Canceled have no report and consumed no run: say so, never exit clean.
if (state.GetProperty("status").GetString() != "Succeeded")
    throw new InvalidOperationException($"run {runId} ended {state.GetProperty("status")}: {(state.TryGetProperty("errorCode", out var e) ? e.GetString() : "no report")}");

File.WriteAllText("report.findings.json", (await ApiAsync(HttpMethod.Get, $"runs/{runId}/report?format=json")).GetRawText());

Python 3.10+ · requests

# requests only. Every failure carries a stable "code"; poll with a fixed interval and stop on a terminal state.
import os, time, requests

BASE = "https://pagespeedaudit.com/api/v1"
HEADERS = {"X-Api-Key": os.environ["PAGESPEEDAUDIT_API_KEY"]}
TERMINAL = {"Succeeded", "Failed", "TimedOut", "Canceled"}

def api(method, path, **kwargs):
    r = requests.request(method, BASE + path, headers=HEADERS, timeout=30, **kwargs)
    if not r.ok:
        raise RuntimeError(r.json().get("code", f"HTTP {r.status_code}"))
    return r.json()

def audit_once(audit_id, url):
    run_id = api("POST", f"/audits/{audit_id}/runs", json={"url": url})["runId"]
    deadline = time.time() + 24 * 3600  # the delivery promise; a run never claimed must not poll forever
    while True:
        run = api("GET", f"/runs/{run_id}")
        if run["status"] in TERMINAL:
            # Failed / TimedOut / Canceled have no report and consumed no run: raise, never return None.
            if run["status"] != "Succeeded":
                raise RuntimeError(f"run {run_id} ended {run['status']}: {run.get('errorCode') or 'no report'}")
            return api("GET", f"/runs/{run_id}/report?format=json")
        if time.time() > deadline:
            raise TimeoutError(f"run {run_id} still {run['status']} after 24 h")
        time.sleep(5)

Contextual resources

Connect the API to the decision being made.

Grouped by the moment they become useful, not dumped in a footer. Every instrument is free; every article links its method.

Contract confidence

Version, stability and change are visible.

Version
v1.0.0 · /api/v1
Stability
Stable. Additive changes only on v1; a breaking change ships under a new path version.
Last contract change
what changed
Errors
RFC 9457 problem+json with code · catalog
Findings document
pagespeed-audit/findings@1 · schema
OpenAPI
3.1 · /openapi/v1.json · api-catalog

Operational status · checked

  • API and database Operational
  • Audit processing (worker heartbeats) Operational

Live, from the same health checks the deploy gate uses: /healthz/ready. No incident history is published yet; the changelog records every contract change with its date.

  1. AdditiveDry-run quotes on POST /orders
  2. ChangedOrdering needs an API key; a signed-in session is refused
  3. Additive402 problems carry the order they refused
  4. AdditiveEvery problem response links to its catalog entry

Start with a key that cannot buy anything.

Create a read + run key, send the safe GET /audits, and add purchase only when an ordering workflow truly needs it.

No entitlement yet? See what every audit includes · Agencies: resell at wholesale · Questions: contact