PerfDelta API: MCP and REST
Last updated: 13 October 2026.
PerfDelta offers two ways to start PageSpeed scans programmatically, manage your projects and read the full results: MCP for AI agents such as Claude, ChatGPT or Cursor, and a REST API with plain JSON for Zapier, Make or your own dashboards. Both use the same API key and the same budget as the web app. How each value is measured is described in the methodology.
Access by plan
| Plan | MCP | REST API | PageSpeed tests per month | PageSpeed tests per day for manual scans, MCP and REST |
|---|---|---|---|---|
| Free | no | no | none | one scan of up to three pages in the web app |
| Solo | yes | no | 600 | 120 |
| Agency | yes | yes | 4,000 | 800 |
| Scale | yes | yes | 12,000 | 2,400 |
API keys are created on the API keys page of your account. Team members use the plan, projects and budget of the team owner.
Authentication
Every request carries the API key as a bearer token:
Authorization: Bearer YOUR_API_KEY
For the Claude.ai connector, which cannot send headers, the MCP endpoint also accepts the key as a URL parameter: https://perfdelta.com/api/mcp.php?key=YOUR_API_KEY. Anyone with this URL can use your key, so keep it private and revoke the key if it leaks. The REST API accepts the header only.
REST: without a valid key the API answers 401 unauthorized. After 20 failed attempts within 15 minutes from the same IP address it answers 429 too_many_failed_attempts with Retry-After in seconds. A plan without access gets 403 upgrade_required with the plan that includes it. MCP applies the same rules but answers with JSON-RPC errors, see MCP errors. Failed attempts are counted separately for MCP and REST.
Budget and costs
- A PageSpeed test is one Lighthouse run for one URL on one device. A scan of three URLs on mobile and desktop costs six.
- Scans via MCP and REST draw on the same monthly budget as monitoring and manual scans in the web app. Monitoring comes first: a scan only starts if it leaves enough for monitoring until the end of the month.
- Manual scans, including MCP and REST, have a daily cap of one fifth of the monthly budget. The daily cap resets at midnight, the monthly budget on the first of the month.
- A URL from one of your projects is scanned in its project, together with the other URLs of the same domain and interval. The cost covers all of these URLs. Your project competitors are compared with Chrome UX Report field data, which costs no PageSpeed tests.
- Reading reports, projects, URLs, history, scan status and usage is free, and so is managing projects, URLs and competitors. URLs you add are measured by monitoring from the monthly budget.
- Each account can send 60 requests per minute, counted separately for MCP and REST. All keys of the account and all team members share this limit. Above it REST answers
429 rate_limitedand MCP the JSON-RPC error-32003, both withRetry-After: 60.
The parameter devices chooses what is measured: both (default), mobile or desktop. A single device halves the cost.
REST API
Base URL: https://perfdelta.com/api/v1. All responses are JSON. Times are in German local time (Europe/Berlin) in the format YYYY-MM-DD HH:MM:SS.
Start a scan: POST /scans.php
Body as JSON or form-encoded: url (absolute, publicly reachable URL) or url_id (a URL from your projects, see /urls.php), plus optionally devices, project_id and scope. If both are sent, url wins. With project_id, a URL that is not yet in one of your projects is first added to that project, like "Add URL" on the project page; it then counts against your URL limit and the scan appears in your dashboard and project history. scope sets what is measured: url (default) measures only this URL. group measures all URLs of its project group on the same domain, like the scan button on the project page, and costs one test per URL and device; only group scans of a URL that shares its group with other URLs appear in its project history. A single-URL scan stays readable by scan_id and report_url.
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/", "devices": "both"}' \
"https://perfdelta.com/api/v1/scans.php"
Response 202:
{
"scan_id": "7c88c55c1d1bc8f6727d8d19ba9c9a00",
"status": "running",
"devices": "both",
"scope": "url",
"measured_urls": 1,
"cost": 2,
"report_url": "https://perfdelta.com/en/r/example.com/7c88c55c-1d1b-c8f6-727d-8d19ba9c9a00",
"budget_remaining": 11936,
"daily_remaining": 2398,
"project_id": 3,
"url_id": 41,
"added_to_project": true
}
budget_remaining is what is still left this month for manual scans, MCP and REST after the monitoring need; daily_remaining is the rest of today's cap. project_id and url_id name the project URL the scan belongs to (null for a URL outside your projects); added_to_project is true when this request added the URL. scope repeats the measuring scope, measured_urls is the number of URLs in this scan.
Scan status and result: GET /scans.php?id={scan_id}
Returns status (running, done or error), started_at and report_url. While running it adds step, step_total, step_msg, updated_at and pages measured so far (same format as below), so a slow scan can be told apart from a stuck one and finished pages can be read early; with error it adds error. With done it also returns mobile_score, desktop_score (average lab score of the measured pages, 0 to 100, null for a device that was not measured), measured_at and pages: one entry per measured page and device with url, device, score, lab (lcp_ms, cls, fcp_ms, tbt_ms, ttfb_ms from Lighthouse) and field (lcp_ms, inp_ms, cls, fcp_ms, ttfb_ms at the 75th percentile of real Chrome users, null without enough field data). A scan usually takes one to three minutes; poll every 15 to 30 seconds. Only the account that started the scan can read it, any other key gets 404. The id may also be written with hyphens as in report_url; case does not matter.
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://perfdelta.com/api/v1/scans.php?id=7c88c55c1d1bc8f6727d8d19ba9c9a00"
Budget: GET /usage.php
Fields: plan, unit (always PageSpeed tests (one per URL and device)), daily_limit, daily_used, daily_remaining, monthly_budget, monthly_used, monitoring_projected (what monitoring needs per month with the current settings), monitoring_remaining_need (what it still needs until the end of the month), available_for_manual_scans and upgrade_plan (next plan with more capacity, null on Scale). All values are PageSpeed tests. Without a monthly budget monthly_budget is null and the four fields from monthly_used to available_for_manual_scans are omitted.
Reading data
| Endpoint | Returns |
|---|---|
GET /projects.php | projects: id, name |
GET /urls.php?project_id={id} | urls: id, url, monitoring_enabled, last_monitored_at, within_plan_limit (false: beyond your URL limit, not measured) |
GET /history.php?url_id={id} | history, newest first: hash, created_at, mobile_score, desktop_score |
GET /report.php?url={url} | latest completed result of any scanned domain: status, mobile_score, desktop_score, measured_at, report_url, scan_id (for /details.php), mobile_measured_at and desktop_measured_at. If the latest scan measured only one device, the other score comes from the most recent earlier scan that measured it, with its own date in mobile_measured_at or desktop_measured_at (null if that device was never measured). While a newer scan runs or after it failed also latest_status and latest_started_at. Without any completed scan, status is that of the latest scan and the scores and measured_at are null. Unknown domain: status: not_found. Only the host of url counts, a path is ignored; URL-encode the parameter. |
GET /fixes.php?url={url}&lang=en | prioritised fixes from the latest completed scan of any scanned domain, no measurement used: status, measured_at, report_url, platform (detected CMS or framework), field (source, p75, rating of LCP, INP and CLS for real users) and fixes, most important first. Each fix: id, type, title, problem, action (text tailored to the platform, lang en or de), metrics (affected Core Web Vitals), fails_in_field (the affected metric that fails for real users, else null), impact (one of estimated_savings_ms, estimated_savings_bytes, unused_bytes, main_thread_ms, transfer_bytes, total_blocking_time_ms, lcp_phase_ms, cls), devices, pages, files (url, wasted_bytes, wasted_ms, estimated), elements (HTML of layout-shifting elements), benchmark (share of measured sites with this finding, median_ms typical saving or main-thread time there, scope, platform, domain_count), observed_effect (metric, median_delta, n, scope: median change of the lab metric where this fix disappeared between two scans, from 10 observations) and guide_url. |
GET /changes.php?url={url}&lang=en | what changed between the latest and the previous completed scan, no measurement used: status (done, no_baseline without an earlier scan with stored page data, not_found), measured_at, previous_measured_at, report_url, page and device compared, scores and previous_scores (mobile, desktop), changes (kind, direction worse, better or neutral, summary, plus resource_type, name, phase, id, before, after, bytes as applicable) and implemented (fixes from the previous scan that are gone now: title, metric, before_value, after_value, delta). |
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://perfdelta.com/api/v1/report.php?url=https%3A%2F%2Fexample.com%2F"
Full report: GET /details.php?id={scan_id}
The same response as the MCP tool get_report_details, described below: everything the report page shows its owner. id is a scan_id from /scans.php or /report.php, or a hash from /history.php; alternatively url_id returns the latest scan of a project URL. Works for scans you started and for every scan of a URL in your projects, including monitoring runs. Any other scan answers 404 not_found, a request without id or url_id 422 missing_id. Free.
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://perfdelta.com/api/v1/details.php?id=7c88c55c1d1bc8f6727d8d19ba9c9a00"
Managing projects, URLs, competitors and notes
The same rules as in the web app apply: plans without multiple projects have one project, URLs count against your plan's URL limit across all projects, only public http(s) URLs are accepted, and the number of competitors per project depends on the plan. Bodies are JSON; POST also accepts form fields.
| Endpoint | Body or parameters | Returns |
|---|---|---|
POST /projects.php | name | 201 with id, name |
POST /urls.php | project_id and url, or urls as a list of up to 100 | added (id, url) and skipped (url, reason: invalid_url, duplicate or url_limit_reached); 201 if at least one URL was added, otherwise 200. URLs without a scheme get https:// |
PATCH /urls.php | url_id, monitoring_enabled (true or false) | id, monitoring_enabled |
DELETE /urls.php?url_id={id} | none | deleted with the id; completed reports remain |
GET /competitors.php?project_id={id} | none | competitors: id, domain |
POST /competitors.php | project_id, domain (domain or URL) | 201 with id, domain; compared from the next scan on |
DELETE /competitors.php?project_id={id}&id={competitor_id} | none | deleted with the id |
GET /notes.php?id={scan_id} | none | notes: your notes on a scan with id, note, created_at |
POST /notes.php | scan_id, note (at most 280 characters) | 201 with id, scan_id, note, created_at; plans with unlimited history only, the note appears in the report history |
PATCH /notes.php | note_id, note | id, note; on any plan |
DELETE /notes.php?note_id={id} | none | deleted with the id; on any plan |
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"project_id": 6, "urls": ["https://example.com/", "https://example.com/pricing/"]}' \
"https://perfdelta.com/api/v1/urls.php"
Error codes
Errors have the form {"error": "code"}. Errors when starting a scan, when creating or changing projects, URLs, competitors and notes, and upgrade_required add a message; scan errors may add further details as listed below.
| HTTP | error | Meaning |
|---|---|---|
| 401 | unauthorized | key missing, wrong or revoked |
| 402 | budget_exhausted | the monthly budget left after the monitoring need does not cover this scan; with needed, remaining, limit, upgrade_plan |
| 402 | daily_limit | today's cap does not cover this scan; same details, resets at midnight |
| 403 | upgrade_required | your plan does not include the REST API |
| 403 | over_url_limit, url_limit_reached, demo_blocked | URL beyond your plan's URL limit, all URLs of your plan in use when adding one via project_id (with upgrade_plan), or the fixed demo domain |
| 403 | project_limit, competitor_limit, monitoring_unavailable, notes_unavailable | your plan allows no further project, no further competitor in this project, or has no monitoring or notes |
| 404 | not_found, url_not_found, unknown_project_id, unknown_url_id, unknown_competitor_id, unknown_note_id | unknown or not yours |
| 405 | method_not_allowed | wrong HTTP method |
| 409 | duplicate | the competitor is already in the project |
| 422 | missing_url, invalid_url, invalid_devices, invalid_scope, missing_id, missing_name, too_many_urls, invalid_domain, invalid_monitoring_enabled, missing_note, note_too_long | input missing or invalid; only public http(s) URLs can be scanned or added |
| 429 | throttled, rate_limited, too_many_failed_attempts | same URL scanned a few minutes ago (with retry_at), too many requests, too many failed logins |
| 500 | scan_failed | the scan could not be started for another reason; retry later or contact us |
| 503 | busy, capacity_exhausted, disabled | all measurement slots busy (retry shortly), daily capacity used up, API switched off |
MCP for AI agents
Endpoint: https://perfdelta.com/api/mcp.php. Transport is Streamable HTTP with JSON-RPC 2.0, stateless: every request is a single POST with a JSON body and gets a JSON answer. There is no Mcp-Session-Id and no SSE stream; GET answers 405. Supported protocol versions: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. The server answers initialize with the version the client asks for, or with 2026-07-28 if it does not know it, and includes instructions on costs and flow.
initialize, notifications/initialized, ping and tools/list work without a key, so clients and directories can read the tool list before you connect. tools/call requires a key. A key that is sent but invalid is rejected for every method. If a client sends the optional headers Mcp-Method or Mcp-Name, they must match the method and tool name in the body.
Setup
Claude Code:
claude mcp add --transport http perfdelta https://perfdelta.com/api/mcp.php \
--header "Authorization: Bearer YOUR_API_KEY"
Or in .mcp.json of a project:
{
"mcpServers": {
"perfdelta": {
"type": "http",
"url": "https://perfdelta.com/api/mcp.php",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}
Cursor: the same block without "type" in .cursor/mcp.json. VS Code: in .vscode/mcp.json under "servers" instead of "mcpServers", with "type": "http".
Claude.ai and Claude Desktop: Settings, Connectors, Add custom connector, with the connector URL https://perfdelta.com/api/mcp.php?key=YOUR_API_KEY. ChatGPT (developer mode): Settings, Connectors, Create, with the same URL including ?key= and no authentication, because the dialog does not offer custom headers.
Tools
| Tool | Parameters | Costs | What it does |
|---|---|---|---|
scan_url | url (string, required), devices (optional), project_id (optional), scope (optional, url or group) | PageSpeed tests | starts a scan; returns the same fields as POST /scans.php. There is no url_id: pass the project URL itself. By default only that URL is measured; scope=group measures its whole project group. With project_id a new URL is added to that project first, so the report shows up in your dashboard |
get_scan | scan_id (string, required) | free | status, progress, scores and Core Web Vitals per page, same fields as GET /scans.php |
get_usage | none | free | budget and remaining tests, same fields as GET /usage.php |
get_report | url (string, required) | free | latest result of any scanned domain, same fields as GET /report.php |
get_report_details | scan_id or url_id | free | the full report of one of your scans, see below |
list_projects | none | free | your projects as a list of id, name, url_count |
get_project | project_id (integer, required) | free | one project with settings (monitoring frequency and devices, alerts, weekly report, public dashboard), competitors and urls, each with tags, monitoring state, within_plan_limit and latest_scan (scan_id, status, scores, measured_at, report_url) |
get_fixes | url (string, required), lang (optional, en or de) | free | prioritised fixes with affected files, elements, benchmark and observed effect, same fields as GET /fixes.php. Hand the files to a coding agent to fix them |
get_changes | url (string, required), lang (optional, en or de) | free | changes since the previous scan and implemented fixes, same fields as GET /changes.php. Use it to explain a score drop |
list_urls | project_id (integer, required) | free | URLs of a project as a list with the fields of GET /urls.php |
get_history | url_id (integer, required) | free | score history of a project URL, {"history": [...]} as in GET /history.php; each hash is a scan_id for get_report_details |
get_monitoring_events | project_id (integer, required), limit (optional, default 50, at most 200) | free | {"events": [...]}, newest first: kind (regression or improvement), url_id, url, scan_id, reasons (score change or Core Web Vitals failing/passing), alert_sent, created_at; kept for 90 days |
create_project | name (string, required) | free | creates a project, returns id, name |
add_urls | project_id (integer, required), urls (list of strings, required) | free | adds URLs to a project, added and skipped as in POST /urls.php |
remove_url | url_id (integer, required) | free | removes a URL from its project |
set_monitoring | url_id (integer, required), enabled (boolean, required) | free | switches monitoring of a project URL on or off |
list_competitors | project_id (integer, required) | free | competitors of a project as a list of id, domain |
add_competitor | project_id (integer, required), domain (string, required) | free | adds a competitor, returns id, domain |
remove_competitor | project_id (integer, required), competitor_id (integer, required) | free | removes a competitor from a project |
add_note | scan_id (string, required), note (string, required) | free | attaches a note to a scan, same fields as POST /notes.php |
update_note | note_id (integer, required), note (string, required) | free | changes one of your notes |
delete_note | note_id (integer, required) | free | deletes one of your notes |
Results come as one text block with JSON. Unlike REST, list_projects, list_urls and list_competitors return the list directly, without the wrapping object. Typical flow for an agent: get_usage, then scan_url, then get_scan every 15 to 30 seconds until done, then get_report_details for the full report. To set up monitoring: create_project, add_urls, optionally add_competitor.
Full report: get_report_details
Returns everything the report page shows to the owner, as JSON for analysis by an AI: summary (scores, previous scores, Core Web Vitals pages passing per device, thresholds, global pass rate, biggest bottleneck), origin_field_data and origin_field_history (CrUX for the whole domain), competitors, perfdelta_average, industry_benchmark (plans with industry benchmark), pages, tech_stack, ai_recommendations, notes and history. Each entry in pages holds one URL and device: score, field and lab with a rating per metric (good, ni, poor), field_distribution, cwv_pass, page_weight_bytes, resource_breakdown, third_parties, opportunities with estimated savings and diagnostics (LCP element, layout shifts, main-thread work, script bundles, largest requests). screenshots and filmstrip (each mobile and desktop) hold absolute image URLs, filmstrip frames with url and timing_ms. notes lists your own notes with id, note, created_at, for use with update_note and delete_note. Works for scans in your projects and for scans your account started; for other domains use get_report. Field names in detail: the top level has complete (true once the scan is done) and purged (true if the page data of an old scan was deleted). summary holds mobile_score, desktop_score, previous_mobile_score, previous_desktop_score, cwv_pages_passing (per device pass and total), cwv_thresholds, global_cwv_pass_rate (with source) and biggest_bottleneck. Each page has field_is_origin_fallback (true: values for the whole domain, the page had no own field data), field_distribution with dist shares per metric, request_count, and per opportunity id, title, savings_ms and resources; the page score is the Lighthouse performance_score. ai_recommendations_lang names the language of the recommendations.
MCP errors
Errors inside a tool come back as a normal tool result with isError: true and a message text, without the REST error code and details. Examples: unknown scan_id, project_id, url_id or competitor_id, a missing url, a reached project, URL or competitor limit, an exhausted budget, or a plan without MCP access (with the plan that includes it). When scan_url fails on budget or daily cap, the text ends with the link to the pricing page. Protocol errors use JSON-RPC error codes and carry the id of the request when it can be read:
| HTTP | code | Meaning |
|---|---|---|
| 200 | -32700 | body is not valid JSON |
| 200 | -32600 | not a JSON-RPC 2.0 request, or the Mcp-Method/Mcp-Name header does not match the body |
| 200 | -32601 | unknown method |
| 401 | -32001 | key missing, wrong or revoked |
| 429 | -32002 | too many failed attempts from this IP address, Retry-After in seconds |
| 429 | -32003 | more than 60 requests per minute, Retry-After: 60 |
| 503 | none | MCP switched off; plain body {"error":"disabled"} |
Questions or a response that does not match this page: contact form.