# Broken Link Checker: Find 404s and Redirects on Any Website (`boubap/broken-links-checker`) Actor

Crawl a website and check every link, image, script and stylesheet: 404s, server errors, dead domains and redirect chains. One row per problem link with the page it was found on. Links that cannot be checked are free.

- **URL**: https://apify.com/boubap/broken-links-checker.md
- **Developed by:** [Baptiste Bouton](https://apify.com/boubap) (community)
- **Categories:** SEO tools, Developer tools, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 link checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Broken Link Checker: Find 404s and Redirects on Any Website

Give it the address of a website. It reads the pages of that site, collects every link, image, script and
stylesheet they contain, and checks each address once. You get one row per problem link with the page it was found
on: 404 and other error pages, dead domains, redirect chains, and links the target site refused to check.
It works as a dead link checker and a redirect checker, needs no API key and charges per link checked.
Links that could not be checked (timeout, refusal) are free.

### What it checks

- **Links on your site**: `<a href>` hyperlinks, `<img src>` images, `<script src>` scripts and `<link href>`
  stylesheets, icons, canonical and alternate URLs.
- **Links to other sites**: checked with one request per address, never crawled. Turn `checkExternalLinks` off to
  check your own site only.
- **Redirects**: followed one by one (up to 10) and counted, so you can replace old addresses with the final one.
- `mailto:`, `tel:` and `javascript:` links are ignored and never written. Link texts are not collected.

### Use cases

- Find the 404s on a site before a launch, a migration or an SEO audit.
- Clean up old `http://` and moved addresses that cost a redirect on every click.
- Check a documentation or blog site every week with the Apify scheduler and get the new broken links only.
- Check just a list of pages (a landing page set, a newsletter archive): set `maxDepth` to 0.

### Input

| Field | Description | Default |
|---|---|---|
| `startUrls` | Pages where the crawl starts. Pages of the same domain (with or without www) are followed | |
| `maxPages` | Pages of the site opened and read for links (see Run time above 700 pages) | `100` (up to 5000) |
| `maxDepth` | Clicks away from a start URL. `0` checks the links of the start URLs only | `10` |
| `checkExternalLinks` | Also check links to other domains | `true` |
| `includeOkLinks` | Also list working links | `false` |
| `maxConcurrency` | Requests sent at once to the crawled site | `4` (up to 10) |

Example:

```json
{ "startUrls": [{ "url": "https://crawlee.dev" }], "maxPages": 200 }
```

### Output

One row per problem link and per page where it appears. Real rows from a run on `https://crawlee.dev` on
26 October 2026 (10 pages, 1,036 links checked, 4 broken, 205 redirected):

```json
[
  {
    "linkUrl": "https://github.com//github.com/apify/crawlee/pull/1890/issues/issuecomment-1533271694",
    "sourcePageUrl": "https://crawlee.dev/js/api/core/changelog",
    "element": "a",
    "result": "broken",
    "statusCode": 404,
    "redirectTo": null,
    "redirectCount": 0,
    "isExternal": true,
    "error": "HTTP 404",
    "checkedAt": "2026-10-26T08:06:43.140Z"
  },
  {
    "linkUrl": "https://www.crawlee.dev/blog/launching-crawlee-python",
    "sourcePageUrl": "https://crawlee.dev/blog",
    "element": "a",
    "result": "redirect",
    "statusCode": 200,
    "redirectTo": "https://crawlee.dev/blog/launching-crawlee-python",
    "redirectCount": 1,
    "isExternal": false,
    "error": null,
    "checkedAt": "2026-10-26T08:06:03.517Z"
  },
  {
    "linkUrl": "https://stackoverflow.com/questions/tagged/crawlee",
    "sourcePageUrl": "https://crawlee.dev/",
    "element": "a",
    "result": "blocked",
    "statusCode": 403,
    "redirectTo": null,
    "redirectCount": 0,
    "isExternal": true,
    "error": "stackoverflow.com refused the request (HTTP 403)",
    "checkedAt": "2026-10-26T08:05:49.287Z"
  }
]
```

`result` is one of:

| Result | Meaning | Charged |
|---|---|---|
| `ok` | 2xx answer (listed only with `includeOkLinks`) | yes |
| `redirect` | works after one or more redirects: `redirectTo` is the final address | yes |
| `broken` | 4xx or 5xx answer, unknown domain, redirect loop | yes |
| `blocked` | the site refused the check (401, 403, 429, 503, 999 or a bot challenge): the link may work in a browser | no |
| `error` | no usable answer: timeout, connection or certificate failure | no |

A link found on 20 pages gives 20 rows but is checked and charged once per run. Addresses that may name a person are
shortened in every field: links to X, Twitter, Facebook, Instagram and GitHub keep only the domain, the account part of
LinkedIn, Medium, YouTube and TikTok profile links becomes `(hidden)`, and so does any part that looks like an e-mail
address. Use `sourcePageUrl` to find a shortened link on your page.

### Check a list of URLs

Already have the addresses, for example the old URLs of a site you moved, a backlink list or the links of a
newsletter? Put them in `urls` (up to 10,000) and nothing is crawled: each address is checked once, with the same
limits as the crawl (4 requests at once, 2 per host, a host is left alone after 5 refusals in a row). It works as a
URL status checker and an HTTP status checker. `startUrls` is ignored when `urls` is filled.

| Field | Description | Default |
|---|---|---|
| `urls` | Addresses to check, one row each. Duplicates are checked once | |
| `maxRedirects` | Redirects followed for each address before it counts as a redirect loop | `10` (1 to 20) |

Each address is asked with `HEAD`, and with `GET` only when the server does not support `HEAD` (400, 405 or 501).
Addresses that point to a private or local network are not checked. Each `ok`, `redirect` or `broken` address
counts as one checked link; `blocked` and `error` addresses are free.

```json
{ "urls": ["https://crawlee.dev/docs", "https://crawlee.dev/nope-404", "http://apify.com"] }
```

One row per address. Real rows from a run on 4 November 2026:

```json
[
  {
    "inputIndex": 0,
    "url": "https://crawlee.dev/docs",
    "statusCode": 301,
    "finalUrl": "https://apify.github.io/crawlee/js/docs/",
    "finalStatusCode": 200,
    "redirectChain": [
      { "url": "https://crawlee.dev/docs", "statusCode": 301 },
      { "url": "https://crawlee.dev/js/docs", "statusCode": 301 }
    ],
    "redirectCount": 2,
    "result": "redirect",
    "contentType": "text/html; charset=utf-8",
    "responseTimeMs": 221,
    "error": null,
    "checkedAt": "2026-11-04T07:54:54.916Z"
  },
  {
    "inputIndex": 2,
    "url": "https://crawlee.dev/nope-404",
    "statusCode": 404,
    "finalUrl": "https://crawlee.dev/nope-404",
    "finalStatusCode": 404,
    "redirectChain": [],
    "redirectCount": 0,
    "result": "broken",
    "contentType": "text/html; charset=utf-8",
    "responseTimeMs": 78,
    "error": "HTTP 404",
    "checkedAt": "2026-11-04T07:54:54.800Z"
  }
]
```

`statusCode` is the first answer and `finalStatusCode` the last one. Profile links and e-mail addresses are masked
as in the crawl: any path on these social networks is hidden in full, so use `inputIndex` (the position of the address
in your list, from 0, duplicates removed) to find the matching entry.

### Pricing

One charge per unique link or URL checked in a run (current price on the Pricing tab), whatever the result shown, with volume discounts for
higher Apify plans. `blocked` and `error` links are free, and so are links skipped because of robots.txt. For scale,
10 pages of crawlee.dev hold 1,036 unique links. Set a maximum cost per run in the Apify Console and the Actor
stops cleanly when it is reached, without charging links it did not check.

### Run time

Most of the time goes into links to other sites, which get 2 requests at once each. On crawlee.dev, whose pages link
to hundreds of GitHub addresses, 100 pages took 15 minutes (about 9 seconds per page). The default run timeout of
2 hours covers roughly 700 pages of that kind; raise the timeout in the run options for more. One minute before the
timeout the Actor stops and ends normally, with every row found so far.

### Polite crawling

- The crawled site gets at most 4 requests at once by default (`maxConcurrency`, up to 10), and the `Crawl-delay` of
  its robots.txt is honored up to 10 seconds between requests. Pages disallowed by robots.txt are not opened, and a
  robots.txt answering with a server error (5xx) stops the crawl of that site.
- Other sites get at most 2 requests at once, one per address.
- An answer 429 or 503 pauses the site (the `Retry-After` header is honored up to 30 seconds) and the link is tried
  again, 3 times at most; then it is listed as `blocked`. After 5 refusals in a row on a site (401, 403, 429, 503, 999
  or a bot challenge) the Actor stops sending requests to that site and
  says so in the log; its remaining links are listed as `blocked` and not charged.
- Requests carry a User-Agent that names the Actor. Bot protections and captchas are never bypassed.
- HEAD requests are used where possible; a server that refuses HEAD gets a GET.

### Limits

- Pages are read as served, without running JavaScript: links added by scripts after loading are not seen.
- `srcset` image candidates, CSS `url()` and links inside iframes are not checked.
- Pages larger than 5 MB are checked but not read for links.
- Files such as PDF, ZIP or images are checked with HEAD and do not count in `maxPages`.

### Recipes

- **404 checker before a migration**: run it on the old site, export the `broken` rows and map each one to a new page.
- **Redirect checker**: keep `result` equal to `redirect` and replace `linkUrl` by `redirectTo` in your pages. Shortened social links
  are not complete: open `sourcePageUrl` to see them.
- **Weekly dead link check**: schedule the run and send new `broken` rows to Slack or e-mail with Make, Zapier or n8n.
- **AI assistants**: with the Apify MCP server an assistant can run a link check and list the pages to fix.

### FAQ

**Do I need an API key or a login?** No. Only public pages are read.

**Does it collect personal data?** No. Link texts are not collected, and profile addresses and e-mail addresses in
links are shortened to `(hidden)`.

**Will it slow my site down?** It sends 4 requests at once by default, follows your robots.txt and its Crawl-delay,
and slows down or stops when your server answers 429 or 503. Lower `maxConcurrency` for a fragile server.

**Why are some links `blocked`?** The target site refused an automated check. They are free: test them in a browser.

### Related Actors

- [PageSpeed Insights Bulk Audit](https://apify.com/boubap/pagespeed-bulk-audit): Lighthouse scores and Core Web
  Vitals for every page of a sitemap.
- [Tech Stack Detector](https://apify.com/boubap/tech-stack-detector): the technologies behind a list of websites.

# Actor input Schema

## `startUrls` (type: `array`):

Pages where the crawl starts, for example your home page. Pages of the same site (same domain, with or without www) are followed from there. To check only the pages you list, set Max depth to 0.

## `maxPages` (type: `integer`):

Maximum number of pages of the site that are opened and read for links. Links found on them are all checked, including images, scripts, stylesheets and links to other sites. Default 100, up to 5000. Pages with many links to other sites take about 9 seconds each: above roughly 700 pages, raise the run timeout (2 hours by default).

## `maxDepth` (type: `integer`):

How many clicks away from a start URL the crawl goes. 0 checks the links of the start URLs only, 1 also reads the pages they link to, and so on. Default 10.

## `checkExternalLinks` (type: `boolean`):

Also check links that point to other domains (one request per address, never crawled). Turn off to check your own site only.

## `includeOkLinks` (type: `boolean`):

Off: the dataset lists broken and redirected links, plus links that could not be checked (blocked or error). On: it also lists working links. Links skipped because of robots.txt are never listed. The price is the same either way.

## `maxConcurrency` (type: `integer`):

How many requests are sent at once to the crawled site. Default 4, up to 10. A Crawl-delay in the site's robots.txt is honored up to 10 seconds. Other sites always get at most 2 requests at once.

## `urls` (type: `array`):

A list of addresses to check one by one, up to 10000, for example after a site migration or for a list of backlinks. When filled, nothing is crawled and Start URLs are ignored. Each address gets one row with its status, the redirect chain and the final address. Duplicates are checked once.

## `maxRedirects` (type: `integer`):

How many redirects are followed for each address of the URL list before it is reported as a redirect loop. Default 10, from 1 to 20.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://crawlee.dev"
    }
  ],
  "maxPages": 5,
  "maxDepth": 10,
  "checkExternalLinks": true,
  "includeOkLinks": false,
  "maxConcurrency": 4,
  "maxRedirects": 10
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset items of the run: one item per result, with the fields described in the dataset schema.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://crawlee.dev"
        }
    ],
    "maxPages": 5
};

// Run the Actor and wait for it to finish
const run = await client.actor("boubap/broken-links-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "startUrls": [{ "url": "https://crawlee.dev" }],
    "maxPages": 5,
}

# Run the Actor and wait for it to finish
run = client.actor("boubap/broken-links-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://crawlee.dev"
    }
  ],
  "maxPages": 5
}' |
apify call boubap/broken-links-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,boubap/broken-links-checker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/HhN7I3pcyj6ICc1aV/builds/Alh6Fd0HqGY3Z7oc5/openapi.json
