Site Health API
Start a bounded crawl of one of your sites, then read page health, dead links and a score.
A Site Health run crawls one of your projects (sites). It starts at the site's root, follows internal links, and records each page's health. Runs are asynchronous: you start one, then poll it.
Scopes: site-health:write to start runs, site-health:read to read them, and projects:read to look up project ids with GET /api/v1/projects. Keys created with every scope (*) can read runs but can't start them; grant site-health:write explicitly.
Start a run
POST /api/v1/projects/{project_id}/site-health/runs
Authorization: Bearer hub_live_YOUR_API_KEY
Idempotency-Key: 3f6c1d2e-9a7b-4c8d-b1e2-0f9a8b7c6d5e
Content-Type: application/json
{ "max_pages": 25, "check_dead_links": true }All body fields are optional:
| Field | Type | Default | Notes |
|---|---|---|---|
url | string | the site's root | Must be on the project's host (www. is ignored). |
max_pages | integer 1–50 | plan maximum | Capped at 25 pages on the free plan and 50 on paid plans. |
check_dead_links | boolean | true | Reports internal pages that returned 4xx/5xx. |
Responses:
202 Accepted: the run was created.Locationpoints to/api/v1/runs/{id}.200 OK: the sameIdempotency-Keywas sent again (meta.idempotent_replay: true), or an identical run is already in progress and you joined it (meta.joined_in_flight: true).Locationis set as for202.
Idempotency-Key is optional but recommended (8–200 printable characters). Keys are kept per account with no expiry. Reusing one with a different request returns 409 idempotency_conflict.
{
"data": {
"id": "6b0e2c1f-4d3a-4b8e-9f1c-2a7d5e6f8a90",
"type": "site_health",
"project_id": "8f3b2c1a-5d4e-4f3a-9b8c-7d6e5f4a3b2c",
"status": "queued",
"phase": "queued",
"created_at": "2026-10-12T12:00:00.000000+00:00",
"started_at": null,
"finished_at": null,
"counters": null,
"error": null,
"request": { "url": "https://example.com/", "max_pages": 25, "check_dead_links": true },
"result_available": false
}
}Poll a run
GET /api/v1/runs/{run_id}
GET /api/v1/runs/{run_id}?include=resultstatus is queued, running, completed, partial, failed or cancelled. The last four are final and never change. A crawl usually finishes within about 90 seconds; polling every few seconds is fine.
partial: the crawl stopped at its page or time limit. The results are real but cover only part of the site.failedwitherror.code=worker_lost: the run stopped before finishing and recorded no results. Start a new run.counters: staysnulluntil something has been measured. It is never a placeholder zero.
With ?include=result, a finished run includes result:
summary:completeandtermination(complete,max-pagesormax-duration).- Page
counts. inventory: eligible versus excluded pages, with reasons and an example URL each.dead_links, covered in the next section.
pages: one entry per crawled page, withhttp_status,fetch_state,indexability_state,health_state(eligible,hidden,redirect,failedorunchecked) andhealth_reason.findings: the site-quality evidence results.score:{ "has_data", "aggregate_score", "status" }. When nothing could be measured,has_dataisfalseand the other fields arenull.
Dead links
dead_links.state is one of:
disabled: the run hadcheck_dead_links: false.unavailable: no crawl result is available.complete: every page in the crawl was checked.partial: the crawl stopped early, so more pages may exist.
Only confirmed 4xx/5xx responses count as found and appear in items. Pages that couldn't be fetched (timeouts, resets) are counted as unverified. They are never reported as broken, and they are left out of checked. Only internal pages the crawl reached are checked.
List runs
GET /api/v1/projects/{project_id}/site-health/runs?limit=20&cursor=…Runs are listed newest first. limit defaults to 20 (maximum 100). When another page exists, meta.has_next is true and links.next holds the URL of the next page. A malformed cursor or limit returns 422.
Latest result
GET /api/v1/projects/{project_id}/site-health/latestReturns { "run", "result", "has_data": true } for the newest finished run with results. If there isn't one, it returns { "run": null, "result": null, "has_data": false } with 200.
Limits and errors
| Limit | Free | Paid |
|---|---|---|
| Runs started per hour | 5 | 20 |
| Runs in progress at once | 1 | 2 |
| Pages per run | 25 | 50 |
| Status | error.code | Meaning |
|---|---|---|
| 401 | missing_key, invalid_key, revoked_key, auth_unavailable | No usable key, or the key isn't linked to an account |
| 403 | insufficient_scope | The key lacks the scope (a * key used to start a run) |
| 404 | not_found | The project or run doesn't exist or isn't yours |
| 409 | idempotency_conflict | The Idempotency-Key was used with a different request |
| 422 | validation_error / invalid_cursor | Invalid body, URL, limit or cursor |
| 429 | quota_exceeded / rate_limit_exceeded | Hourly runs, runs in progress, or the per-minute key limit. See Retry-After. |
| 503 | service_unavailable | A temporary problem; nothing was started. Retry later. |
Error bodies use the standard envelope and include request_id. Every response also carries an X-Request-Id header.
