Compare a crawl with the previous one
GET/crawls/{crawl_id}/diff
- Scope:
- read
- Rate class:
- heavy
- Operation id:
- get_crawl_diff
What changed between a completed crawl and the previous completed crawl of the same site in the same mode (or any other completed crawl of the site in the same mode: against). Counts of new, disappeared and changed pages and of new, fixed and persistent broken links. Only what this crawl saw counts as a change: a link is fixed when it was checked again and works, a page disappeared when it now answers an error or redirect or the crawl covered the whole site; not_rechecked and not_reached count the rest.
The summary of the previous-crawl comparison is counted once when the crawl is processed and served from there (source: stored). Pass include=lists for the first limit pages and links of each kind (it is counted for the request). Crawls of more than 100,000 pages are compared by their broken links alone (pages_compared: false; a comparison counted for a request does so above 25,000 pages). A crawl with no earlier crawl answers has_previous: false, not an error. A CI gate: curl .../crawls/$ID/diff | jq -e '.summary.new_broken == 0'.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| crawl_id | path | stringrequired | The crawl's id (a UUID).Range: at most 64 characters. |
| against | query | stringoptional | previous (default), or the id of another completed crawl of the same site and mode.Range: at most 64 characters. Default: previous. |
| include | query | anyoptional | lists: also the first limit items of each list. |
| limit | query | integeroptional | Items per list with include=lists.Range: 1 to 200. Default: 50. |
Answer 200
OK. The body is a CrawlDiff.
| Field | Type | Description |
|---|---|---|
| crawl_id | string | The crawl that was asked about (the current one). |
| has_previous | boolean | False when there is no earlier completed crawl of the site in the same mode: everything else is then null. |
| current | object | |
| current.crawl_id | string | The crawl's id. |
| current.pages | integer | Pages it checked. |
| current.completed_at | string or null | When it completed (UTC). |
| compared | string or null | What was compared: pages (pages and links) or broken_links. |
| computed_at | string or null | When the summary was counted (UTC). |
| coverage_gap | string or null | Why pages the current crawl did not reach are not listed as disappeared: stopped (it ended early) or scope (it covered less than the previous crawl). |
| lists | object or null | Only with include=lists. |
| lists.changed | array of object | Default: []. |
| lists.changed[].url | string | |
| lists.changed[].changes | object | By field: status_code, title, meta_description, is_indexable, canonical_url. |
| lists.disappeared | array of object | Default: []. |
| lists.disappeared[].url | string | |
| lists.disappeared[].previous_status_code | integer or null | |
| lists.disappeared[].redirect_to | string or null | Where it redirects now, when it does; absent when redirects were not counted. |
| lists.disappeared[].status_code | integer or null | What the page answers now; null when the crawl did not find it. |
| lists.disappeared[].title | string or null | Its title in the previous crawl. |
| lists.fixed_broken | array of string | Default: []. |
| lists.new_broken | array of string | Default: []. |
| lists.new_pages | array of object | Default: []. |
| lists.new_pages[].url | string | |
| lists.new_pages[].status_code | integer or null | |
| lists.new_pages[].title | string or null | |
| pages_compared | boolean or null | False when only broken links were compared: a fast crawl, or crawls too big to compare page by page (pages_note says which). |
| pages_note | string or null | Why pages were not compared. |
| previous | object or null | The crawl it was compared with. |
| previous.crawl_id | string | The crawl's id. |
| previous.pages | integer | Pages it checked. |
| previous.completed_at | string or null | When it completed (UTC). |
| source | string or null | stored (counted when the crawl was processed) or computed (counted for this request). |
| summary | object or null | Counts of what changed. Only what the current crawl saw counts as a change: a link broken last time is fixed
only when this crawl checked it again and it works; one it did not check again is not_rechecked. |
| summary.new_broken | integer | Links broken now that were not broken in the previous crawl. |
| summary.fixed_broken | integer | Links broken before that were checked again and work. |
| summary.not_rechecked | integer | Links broken before that this crawl did not check again: not counted as fixed. |
| summary.changed | integer or null | Pages whose status, title, meta description, indexability or canonical changed. Null when pages were not compared. |
| summary.disappeared | integer or null | Pages of the previous crawl that now answer an error or a redirect, or are gone from a crawl that covered the whole site. Null when pages were not compared. |
| summary.new_pages | integer or null | Pages not in the previous crawl. Null when pages were not compared. |
| summary.not_reached | integer or null | Pages of the previous crawl this one did not reach: not counted as gone. Null when pages were not compared. |
| summary.persistent_broken | integer or null | Links broken in both crawls. |
| summary.redirected | integer or null | Of the disappeared pages, the ones that now redirect (often on purpose). Absent when redirects were not counted; null when pages were not compared. |
| truncated | object or null | With lists: which lists were cut at limit. |
{
"crawl_id": "0b6c0f5e-6f3c-4a0e-9a53-2f6f3a3c7c11",
"has_previous": true,
"current": {
"crawl_id": "0b6c0f5e-6f3c-4a0e-9a53-2f6f3a3c7c11",
"pages": 1840,
"completed_at": "2026-10-31T09:00:00Z"
},
"compared": "pages",
"computed_at": "2026-10-31T09:14:02Z",
"pages_compared": true,
"previous": {
"crawl_id": "8d1d7a2e-2a43-45a8-b7d1-6d0c3a8f9e02",
"pages": 1832,
"completed_at": "2026-10-30T09:00:00Z"
},
"source": "stored",
"summary": {
"new_broken": 2,
"fixed_broken": 5,
"not_rechecked": 0,
"changed": 31,
"disappeared": 0,
"new_pages": 8,
"not_reached": 0,
"persistent_broken": 17
}
}Problems this call documents
- 404 Not found:
not_found - 409 Crawl not ready:
crawl_not_ready - 422 Validation failed:
validation_error - 429 Too many heavy calls at once:
too_many_heavy_requests - 503 Service busy:
capacity_busy - 503 Query too slow:
query_too_slow
List a crawl's issues
GET/crawls/{crawl_id}/issues
- Scope:
- read
- Rate class:
- heavy
- Operation id:
- list_crawl_issues
The issues of a completed crawl, most important first: broken links and resources (with the page and the HTML element they sit in, most linked first), page SEO issues, then AI readiness issues, each with a short fix. Filter by category, severity and code. Pages the site refused are counted as not checked, never as broken. A fast crawl has broken links only: asking for seo or ai_readiness answers an empty list.
Lists are cursor pages (next_cursor back as cursor, up to 100 per page). A page can hold fewer issues than limit while has_more is true: a filter that matches few issues is searched in bounded steps, so keep following the cursor until has_more is false. The first page also carries the crawl's totals, counted once when the crawl was processed (null with totals_status: pending until then).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| crawl_id | path | stringrequired | The crawl's id (a UUID).Range: at most 64 characters. |
| category | query | stringoptional | Only this category. Default: all of them.One of: broken, seo, ai_readiness. |
| severity | query | stringoptional | Only issues of this severity.One of: error, warning, info. |
| code | query | stringoptional | Only this issue code, for example missing_title.Range: at most 100 characters. |
| limit | query | integeroptional | Items per page, 1 to 100.Range: 1 to 100. Default: 50. |
| cursor | query | stringoptional | The next_cursor of the previous page. Opaque: never build one. |
Answer 200
OK. The body is a IssuePage.
| Field | Type | Description |
|---|---|---|
| data | array of object | The items of this page, in the endpoint's fixed order. |
| data[].id | string | Stable within the crawl: broken:<row>, seo:<page row>:<code> or ai:<page row>:<code>. |
| data[].category | string | broken, seo or ai_readiness. |
| data[].code | string | The catalog code, for example broken_link, missing_title, js_shell. Unknown codes may appear. |
| data[].label | string | The catalog's name for the code. |
| data[].severity | string | error, warning or info. |
| data[].fix | string | What to do, in a sentence or two. |
| data[].detail | string or null | A short fact about this page's issue, for example max-snippet:50. |
| data[].element | object or null | The HTML element the issue sits in. |
| data[].element.selector | string or null | Page issues: a CSS selector for the element to look at. |
| data[].element.tag | string or null | The HTML tag of the element. |
| data[].element.text | string or null | Broken links: the anchor text (or alt text) of the link. |
| data[].element.xpath | string or null | Broken links: where the link sits in the page's HTML. |
| data[].occurrences | integer or null | Broken links: how many pages link to the target (at least 1). |
| data[].page_url | string or null | The page the issue is on (for a broken link: the page that links to it). |
| data[].resource_type | string or null | Broken links: link, image, css, js, video... |
| data[].status_code | integer or null | Broken links: the HTTP status the target answered, null when it did not answer. |
| data[].target_url | string or null | Broken links: the URL that does not work. |
| has_more | boolean | True when another page follows. |
| next_cursor | string or null | Pass as cursor to get the next page; null on the last page. |
| totals | object or null | First page only (no cursor): the crawl's issue totals; null when they are not stored (see totals_status) and on later pages. |
| totals.computed_at | string | When they were counted (UTC). |
| totals.broken | integer | Broken links and resources. URLs the site refused are not counted as broken. |
| totals.not_checked | integer | URLs the site refused or that did not answer: neither working nor broken. |
| totals.ai_readiness | object or null | Null for a fast crawl. |
| totals.ai_readiness.total | integer | AI readiness issues found, one per page and code. |
| totals.ai_readiness.by_code | object | Pages per issue code (only codes that were found). |
| totals.seo | object or null | Null for a fast crawl (no page data), and while totals_status is incomplete. |
| totals.seo.pages | integer | Pages the SEO checks read. |
| totals.seo.total | integer | Page issues found, one per page and code. |
| totals.seo.by_code | object | Pages per issue code (only codes that were found). |
| totals_status | string or null | First page only. ready; incomplete (the SEO count ran out of time: seo is null); pending (the crawl's data is still being processed, or it is older than the totals: totals is null). |
{
"data": [
{
"id": "broken:48213",
"category": "broken",
"code": "broken_link",
"label": "Broken link",
"severity": "error",
"fix": "On https://shop.example/blog/care-guide the <a> at //*[@id=\"content\"]/p[3]/a points to https://shop.example/old-guide, which returns 404. Point it to the page that replaced it, or remove the link.",
"element": {
"tag": "a",
"text": "Read the guide",
"xpath": "//*[@id=\"content\"]/p[3]/a"
},
"occurrences": 12,
"page_url": "https://shop.example/blog/care-guide",
"resource_type": "link",
"status_code": 404,
"target_url": "https://shop.example/old-guide"
}
],
"has_more": true,
"next_cursor": "eyJ2IjoxfQ.opaque",
"totals": {
"computed_at": "2026-10-31T09:14:02Z",
"broken": 29,
"not_checked": 4,
"ai_readiness": {
"total": 65,
"by_code": {
"js_shell": 12,
"noindex": 8
}
},
"seo": {
"pages": 64,
"total": 265,
"by_code": {
"missing_og": 43,
"missing_title": 9
}
}
},
"totals_status": "ready"
}Problems this call documents
- 400 Invalid cursor:
invalid_cursor - 404 Not found:
not_found - 409 Crawl not ready:
crawl_not_ready - 422 Validation failed:
validation_error - 429 Too many heavy calls at once:
too_many_heavy_requests - 503 Service busy:
capacity_busy - 503 Query too slow:
query_too_slow