Skip to main content
Find the links on your site that lead to a 404 or 410 page, and the exact pages that hold them, so you know what to fix. Crawl reads the site and keeps each page’s HTML, your script collects the links on those pages, and Fetch checks each link and returns the target’s status code. Uses: Crawl with output_format, Fetch with allowed_status_codes, Batch

How it works

  1. Crawl reads the site. A crawl with "output_format": "html" keeps each matching page’s HTML. The download endpoint returns every page in one NDJSON file.
  2. Your script collects the links. It parses each page’s <a href> links, keeps the ones on the site’s own domain, and records which pages link to each one.
  3. Fetch checks each link. With allowed_status_codes=404,410, Fetch answers 200 and puts the target’s status in the Zr-Status-Code header. A link that loads normally has no Zr-Status-Code header and answers 200.
  4. You get the report. Every link with a status of 400 or more is listed with the pages to fix.

Ask your AI agent

With the Zenrows MCP server connected to Claude, Cursor or any MCP client, paste this prompt. Your agent picks the right Zenrows tools and settings on its own.
Save this as broken_links.py and run it. It needs only requests.
Python (Requests)
stream=True reads the response headers and leaves the page body undownloaded, which keeps each check fast. Each check is one Fetch request, and mode=auto loads links on protected sites too. Fetch bills a 404 or 410 answer like any other page. To check a single URL, send it with allowed_status_codes and read the Zr-Status-Code header:
cURL
Output
Without allowed_status_codes, Fetch answers a missing page with its own 404 and the error code RESP002, which also tells you the link is broken. For a large site, send the links to one Batch job instead of a thread pool. Leave out allowed_status_codes: each link that answers 404 then becomes a failed row whose error holds "status": 404 and "code": "RESP002".
Python (Requests)
Output
For more than one page of results, follow next_cursor as shown in Scrape a list of URLs. The run’s stats.failure_reasons counts failures by kind, for example {"bad_target": 1}.

Example output

Every link on scrapingcourse.com loads, so this run adds one made-up URL, /product/does-not-exist/, to the links found on the Abominable Hoodie page before the checks. Everything else is the real run:
The crawl read three product pages, which hold 41 links on the site’s own domain: category pages, other products, the cart and checkout pages, and product images. All 41 returned 200.

Customize it

Troubleshooting

  • A link reports 422 or 429 instead of a page status: that code is Fetch’s answer, not the target’s. Check the link again. For 429, set max_workers to the value of the Concurrency-Limit response header.
  • The script finds few links: the crawl kept few pages. Raise max_items and max_pages, and add the paths of listing pages to include_patterns.
  • Links show up twice with and without a trailing slash: the site links to both spellings. Normalize them before checking, for example with link.rstrip("/").
  • 429 with too_many_crawls: other Crawl and Batch jobs on your account are still running. Wait the number of seconds in the Retry-After header, then start the crawl again.

Map every URL on a website

List a site’s URLs without their HTML.

Get alerted when a website adds new pages

Compare a section’s URLs run after run.

Scrape a list of URLs

Send thousands of URLs as one Batch job.

Crawl a website from a start URL

Get every matching page with its HTML.