# Website Broken Link Checker (`pradio/broken-link`) Actor

Finds the broken links on the pages you name. Paste URLs or bare domains and press Start: every link found is followed to its final HTTP status, one row per link, with the verdict, anchor text, source page, redirect count and timing. No login, no browser.

- **URL**: https://apify.com/pradio/broken-link.md
- **Developed by:** [Pradio Actors](https://apify.com/pradio) (community)
- **Categories:** Lead generation, Automation, Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.43 / 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

## Website Broken Link Checker

### What does Website Broken Link Checker do?

Website Broken Link Checker reads the pages you name and returns one row per link found on them: the checked URL, its final HTTP status, an `ok`, `broken` or `error` verdict, the anchor text and the page it appeared on. Paste URLs or bare domains, press Start, and each page is fetched once, its links collected and every one checked end to end. Sixteen fields land on each data row. It checks the links on the pages you list; it is not a site crawler and it does not fix anything.

Each checked link costs $0.0008, billed only after its row is in your dataset. In a measurement on 40 sites it had never seen, 35 answered with checked links, a hit rate of 87.5% (3 returned no checkable links, 2 errored). A run that finds no links returns one uncharged row that says so.

### Who uses Website Broken Link Checker

| Buyer | What they run it for |
|---|---|
| Website owners | Finding dead links on their own pages before visitors or crawlers do. |
| SEO practitioners | Finding dead links that leak ranking; each row names the page and the anchor to fix. |
| Teams after a migration or redesign | Checking that old URLs redirect somewhere instead of dying. |
| Content maintainers | Auditing outbound links that rot quietly over time. |

### Features

- **Checks every link on the pages you name.** Paste full URLs or bare domains; each page is fetched once and every anchor on it is checked.
- **HEAD first, GET when refused.** A cheap HEAD request leads; a server that answers 405 or 501 is asked again with GET.
- **Redirects followed and counted.** Up to 10 redirects are followed per link, so `final_url` and `redirect_count` are read, not assumed.
- **Three honest verdicts.** `ok` below HTTP 400, `broken` at 400 or above, `error` when a link never answers within 15 seconds.
- **Checked once across pages.** A footer link repeated across start pages is checked once; `all_sources` lists every place it appeared.
- **Unusable entries answered, not dropped.** An entry in `queries` that is not a fetchable URL gets its own free row with `status` `bad_url` and the reason.
- **Plain HTTP, no browser, no login.** Up to 8 links are checked at once, with a 15 second limit per request.
- **Caps you control.** `maxResultsPerQuery` limits the links checked per page; `maxItems` limits the whole run, 100 by default.

### What you can count on

- You pay only for rows the run judged; a row it could not judge is pushed as an uncharged `ITEM_STATUS` row with the reason on it.
- Every row is charged only after it is written to your dataset; a row you cannot see is never billed.
- A run that finds nothing returns one `NO_RESULTS` row that says so, never an empty dataset, and it is not charged.
- A spending limit stops the run cleanly: one `STOPPED_EARLY` row reports how many rows were returned and how many were not.
- Every run writes a `RUN_SUMMARY` entry with `rowsFetched`, `rowsPushed`, `rowsCharged` and `duplicatesDropped`, so a short run and a broken one are told apart.
- If a start page cannot be read or changes what it answers, the run fails with the error in the log; it never returns rows full of nulls and calls it success.
- No value is invented: a field the checked page does not show is null, and the output table says which.

### Why this one

- This Actor bills $0.0008 per checked link; the most-used alternative on this platform bills $0.001, measured on 2026-09-16.
- Run on the same pages that day, the alternative left its redirect-chain field empty on 12 of its 19 rows and its error field on 16 of 19. Here the redirect count is on every row, and a link that never answers carries the reason in `status_text`.
- The hit rate above is measured on sites this Actor had never seen, not on a best case.
- Every row names the page the link was found on, so a dead link is a fix, not a riddle.

### What data does Website Broken Link Checker return?

One real row from a run over the example pages in the default input:

```json
{
  "url": "https://crawler-test.com/links/not_found/foo1",
  "final_url": "https://crawler-test.com/links/not_found/foo1",
  "status": 404,
  "status_text": "Not Found",
  "classification": "broken",
  "is_broken": true,
  "source_domain": "crawler-test.com",
  "source_url": "https://crawler-test.com/links/broken_links_internal",
  "anchor_text": "Broken Internal Link 1",
  "element": "a",
  "all_sources": [
    {
      "source_url": "https://crawler-test.com/links/broken_links_internal",
      "anchor_text": "Broken Internal Link 1",
      "element": "a"
    }
  ],
  "method": "HEAD",
  "redirect_count": 0,
  "duration_ms": 291,
  "checked_at": "2026-09-18T06:29:01.047Z",
  "row_type": "ROW"
}
```

Each row is one link checked end to end. Every field below sits on every data row; a value a check cannot produce is null, never absent.

| Field | What it is |
|---|---|
| `url` | The link as it was found, made absolute. This is the row's key: one row per unique link URL. |
| `final_url` | Where the link ended after its redirects; the same as `url` when it never redirected, null when it never answered. |
| `status` | Two shapes by design, documented in the table below: an integer HTTP status on a checked-link row, or the miss word `bad_url` on an `ITEM_STATUS` row. |
| `status_text` | The server's status line, like `Not Found`, or the failure reason when nothing answered. |
| `classification` | The verdict on the row: `ok`, `broken` or `error`. |
| `is_broken` | true on a confirmed 4xx or 5xx, false on a healthy answer, null when unproven. |
| `source_url` | The start page this link was first found on. |
| `anchor_text` | The clickable text of the link on that page. |
| `element` | The HTML element the link came from; `a` here, since only anchor tags are checked. |
| `all_sources` | Every page and anchor text the same link appeared on, when more than one page carries it. |
| `method` | `HEAD` or `GET`: how the check was made. |
| `redirect_count` | How many redirects were followed before the final answer; a URL answered directly shows none. |
| `duration_ms` | How long the check took in milliseconds, redirects included. |
| `checked_at` | The ISO timestamp of the check. |
| `source_domain` | The host of the start page the link was found on (one of the pages you named); the link's own host is in `url`. |
| `row_type` | `ROW` on every checked link; the status kinds below mark the run's own messages. |

The Overview tab shows the per-link fields; the All fields tab adds `duration_ms`, `checked_at` and `source_domain`. Across the captured runs (12 rows locally, 1,000 on the platform, 2026-09-18), no field was empty on every row.

`status` carries two shapes by design:

| `status` value | On which rows | What it means |
|---|---|---|
| An integer HTTP code, like `200` or `404` | `ROW` | The status the link's final address answered with; null when no response came back. |
| `bad_url` | `ITEM_STATUS` | The `queries` entry was not a usable URL. The entry is named in `url`, the reason sits in `reason`, and the row is pushed free: a malformed entry is answered, never dropped. |

`row_type` says what a row is. `ROW` is a checked link. `ITEM_STATUS` is a per-item miss the schema declares free: pushed so you see it, never billed. `NO_RESULTS` means no page produced a checkable link. `STOPPED_EARLY` means your spending limit ended the run. Status rows also carry the run's bookkeeping:

| Field | What it is |
|---|---|
| `reason` | Why the run ended or the item missed; on status rows only. |
| `rowsFetched` | Links read before the run ended; on status rows only. |
| `rowsReturned` | Rows returned before the spending limit stopped the run; on `STOPPED_EARLY`. |
| `rowsRemaining` | Rows not returned when the spending limit stopped the run; on `STOPPED_EARLY`. |

### Pricing

You pay $0.0008 per checked-link row, the `result-returned` event, charged only after the row is written. Apify also bills its own `apify-actor-start` event once per run, $0.00005 at this Actor's memory size. Status rows, `bad_url` miss rows and dropped duplicate links are free. Every checked link is billed, including `broken` and `error` verdicts: the verdict is the answer you paid for.

| Rows returned | Link charges | Start event | Total |
|---|---|---|---|
| 100 | $0.08 | $0.00005 | about $0.08 |
| 1,000 | $0.80 | $0.00005 | about $0.80 |
| 10,000 | $8.00 | $0.00005 | about $8.00 |

A start page carries more than one link. The 35 pages that answered in the measurement produced 3,504 link rows in total, about 100 per page, so budget by sweep:

- 100 start pages: about 88 answer, about 8,800 links checked, about $7.04 plus the start event.
- 1,000 start pages: about 875 answer, about 87,600 links checked, about $70.08 plus the start event.

Your pages will differ, and `maxItems` caps a run at 100 rows unless you raise it.

Duplicates are billed once, not once per sighting. If 50 link sightings across your pages deduplicate to 47 unique URLs, you pay for 47 checks; the repeats join `all_sources` free.

### How do I use Website Broken Link Checker?

1. Open the Actor and press **Try for free**.
2. Paste your start pages into **Queries**, one URL or bare domain per line. The input carries three example pages from a public test site; replace them with yours.
3. Optionally set **Max Results Per Query** and **Maximum items**.
4. Press **Start**. Rows land in the dataset as links are checked, and the run ends when every page is read.

Example input:

```json
{
  "queries": ["https://crawler-test.com/links/broken_links_internal", "https://crawler-test.com/links/broken_links_external", "https://example.com/"],
  "maxItems": 100
}
```

Or start a run over the API:

```
curl -X POST "https://api.apify.com/v2/acts/Pradio~broken-link/runs?token=YOUR_APIFY_TOKEN" -H "Content-Type: application/json" -d "{\"queries\":[\"https://example.com/\"]}"
```

### Input

| Input | Default | What it does |
|---|---|---|
| `queries` | three example pages | The start pages to check, one URL or bare domain per line. An entry that is not a fetchable URL gets its own free `bad_url` row with the reason, so a typo is answered, never dropped. |
| `maxResultsPerQuery` | none | The most links checked from one start page. Unset means every link found is checked. |
| `maxItems` | 100 | The most link rows one run returns in total. Rows past the cap are dropped, and the run summary shows how many links were checked. |

#### queries

Each entry is one start page: a full URL like `https://example.com/blog`, or a bare domain like `example.com`, which is fetched over HTTPS. The page is fetched once, its anchors are collected and each unique link is checked. Only the pages you list are read; the Actor checks the links on them and does not crawl further. An entry nothing can be fetched for, a typo or an unrecognisable line, is answered on its own uncharged row: `status` `bad_url`, the entry in `url`, the reason in `reason`.

### Output

Rows land in the default dataset as links are checked. Four extras tell you how a run went:

- `ITEM_STATUS`: a `queries` entry nothing could be fetched for, named in `url` with `status` `bad_url` and the reason in `reason`. Pushed so you see it, never billed.
- `NO_RESULTS`: no start page produced a checkable link. One uncharged row carries `reason` and `rowsFetched`, so an empty answer is an answer, not silence.
- `STOPPED_EARLY`: your spending limit ended the run. One uncharged row carries `rowsReturned` and `rowsRemaining`; raise the limit and re-run for the rest.
- `RUN_SUMMARY`: an entry in the run's key-value store carrying the counts: links fetched, rows pushed, rows charged, duplicates dropped, stopped early or not.

A run where every start page fails pushes nothing and fails with the reason in the log. No rows are billed, because none were written.

### What can you do with the data?

**Sweep your own site before a launch.** Run the checker over the pages, filter `classification` to `broken`, and hand the list to whoever fixes it. Each row already carries the dead URL, the page it sits on and the anchor text to search for.

**Audit a migration.** Old URLs that still redirect show how many redirects they took and where they landed in `redirect_count` and `final_url`. The ones that answer 404 instead are the ones to remap.

**Watch outbound links on a schedule.** Reference, partner and affiliate links rot quietly. Put the run on an Apify schedule, export the broken rows to a sheet, and the dataset becomes a monthly fix list.

**Qualify a site you are evaluating.** A page full of dead links says something about how it is maintained. One run counts them without a manual click-through.

### Use Website Broken Link Checker with AI agents

Paste this line to give an agent this Actor through Apify's MCP server:

```
claude mcp add --transport http apify "https://mcp.apify.com?tools=Pradio/broken-link"
```

### Personal data

- A row carries only the declared link-health fields: the checked URL, its HTTP status and verdict, the anchor text and the page it appeared on. No field names or identifies a person.
- A row keeps facts and the link's own anchor text, never the body or a substantial part of a page.
- Every row links back to the page it was found on, so what was collected is easy to check.
- A page that refuses the read is reported as an `error` row, and if no start page answers the run fails; nothing retries past a block or works around a refusal.
- Personal data is not what a row is about, but it can appear incidentally inside anchor text or a URL path (a name in a link label, a profile slug in a path). Run it against sites you operate or are permitted to check.
- You choose the pages, so you are the controller for the list you supply. To exclude a URL, remove it from the input; the Actor reads only what you list.

### Release notes

- `0.1.13` (2026-09-18): an unusable `queries` entry now gets its own free `bad_url` row instead of being dropped; the redirect field is `redirect_count`.
- `0.1.12` (2026-09-17): the Store icon redrawn.
- `0.1.11` (2026-09-17): the sample row on this page re-synced to a fresh capture.
- `0.1.10` (2026-09-17): a link first seen without anchor text picks it up when a later page shows it.
- `0.1.9` (2026-09-17): `source_url` and `anchor_text` now always describe the same sighting.
- `0.1.8` (2026-09-17): `RUN_SUMMARY` is now an entry in the run's key-value store.
- `0.1.7` (2026-09-17): `all_sources` entries carry the element name.
- `0.1.6` (2026-09-17): `all_sources` entries use snake\_case keys.

### Limits

- It checks the links on the pages you name and reports them. It is not a crawler: nothing past the listed pages is read. And it does not fix links: repair stays with you.
- Only anchor tags are checked. `javascript:`, `mailto:`, `tel:`, `data:`, `sms:` and `ftp:` links are skipped, and so are images, scripts and stylesheets.
- Links a page builds with JavaScript after load are not seen: pages are fetched over plain HTTP, with no browser.
- Requests are logged-out and public, with a fixed declared user agent. There is no proxy rotation and no retry past a block: a site that refuses is an `error` row, not a workaround.
- A link that never answers within 15 seconds is an `error` row, suspected but not proven broken.
- A redirect chain is followed for at most 10 redirects; a longer chain returns the last redirect's status.
- Every checked link is a billed row, including `error` verdicts. A run of nothing but unanswered links still bills its rows, because the check itself is the answer.

### Troubleshooting

**The run returned fewer rows than `maxItems`.**
`maxItems` is a ceiling, not a target. Fewer rows means the pages ran out of links first; `rowsFetched` in `RUN_SUMMARY` shows how many links were checked.

**The dataset has one row saying `NO_RESULTS`.**
No start page produced a checkable link. That is the answer to this run, not a bug, and the row is free.

**A row says `bad_url` in `status`.**
One `queries` entry was not a URL the Actor could fetch, a typo or an unrecognisable line. The row names the entry in `url`, gives the reason in `reason`, and is free. The other entries ran normally.

**A row says `error` with `is_broken` empty.**
The link never answered within 15 seconds. It is suspected, not proven dead; re-run later to retry it.

**The run failed with "no start page could be read".**
Every entry failed at the network level: nothing was pushed and no rows were billed. Check the URLs you pasted.

### FAQ

**Can I use integrations with Website Broken Link Checker?**
Yes. The dataset plugs into Apify integrations like Zapier, Make and webhooks, and Apify schedules can re-run the sweep weekly or monthly without you touching it.

**Can I use Website Broken Link Checker with the Apify API?**
Yes. Start runs and read the dataset back over the API; the curl example above is the whole call.

**Can I use Website Broken Link Checker through an MCP server?**
Yes. The line under "Use Website Broken Link Checker with AI agents" adds it to an agent through Apify's MCP server.

**Is it legal to check links this way?**
The Actor makes the same plain HTTP requests any monitoring tool makes: it bypasses no login and keeps only link facts. You choose the pages, so point it at sites you are responsible for or allowed to test.

Something look wrong? Report it on this Actor's Issues tab in the Apify Console and it will be looked at.

### Not affiliated

Website Broken Link Checker is an independent tool, not affiliated with or endorsed by any site you point it at. The example pages in the default input belong to crawler-test.com, a public test site.

# Actor input Schema

## `queries` (type: `array`):

The start pages to check: a full URL or a bare domain per line. Each page is fetched once and every link on it is checked to its final HTTP status.

## `maxResultsPerQuery` (type: `integer`):

The most links checked from one start page; leave unset to check every link the page carries.

## `maxItems` (type: `integer`):

The most link rows one run returns in total; rows past the cap are dropped and the run summary shows how many links were checked.

## Actor input object example

```json
{
  "queries": [
    "https://crawler-test.com/links/broken_links_internal",
    "https://crawler-test.com/links/broken_links_external",
    "https://example.com/"
  ],
  "maxItems": 100
}
```

# Actor output Schema

## `rows` (type: `string`):

The checked-link rows for this run, one row per unique link found on the pages given.

## `summary` (type: `string`):

Counts for this run: links fetched, rows pushed, rows charged, duplicates dropped, stopped early.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("pradio/broken-link").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("pradio/broken-link").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 '{}' |
apify call pradio/broken-link --silent --output-dataset

```

## MCP server setup

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

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/pDKqvReedkPnaPEhA/builds/M0HQTGC6hzJt0qtBG/openapi.json
