Feedback

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

PlanMCPREST APIPageSpeed tests per monthPageSpeed tests per day for manual scans, MCP and REST
Freenonononeone scan of up to three pages in the web app
Soloyesno600120
Agencyyesyes4,000800
Scaleyesyes12,0002,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

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

EndpointReturns
GET /projects.phpprojects: 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=enprioritised 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=enwhat 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.

EndpointBody or parametersReturns
POST /projects.phpname201 with id, name
POST /urls.phpproject_id and url, or urls as a list of up to 100added (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.phpurl_id, monitoring_enabled (true or false)id, monitoring_enabled
DELETE /urls.php?url_id={id}nonedeleted with the id; completed reports remain
GET /competitors.php?project_id={id}nonecompetitors: id, domain
POST /competitors.phpproject_id, domain (domain or URL)201 with id, domain; compared from the next scan on
DELETE /competitors.php?project_id={id}&id={competitor_id}nonedeleted with the id
GET /notes.php?id={scan_id}nonenotes: your notes on a scan with id, note, created_at
POST /notes.phpscan_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.phpnote_id, noteid, note; on any plan
DELETE /notes.php?note_id={id}nonedeleted 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.

HTTPerrorMeaning
401unauthorizedkey missing, wrong or revoked
402budget_exhaustedthe monthly budget left after the monitoring need does not cover this scan; with needed, remaining, limit, upgrade_plan
402daily_limittoday's cap does not cover this scan; same details, resets at midnight
403upgrade_requiredyour plan does not include the REST API
403over_url_limit, url_limit_reached, demo_blockedURL 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
403project_limit, competitor_limit, monitoring_unavailable, notes_unavailableyour plan allows no further project, no further competitor in this project, or has no monitoring or notes
404not_found, url_not_found, unknown_project_id, unknown_url_id, unknown_competitor_id, unknown_note_idunknown or not yours
405method_not_allowedwrong HTTP method
409duplicatethe competitor is already in the project
422missing_url, invalid_url, invalid_devices, invalid_scope, missing_id, missing_name, too_many_urls, invalid_domain, invalid_monitoring_enabled, missing_note, note_too_longinput missing or invalid; only public http(s) URLs can be scanned or added
429throttled, rate_limited, too_many_failed_attemptssame URL scanned a few minutes ago (with retry_at), too many requests, too many failed logins
500scan_failedthe scan could not be started for another reason; retry later or contact us
503busy, capacity_exhausted, disabledall 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

ToolParametersCostsWhat it does
scan_urlurl (string, required), devices (optional), project_id (optional), scope (optional, url or group)PageSpeed testsstarts 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_scanscan_id (string, required)freestatus, progress, scores and Core Web Vitals per page, same fields as GET /scans.php
get_usagenonefreebudget and remaining tests, same fields as GET /usage.php
get_reporturl (string, required)freelatest result of any scanned domain, same fields as GET /report.php
get_report_detailsscan_id or url_idfreethe full report of one of your scans, see below
list_projectsnonefreeyour projects as a list of id, name, url_count
get_projectproject_id (integer, required)freeone 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_fixesurl (string, required), lang (optional, en or de)freeprioritised 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_changesurl (string, required), lang (optional, en or de)freechanges since the previous scan and implemented fixes, same fields as GET /changes.php. Use it to explain a score drop
list_urlsproject_id (integer, required)freeURLs of a project as a list with the fields of GET /urls.php
get_historyurl_id (integer, required)freescore history of a project URL, {"history": [...]} as in GET /history.php; each hash is a scan_id for get_report_details
get_monitoring_eventsproject_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_projectname (string, required)freecreates a project, returns id, name
add_urlsproject_id (integer, required), urls (list of strings, required)freeadds URLs to a project, added and skipped as in POST /urls.php
remove_urlurl_id (integer, required)freeremoves a URL from its project
set_monitoringurl_id (integer, required), enabled (boolean, required)freeswitches monitoring of a project URL on or off
list_competitorsproject_id (integer, required)freecompetitors of a project as a list of id, domain
add_competitorproject_id (integer, required), domain (string, required)freeadds a competitor, returns id, domain
remove_competitorproject_id (integer, required), competitor_id (integer, required)freeremoves a competitor from a project
add_notescan_id (string, required), note (string, required)freeattaches a note to a scan, same fields as POST /notes.php
update_notenote_id (integer, required), note (string, required)freechanges one of your notes
delete_notenote_id (integer, required)freedeletes 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:

HTTPcodeMeaning
200-32700body is not valid JSON
200-32600not a JSON-RPC 2.0 request, or the Mcp-Method/Mcp-Name header does not match the body
200-32601unknown method
401-32001key missing, wrong or revoked
429-32002too many failed attempts from this IP address, Retry-After in seconds
429-32003more than 60 requests per minute, Retry-After: 60
503noneMCP switched off; plain body {"error":"disabled"}

Questions or a response that does not match this page: contact form.