01 · Product
Integrate audits
Hold entitlements, start runs, poll, and pull the HTML report or the findings JSON into your own workflow.
GET /auditsPOST /audits/{id}/runsGET /runs/{id}GET /runs/{id}/report
PageSpeedAudit Developers · REST API v1.0.0 · production
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.
purchase is never implicitcodeBase URL https://pagespeedaudit.com/api/v1 · OpenAPI 3.1 at /openapi/v1.json
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
01 · Product
Hold entitlements, start runs, poll, and pull the HTML report or the findings JSON into your own workflow.
GET /auditsPOST /audits/{id}/runsGET /runs/{id}GET /runs/{id}/report02 · Agency
Order audits at your wholesale rate with a safe quote first, assign each to a client URL, collect both deliverables under your brand.
POST /orders dryRun, then realPOST /audits/{id}/runsGET /runs/{id}/report?format=html03 · Partner
Referral link and stats, conversions and payouts, recruiting, and a server-to-server postback you configure once.
GET /affiliate/meGET /affiliate/statsPUT /affiliate/postbackFive-minute quickstart
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
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
Step 2 of 5 · read · safe
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).
POST /checkout/sessions, or order on the account — see purchasing.insufficient_scope if the key has no read; 401 if the key is wrong.Step 3 of 5 · run · spends one included run
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.
url_required, invalid_url, url_locked, run_in_progress, runs_exhausted, expired, revoked.409 run_in_progress, never a duplicate run.Step 4 of 5 · read · safe
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 Queued → Starting → Running 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.
run_in_progress is the API refusing to let you.Step 5 of 5 · read · safe
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.
report_not_ready before the run has succeeded; invalid_format. A non-2xx is never a report — hence --fail and the rename.#!/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
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
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
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.
GET /audits → status: Active, urlLockedAtUtc: nullMutation
POST /audits/{auditId}/runs with { "url": "https://example.com/" }. Returns 202 and a runId. From here the entitlement belongs to that URL; expiresAtUtc is set 30 days out.
run scopeAsynchronous
Poll GET /runs/{runId} every few seconds. status moves Queued → Starting → Running. A second POST meanwhile is answered 409 run_in_progress — a retried request can never start a duplicate.
GET /runs/{runId}/events — human-readable timelineTerminal · consumed
availableReportFormats lists json and html. One of the 3 runs is consumed. GET /runs/{runId}/report?format=json|html streams either twin.
Terminal · not consumed
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.
Canceled — a run stopped before completion; it consumes nothing eitherOptional branch
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}.
Loop
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.
Ends
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.
POST /audits/{id}/runs { url }runId · status: QueuedGET /runs/{runId}status: RunningGET /runs/{runId}status: Succeeded · availableReportFormats: ["json","html"]GET /runs/{runId}/report?format=jsonpagespeed-audit/findings@1 documentMoney-moving operations
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.
No — a visitor is buying
POST /checkout/sessionshosted Stripe Checkout URL · the buyer pays in a browser · the entitlement lands on their emailYes
Yes
POST /audits/{id}/runsskip purchasing entirelyNo
POST /orders { "quantity": 1, "dryRun": true }200 quote — price, discount, route · nothing createdPOST /orders + Idempotency-Key201 Checkout URL — no saved cardAn approved reseller's wholesale price is applied server-side on every order — there is no code to send and none can be supplied. If it cannot be applied the order fails with wholesale_unavailable rather than billing at list price.
| Situation | Operation | Result |
|---|---|---|
| A visitor is buying, without API authentication | POST /checkout/sessions | Hosted Stripe Checkout URL · $497 |
| Account, unsure of price or route | POST /orders dryRun | 200 quote — nothing created or charged |
| Account, no saved card | POST /orders | 201 order + checkoutUrl · paymentRoute: link |
| Account with a saved card under an off-session mandate | POST /orders | 202 charged immediately (status: paid) · credits arrive on settlement (completed) · paymentRoute: charge |
| Approved reseller, either route | POST /orders | Wholesale applied server-side: $298.20 per audit instead of $497 (40% off) |
| Unused entitlement already held | POST /audits/{id}/runs | Skip purchasing |
The safe default
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
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-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.POST /orders per account, dry runs included.429 daily_limit_reached; a platform-wide backstop — 503 ordering_paused; a kill switch — 503 ordering_disabled. Nothing is charged on any of them.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
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.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
confirmed — true, material, controllable; owed engineering effort.false_positive — falsified by direct evidence.deprioritized — true, but owed no effort.platform_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| Field | Type | Required | Meaning |
|---|---|---|---|
schema | "pagespeed-audit/findings@1" | yes |
run 22 fields| Field | Type | Required | Meaning |
|---|---|---|---|
run | object | yes | |
run.run_id | string | yes | |
run.generated_at | string | yes | |
run.skill_version | string | yes | |
run.site | string | yes | |
run.url | string | yes | |
run.final_url | string | null | ||
run.strategy | mobile | desktop | yes | |
run.date | string | yes | |
run.lighthouse_version | string | null | ||
run.data_sources | string[] | ||
run.field_available | boolean | yes | |
run.binding_constraint | string | yes | |
run.missing_from_psi | string | null | ||
run.adjudication_counts | object | yes | |
run.adjudication_counts.confirmed | integer | yes | |
run.adjudication_counts.false_positive | integer | yes | |
run.adjudication_counts.deprioritized | integer | yes | |
run.adjudication_counts.platform_blocked | integer | yes | |
run.adjudication_counts.readout | integer | yes | |
run.framework | string | 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) | |
run.noise_rate | number | null | Noise & Silence framework: (false_positive + deprioritized + platform_blocked) / adjudicated flags, readout excluded |
metrics 8 fields| Field | Type | Required | Meaning |
|---|---|---|---|
metrics | object[] | yes | |
metrics[].metric | LCP | INP | CLS | FCP | TTFB | yes | |
metrics[].source | field_page | field_origin | lab | yes | |
metrics[].value | number | null | yes | |
metrics[].unit | ms | | ||
metrics[].category | string | null | ||
metrics[].good | number | null | ||
metrics[].poor | number | null |
history 5 fields| Field | Type | Required | Meaning |
|---|---|---|---|
history | object[] | ||
history[].date | string | yes | |
history[].metric | LCP | INP | CLS | FCP | TTFB | yes | |
history[].p75 | number | null | yes | |
history[].unit | ms | |
adjudications 12 fields| Field | Type | Required | Meaning |
|---|---|---|---|
adjudications | object[] | yes | |
adjudications[].audit_id | string | yes | |
adjudications[].disposition | confirmed | false_positive | deprioritized | platform_blocked | readout | yes | |
adjudications[].claim | string | yes | |
adjudications[].evidence | string | yes | |
adjudications[].finding_ref | string | null | ||
adjudications[].metric_savings | object | null | ||
adjudications[].has_quick_fix | boolean | ||
adjudications[].fix | object | null | ||
adjudications[].fix.lang | string | ||
adjudications[].fix.code | string | ||
adjudications[].fix.note | string |
findings 37 fields| Field | Type | Required | Meaning |
|---|---|---|---|
findings | object[] | yes | |
findings[].id | string | yes | 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. |
findings[].slug | string | yes | 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. |
findings[].title | string | yes | |
findings[].severity | CRITICAL | HIGH | MEDIUM | LOW | yes | |
findings[].confidence | HIGH | MEDIUM-HIGH | MEDIUM | LOW | yes | |
findings[].effort | string | null | ||
findings[].tags | string[] | ||
findings[].evidence | string | ||
findings[].scope | string | null | 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. | |
findings[].falsification | 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. | |
findings[].falsification_measured | string[] | null | Claims the audit OBSERVED — header, byte count, snippet, response. Optional; added additively to @1. | |
findings[].falsification_reasoned | 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. | |
findings[].verify_before_shipping | 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. | |
findings[].coupling | object[] | 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. | |
findings[].coupling[].finding | string | yes | |
findings[].coupling[].kind | invalidates | invalidated_by | depends_on | required_by | co-requisite | interacts | yes | |
findings[].coupling[].note | string | null | ||
findings[].coupling[].mirrored | boolean | true = generated from the other finding's declaration, not authored here | |
findings[].chain | string | null | ||
findings[].fix | object | null | ||
findings[].fix.lang | string | ||
findings[].fix.before | string | null | ||
findings[].fix.after | string | ||
findings[].fix.note | string | null | ||
findings[].impact | object | yes | |
findings[].impact.narrative | string | yes | |
findings[].impact.arithmetic | string | null | ||
findings[].impact.metric | LCP | INP | CLS | FCP | TTFB | null | ||
findings[].impact.unit | ms | | null | ||
findings[].impact.min | number | null | ||
findings[].impact.max | number | null | ||
findings[].impact.direction | decrease | increase | null | ||
findings[].impact.confidence | HIGH | MEDIUM-HIGH | MEDIUM | LOW | null | ||
findings[].sequencing | string | null | ||
findings[].locked | boolean | 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. | |
findings[].redirect | string | null | For a locked finding: the controllable equivalent (merchant-side lever) or an explicit stop-trying note shown in place of a fix. |
deliverables 4 fields| Field | Type | Required | Meaning |
|---|---|---|---|
deliverables | object[] | ||
deliverables[].title | string | yes | |
deliverables[].lang | string | ||
deliverables[].code | string | yes |
done_well 3 fields| Field | Type | Required | Meaning |
|---|---|---|---|
done_well | object[] | ||
done_well[].item | string | yes | |
done_well[].evidence | string | null |
plan 4 fields| Field | Type | Required | Meaning |
|---|---|---|---|
plan | object[] | ||
plan[].bucket | today | sprint | architectural | yes | |
plan[].position | integer | yes | |
plan[].item | string | yes |
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.
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 & 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 & 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
Audit entitlement · 1 URL · 3 runs · 30 days
├── Run 1 … 3
│ ├── metrics[] field p75 and lab, separate rows
│ ├── adjudications[] one row per flag · Real / Wrong / Trivial / Locked
│ ├── findings[] evidence · chain · prediction · fix or redirect
│ └── plan[] today · sprint · architectural
└── Deliverables, per succeeded run
├── report.html self-contained, brandable
└── findings.json pagespeed-audit/findings@1Versioning
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
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.
| Operations | read | run | purchase | Dashboard session | No 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
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.
Queued Accepted. Your site as a public URL or bare domain; the origin is established.Discovering robots.txt, then the sitemap where one exists, then homepage discovery as the fallback.Clustering URLs are grouped into templates — product, article, category and the rest — so a page can stand for many.Probing Candidates are fetched: reachability, HTTP status, navigation linkage, and whether Google CrUX holds field data at page or origin level.Adjudicating One primary recommendation with alternates, each with its reason and when to prefer it.Done 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
409 url_already_locked — every entitlement is locked; the moment has passed. Past analyses stay readable.403 customers_only — no active audit on the account; part of the paid audit.429 analysis_limit_reached — the daily allowance is used.Before locking a URL, the Speed → Revenue calculator puts a commercial value on the candidates the finder returns.
Error recovery
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"
}
| Status | Codes |
|---|---|
| 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 credential | no code — The `X-Api-Key` header is missing, malformed, revoked, or the account is blocked. Empty body. |
| 429 Too many requests — slow down | no 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 500 | no code — An unhandled failure — nothing the API refused on purpose. Empty body. |
API reference
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
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
POST /api/v1/checkout/sessions |
Start a checkout | None | — | 10 / min per IP |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
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 |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
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 |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
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 |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
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 |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
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 |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
POST /api/v1/newsletter/subscribe |
Subscribe to the newsletter | None | — | 10 / min per IP |
| Operation | Summary | Credential | Scope | Limit |
|---|---|---|---|---|
POST /api/v1/contact |
Send a contact message | API key | — | 60 / min per account |
No operation matches that filter.
Client examples
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)
code, not the proseContextual resources
Grouped by the moment they become useful, not dumped in a footer. Every instrument is free; every article links its method.
Decide where an audit has the highest commercial and representational value.
Turn a finding into a budget, a testable change and a platform-native action.
impact is recomputed, not copied from the flag.Tell a stable estimate from a statistically defensible improvement claim.
metrics[] rows with different sources together.Contract confidence
/api/v1code · catalogpagespeed-audit/findings@1 · schemaOperational status · checked
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.
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