# Bulk URL Status Checker - HTTP Status & 301 Redirect Checker (`tidytools/bulk-url-status-checker`) Actor

Bulk HTTP status code and redirect chain checker: paste up to 100,000 URLs, a Google Sheet, CSV or sitemap to check URL status: status code, every 301/302 hop, final URL and why a 404, DNS or SSL check failed. Validate a migration redirect map. $1 per 1,000 URLs; timeouts free.

- **URL**: https://apify.com/tidytools/bulk-url-status-checker.md
- **Developed by:** [Yukai Lin](https://apify.com/tidytools) (community)
- **Categories:** SEO tools, Developer tools, Automation
- **Stats:** 3 total users, 3 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.80 / 1,000 checked urls

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

### What does Bulk URL Status Checker do?

Bulk URL Status Checker is an **HTTP status code and 301 redirect checker for URL lists**: give it up to 100,000 URLs and get one row per URL with the **status code, every redirect hop, the final URL** and the reason when a link fails (404, server error, DNS error, expired SSL certificate, timeout). **$1 per 1,000 URLs, no start fee.**

| You give | You get (one row per URL) | Price |
|---|---|---|
| URLs: a list, a column pasted from a spreadsheet, a **Google Sheets link**, a `.txt`/`.csv` file or an XML sitemap | `status` (200, 301, 404, 500…), `statusClass` (`ok`, `broken`, `server-error`, `error`…), `redirectPath` (the whole chain in one CSV cell: `301 http://a.com -> 200 https://a.com/`), `finalUrl`, `error` reason, SSL days left, canonical and `indexable` | $0.001 per URL |
| A redirect map: `old URL, expected new URL` per line, or a link to the CSV / Google Sheet (Shopify / WordPress exports work). `old URL, 410` for removed pages | `matchesExpected` and `issue`: `wrong-target`, `chain`, `302-not-301`, `https-downgrade`, `not-redirected`, `target-broken`, `still-live` | $0.001 per old URL |

Free: invalid lines, duplicates and URLs that never answer. Results export to CSV, Excel or JSON.

**Redirect map example:** paste the CSV into **Redirect map**, set **Redirect base URL** to the old site, and each line comes back with a verdict (real results, September 2026):

```text
Redirect from,Redirect to        →  result
/download/,/downloads/           →  matchesExpected: true,  issue: null
/about/,/about/apps/             →  matchesExpected: false, issue: "not-redirected"
```

Use it as a bulk URL status checker, HTTP status code checker, 301 redirect checker, redirect chain checker, 404 checker or for site migration QA.

- 🔗 **Full redirect chains**: every hop with its status (301, 302, 307, 308) and `Location` header, redirect loops, and http → https upgrades
- 🧭 **Redirect map check (migration QA)**: old URL + expected new URL per line, or a link to your redirect CSV / Google Sheet (the Old / New columns are found by their header); each result says `matchesExpected` and the issue: `wrong-target`, `chain`, `302-not-301`, `https-downgrade`, `not-redirected` or `target-broken`. Pages you removed on purpose: write `old URL, 410` and they pass when they answer 404 or 410
- 📥 **Many ways to give URLs**: a list, pasted text (any separator), uploaded or linked `.txt`/`.csv` files, a Google Sheets link (the normal share link works, no "publish to web" needed), Google Drive or Dropbox file links, or an XML sitemap (indexes and `.gz` followed)
- ❌ **Clear error reasons**: `DNS: domain not found`, `TLS: certificate expired`, `TLS: self-signed certificate`, `connection refused`, `timeout`, `redirect loop`, `redirect to a malformed address` instead of a bare "failed". Certificates are never ignored: a bad certificate is reported, not skipped
- 🧠 **Smart requests**: tries a light HEAD request first and confirms with GET when a server rejects HEAD, so you get fewer false 404s and 405s
- 🍪 **Handles cookie redirects** (sites that set a cookie and redirect back to the same page) without reporting a false loop
- 🔁 **Re-checks timeouts** once more with a longer timeout, for free, so busy servers are not reported as dead; a URL that still never answers is not charged
- 🚦 **Fast on big lists, polite to each site**: up to 40 URLs at a time but at most 8 on one site, connections are reused, and a site that answers `429 Too Many Requests` gets fewer parallel requests instead of a column of 429 rows
- 🏷️ **Status classes** you can filter on: `ok`, `redirect`, `broken`, `server-error`, `restricted`, `error`, `invalid`
- 🔒 **SSL certificate expiry**: for https URLs, `sslDaysLeft`, `sslValidTo`, `sslIssuer` and `sslValid`, with `sslExpiring` when fewer than 14 days are left (configurable). One check per host, no extra charge
- 🧾 **Canonical, meta robots and title** (on by default, free): `indexable`, `canonicalMatches` and `notIndexableReason` for final 200 HTML pages; in a redirect map, a new page whose canonical is not the expected URL gets the `canonical-mismatch` issue
- 📈 **Monitoring mode**: compare with the previous run and see which URLs broke, recovered or started redirecting somewhere else
- ⚙️ **Control**: follow redirects or stop at the first answer, max redirects, custom User-Agent, HEAD/GET/auto
- ⚡ **Fast and cheap**: thousands of URLs per minute, **$1 per 1,000 URLs**

### Who is it for?

- **Site migrations**: check that every old URL redirects to the right new page with a single 301, straight from your redirect spreadsheet
- **SEO**: find 404s, redirect chains and http links in a list you exported from a sitemap, Search Console or a crawler
- **Link lists and directories**: clean up outdated links in bookmarks, resource pages, spreadsheets or a database
- **Monitoring**: run it on a schedule with **Compare with the previous run** and get only the URLs whose status or final URL changed, plus SSL certificates about to expire

### How much does it cost?

| Event | Price |
|---|---|
| Checked URL | **$1 / 1,000 URLs** |

**Example:** a 10,000-URL migration check costs 10,000 × $0.001 = **$10.00** on the Free plan.

**No start fee.** Higher Apify plans get volume discounts (see the *Pricing* tab). Invalid URLs (for example `not a url` or `ftp://…`) are reported but **not charged**, and neither are URLs that never answer (timeouts, dropped connections).

#### Control your cost

- **Charged:** one `Checked URL` event per URL that got an answer, including a negative one: a 404, a redirect loop, a DNS error, a refused connection or an expired certificate is a result (the URL is dead), so it is charged (`charged: true`).
- **Free** (`charged: false`): invalid lines such as `not a url`, `Acme Inc` or `localhost` (`statusClass: "invalid"`), a URL list file or sitemap that could not be read (one `invalid` row with the reason), relative redirect-map paths without a base URL, duplicate URLs (checked once; the row lists every original line in `inputs`; `EXAMPLE.com`, `example.com`, `https://example.com/` and a `#fragment` count as the same URL) and URLs that never answer.
- **Timeouts are re-checked, and free if they stay silent:** a URL that fails to connect (timeout, connection dropped) is checked once more with a timeout of at least 30 seconds, alongside the remaining URLs and at most 2 at a time per site. If the second try answers, the URL is charged once; if it still does not answer, the row says so (`charged: false`) and `SUMMARY.unansweredNotCharged` counts them.
- **Input mistakes never fail the run:** an empty input, a list of invalid lines or more than 100,000 URLs ends with a free `invalid` row that explains what to fix and a normal exit (nothing checked, nothing charged).
- **Before it starts**, the run logs its plan (number of URLs × price = the most it can cost) and compares it with your **maximum charge per run** (run options). It is saved as `costPlan` in `SUMMARY`.
- **When the maximum is reached**, the run stops: the status message says so, `SUMMARY.status` is `LIMIT_REACHED` and `SUMMARY.notProcessed` has the number of URLs not checked and the first 100 of them.
- **If Apify restarts the run** (server migration or Resurrect), items already finished are skipped and not charged again (`SUMMARY.resumedSkipped`).
- **HTTP 429 (Too Many Requests)**: the URL is checked once more after the wait the server asks for in `Retry-After` (at most 10 seconds; 2 seconds when it gives none), and the result shows `rateLimited`. A URL that still answers 429 is reported as `restricted` with a `note`. The check is charged once, retry included.

#### Price comparison (checked September 2026)

| Actor | Price per 1,000 URLs (free plan) | Redirect map (expected target) check |
|---|---|---|
| **Bulk URL Status & Redirect Checker (this Actor)** | **$1**, no start fee | Yes |
| automation-lab/bulk-url-status-checker | $0.08 + $0.005 start fee | No |
| logiover/bulk-url-status-checker | $2.50 | No |
| automation-lab/http-status-checker | $0.575 + $0.035 start fee | No |
| onescales/simple-http-status-code-checker | $300 on the free plan ($10 on Bronze) | No |

automation-lab/bulk-url-status-checker is much cheaper for large lists and also offers sitemap, text and HTML-signal inputs. Choose this Actor when you need the redirect map check with issue types, cookie-redirect handling, free re-checks of timeouts, SSL expiry and run-to-run comparison. For small batches the start fees matter: 10 URLs cost $0.01 here and about $0.041 with automation-lab/http-status-checker.

**vs Apify Sitemap Extractor:** it also checks the status of sitemap URLs ($0.50 per 1,000 on the free plan, $0.10 on Gold), but it has no redirect chains, redirect map check or error reasons.

### How to use it

1. Paste your **URLs**, one per line (bare domains like `example.com` are fine), or use **Paste URLs as text**, **URL list files** or **Sitemap URL**.
2. For a migration, fill in **Redirect map** with one `old URL, expected new URL` pair per line. Paths only (`/old,/new`, as exported by Shopify or WordPress)? Also fill in **Redirect base URL**.
3. Optional: turn on **Only report problems** to skip clean 200 responses in the output. **Read canonical, meta robots and title** (indexability) is on by default; turn it off for the fastest status-only check.
4. Click **Start** and export the results as CSV, Excel or JSON.

#### How to check a list of URLs for 404s and redirects in bulk

1. Copy the URL column from your spreadsheet (or export from Search Console, a CMS or a crawler) and paste it into **Paste URLs as text**; any separator works. Or share the sheet as "Anyone with the link" and put its link into **URL list files** (or **URLs**): every URL in the tab is checked.
2. Turn on **Only report problems** so the output lists only 404s, server errors, redirects and dead hosts.
3. Start the run. Sort by `statusClass`: `broken` (4xx), `server-error` (5xx), `error` (DNS, SSL, timeout, loop; the reason is in `error`). Redirected URLs have `redirects` above 0, every hop in `redirectChain` and the same chain as one text cell in `redirectPath` (handy in CSV and Google Sheets). Each row keeps your original line in `input`, so you can match it back to the sheet.

#### How to check 301 redirects after a site migration

1. Export the redirect list (the *URL redirects* page of the Shopify admin and WordPress redirect plugins export it as CSV) or write `old URL, new URL` pairs. Quoted CSV cells are fine. For pages you deleted on purpose, write `old URL, 410` (or `404` / `gone`).
2. Paste it into **Redirect map**, header row included, or put the link of the CSV or Google Sheet into **Redirect map file** (columns such as Old URL / New URL or Redirect from / Redirect to are found by their header; otherwise the first two columns are used). If the old URLs are paths only (`/old-page`), set **Redirect base URL** to the old site, e.g. `https://shop.com`.
3. Start the run. `SUMMARY.redirectMap` counts passed and wrong redirects; filter the rows on `matchesExpected: false` or `issue` to get the fix list (`wrong-target`, `not-redirected`, `302-not-301`, `chain`, `https-downgrade`, `target-broken`, `canonical-mismatch`; for removed pages `still-live`, `redirected`, `wrong-status`).

#### How to find broken URLs and redirect chains in a sitemap

1. Put the sitemap address (e.g. `https://example.com/sitemap.xml`) into **Sitemap URL**; sitemap indexes and `.gz` files are followed.
2. Turn on **Only report problems**: a sitemap should list only final 200 pages, so every row in the output (redirect, 404, not indexable because of `noindex` or another canonical) is a sitemap entry to fix.
3. To keep watching it, turn on **Compare with the previous run** and schedule the task weekly (see *Monitoring* below).

#### Input example

```json
{
    "urls": [
        "http://python.org",
        "https://github.com/apify/crawlee",
        "https://example.com/this-page-does-not-exist",
        "https://expired.badssl.com/"
    ],
    "onlyProblems": false,
    "method": "auto",
    "timeoutSecs": 20
}
```

#### Output example

Every row starts with `input` / `inputUrl` (the line exactly as you gave it, e.g. `python.org`) and `inputIndex` (its position after duplicates are merged), and ends with `charged`, so results map back to your spreadsheet. `url` is the address that was checked (bare domains get `https://`).

Real results from the input above. A redirect from http to https:

```json
{
    "url": "http://python.org",
    "finalUrl": "https://www.python.org/",
    "status": 200,
    "statusClass": "ok",
    "redirects": 1,
    "statusText": "OK",
    "redirectChain": [
        { "url": "http://python.org", "status": 301, "location": "https://www.python.org/" },
        { "url": "https://www.python.org/", "status": 200 }
    ],
    "httpsUpgrade": true,
    "contentType": "text/html; charset=utf-8",
    "contentLength": 11774,
    "server": "nginx",
    "xRobotsTag": null,
    "responseMs": 341,
    "error": null
}
```

A site with an expired certificate:

```json
{
    "url": "https://expired.badssl.com/",
    "finalUrl": "https://expired.badssl.com/",
    "status": null,
    "statusClass": "error",
    "redirects": 0,
    "redirectChain": [],
    "error": "TLS: certificate expired",
    "errorType": "network",
    "responseMs": 86
}
```

#### SSL and indexability (real results, September 2026)

With **Read canonical, meta robots and title** on (`"includeHtmlSignals": true`, the default; set it to `false` for the fastest status-only check), a page whose canonical points to another URL is reported as not indexable:

```json
{
    "url": "https://www.apple.com/iphone?x=1",
    "finalUrl": "https://www.apple.com/iphone/?x=1",
    "status": 200,
    "sslHost": "www.apple.com",
    "sslValid": true,
    "sslValidTo": "2026-12-16T18:31:25.000Z",
    "sslDaysLeft": 78,
    "sslIssuer": "Apple Inc.",
    "sslExpiring": false,
    "sslExpired": false,
    "title": "iPhone - Apple",
    "canonical": "https://www.apple.com/iphone/",
    "metaRobots": null,
    "canonicalUrl": "https://www.apple.com/iphone/",
    "canonicalMatches": false,
    "indexable": false,
    "notIndexableReason": "canonical points elsewhere"
}
```

The expired test site gets `"sslDaysLeft": -4188, "sslValid": false, "sslExpired": true, "sslError": "expired", "sslIssuer": "COMODO CA Limited"`. SSL fields are added by default (turn off **Check SSL certificates** to skip them); `sslError` also reports `self-signed`, `wrong host` or `incomplete chain`.

#### Redirect map (migration check)

Input: one pair per line in **Redirect map** (comma, tab, space or `->` between the two URLs; a CSV header row is skipped). Via the API you can also pass objects `{ "from": "...", "to": "..." }`.

```json
{
    "redirectMap": [
        "http://python.org, https://www.python.org/",
        "https://www.python.org/download/, https://www.python.org/downloads/source/",
        "http://stripe.com/docs, https://docs.stripe.com/",
        "https://www.python.org/nothing-here/, https://www.python.org/"
    ]
}
```

Real results (September 2026, shortened):

```json
[
    { "url": "http://python.org", "finalUrl": "https://www.python.org/", "redirects": 1, "expectedUrl": "https://www.python.org/", "matchesExpected": true, "issue": null },
    { "url": "https://www.python.org/download/", "finalUrl": "https://www.python.org/downloads/", "expectedUrl": "https://www.python.org/downloads/source/", "matchesExpected": false, "issue": "wrong-target" },
    { "url": "http://stripe.com/docs", "finalUrl": "https://docs.stripe.com/", "redirects": 2, "expectedUrl": "https://docs.stripe.com/", "matchesExpected": true, "issue": "chain" },
    { "url": "https://www.python.org/nothing-here/", "status": 404, "expectedUrl": "https://www.python.org/", "matchesExpected": false, "issue": "not-redirected" }
]
```

| issue | Meaning |
|---|---|
| `wrong-target` | Redirects, but not to the expected URL |
| `not-redirected` | The old URL does not redirect at all |
| `target-broken` | Redirects to a page that returns an error |
| `chain` | Reaches the right page through more than one redirect |
| `302-not-301` | The first redirect is temporary (302/303/307); use 301 or 308 for moved pages |
| `https-downgrade` | A redirect in the chain goes from https to http |
| `canonical-mismatch` | Only with **Read canonical, meta robots and title**: the right page is reached, but its canonical tag points to another URL (real example: `http://docs.python.org/3/` → canonical `https://docs.python.org/3/index.html`) |
| `still-live` / `redirected` / `wrong-status` | Removed pages (`old URL, 410`): the page still answers 2xx / it redirects instead of answering 404 or 410 / it answers something else (5xx, DNS error). 404 and 410 both pass |

`issues` lists all problems found; `issue` is the most important one. URLs are compared exactly, except for host letter case and a trailing slash.

**Relative paths (Shopify, WordPress exports).** Redirect exports often use paths only, such as `/download/,/downloads/`. Put the old site in **Redirect base URL** (`"redirectBaseUrl": "https://www.python.org"`) and each path is checked on that site; a relative target is put on the same site. Without it, the base is taken from the full old URLs in the map when they all share one site; otherwise the relative lines come back as `statusClass: "invalid"` with the error `relative path: set "Redirect base URL"…` (not charged) and keep their `expectedUrl`.

```json
{
    "redirectMap": ["Redirect from,Redirect to", "/download/,/downloads/", "/about/,/about/apps/"],
    "redirectBaseUrl": "https://www.python.org"
}
```

Real results (September 2026, shortened):

```json
[
    { "input": "/download/", "url": "https://www.python.org/download/", "finalUrl": "https://www.python.org/downloads/", "expectedUrl": "https://www.python.org/downloads/", "matchesExpected": true, "issue": null },
    { "input": "/about/", "url": "https://www.python.org/about/", "status": 200, "expectedUrl": "https://www.python.org/about/apps/", "matchesExpected": false, "issue": "not-redirected" }
]
```

#### Summary

`SUMMARY` in the key-value store: `status` (`SUCCESS`, `PARTIAL_RESULTS`, `FAILED`, `NO_RESULTS`, `LIMIT_REACHED`), counts by status class, `ssl` (hosts checked, `expiring` and `invalid` hosts), `htmlSignals` counts when enabled, `comparison` in monitoring mode, `duplicatesMerged`, `costPlan`, `notProcessed` (only when the run stopped at your limit), `redirectBaseUrl` / `relativePathsWithoutBase` when the map has relative paths and, with a redirect map, `redirectMap`: `{ "checked": 4, "matches": 2, "mismatches": 2, "passed": 1, "withIssues": 3, "byIssue": { "wrong-target": 1, "chain": 1, "not-redirected": 1 } }`.

#### Monitoring: compare with the previous run

Turn on **Compare with the previous run** and schedule the Actor (daily or weekly). Each result gets `change`: `new`, `changed` or `unchanged`; changed URLs also get `changeType` (`broke`, `recovered`, `status-changed`, `new-redirect`, `redirect-target-changed`, `redirect-removed`), `previousStatus` and `previousFinalUrl`. With **Only output changes**, later runs output only new and changed URLs, so an Apify integration (email, Slack, webhook) only fires when something happened. Runs with the same **Monitor name** are compared (default: the sitemap or first URL's host).

`SUMMARY.comparison` from a real second run (one URL added, one removed):

```json
{ "monitorName": "c2-test", "firstRun": false, "new": 1, "changed": 0, "unchanged": 6, "removed": 1, "changedUrls": [],
  "removedUrls": [{ "url": "https://www.apple.com/iphone?x=1", "previousStatus": 200, "previousFinalUrl": "https://www.apple.com/iphone/?x=1" }] }
```

The first run of a monitor is the baseline (`firstRun: true`, every URL is `new`). URLs are checked and charged the same way in every run.

#### Status classes

| statusClass | Meaning |
|---|---|
| `ok` | 2xx after following redirects |
| `broken` | 4xx such as 404 Not Found or 410 Gone |
| `server-error` | 5xx |
| `restricted` | 401, 403, 429, or a site known to block automated checks (LinkedIn, X/Twitter, Facebook…). Usually works in a normal browser |
| `error` | No HTTP answer: DNS, SSL, refused connection, timeout, redirect loop |
| `invalid` | Not a valid http(s) URL (not charged) |

### Use with AI agents (MCP)

Connect Apify's MCP server (https://mcp.apify.com?tools=tidytools/bulk-url-status-checker) to Claude, Cursor or any MCP client, then ask e.g. "Check these 200 old URLs against our redirect spreadsheet and list the ones that do not land on the right page."

```json
{ "urls": ["http://python.org", "https://example.com/this-page-does-not-exist"] }
```

Unreachable URLs carry an `errorType` (`network`, `timeout`…); invalid URLs are not charged.

### Use it from code and integrations

Run it from your own code with the Apify API. This call waits for the run and returns the results as JSON (replace `YOUR_TOKEN` with your Apify API token):

```bash
curl -X POST "https://api.apify.com/v2/acts/tidytools~bulk-url-status-checker/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls":["http://python.org","https://github.com/apify/crawlee","https://example.com/this-page-does-not-exist","https://expired.badssl.com/"],"includeHtmlSignals":true}'
```

The synchronous endpoint waits up to 5 minutes. For bigger runs, start the run with `POST https://api.apify.com/v2/acts/tidytools~bulk-url-status-checker/runs` and read the dataset when it finishes, or use the `apify-client` package for JavaScript or Python.

**Schedules and integrations:** run it daily or weekly with Apify Schedules, get a webhook when a run finishes, or send the results to Zapier, Make, n8n, Google Sheets, Slack and other apps with Apify integrations. Results can be exported as JSON, CSV, Excel or XML.

### Tips

- To check **every link on a website** rather than a list, use **Broken Link Checker** (by TidyTools), which crawls the site and tells you which page each broken link is on.
- Need the URL list first? **Sitemap URL Extractor** (by TidyTools) gets all URLs of a site from its sitemaps.
- Use **HEAD only** for the fastest checks on servers you control, or **GET only** for servers that answer HEAD incorrectly.
- Checking thousands of URLs on one site of your own? Raise **Parallel requests per site** (`maxConcurrencyPerHost`, default 8) to go faster; lower it for a small or rate-limited site.
- Turn off **Follow redirects** to see only the first answer (status and `location`) of each URL.

### Limitations

- Only the exact URLs you give are requested (one request each, plus the redirects it follows), with the User-Agent `TidyToolsLinkCheck/1.0 (+https://apify.com/tidytools)` unless you set your own. It does not crawl, so it does not read `robots.txt`.
- Private and local addresses (`localhost`, `10.x.x.x`, names without a dot) cannot be reached from Apify's servers; names without a dot are reported as `invalid` (not charged).
- Requests come from Apify's servers. Some sites block data-center traffic or show a bot challenge; those URLs are reported as `restricted` or `error` even if they work for you.
- Page content is not downloaded, except the first 256 KB of final 200 HTML pages when **Read canonical, meta robots and title** is on. Canonical and robots tags added later by JavaScript are not seen.
- The SSL certificate is read with a direct connection from Apify's servers, not through the proxy.

### FAQ

**Does the Actor collect usage data?** The Actor sends one anonymous usage counter per run (input type, item count range, outcome) to improve the tool; no URLs, content or account data.

**Can it check my redirects after a site migration?** Yes. Put one "old URL, expected new URL" pair per line in **Redirect map (migration check)**; each result gets `matchesExpected` and an issue such as `wrong-target`, `chain`, `302-not-301`, `not-redirected` or `target-broken`. For path-only exports from Shopify or WordPress, also fill in **Redirect base URL (for relative paths)**.

**Can I give it a Google Sheet?** Yes. Share the sheet as "Anyone with the link" (Viewer) and paste its normal link into **URL list files** or **URLs**: the tab in the link is read as CSV and every URL in it is checked. A redirect map sheet goes into **Redirect map file**. A private sheet gives one free `invalid` row that says how to share it.

**Does the redirect checker show the full redirect chain?** Yes. Every hop is listed with its status (301, 302, 307, 308) and `Location` header, plus the final URL, redirect loops, http → https upgrades and https → http downgrades (`httpsDowngrade`). `redirectPath` puts the whole chain in one text column for CSV and Excel exports. Turn off **Follow redirects** to see only the first response, and use **Max redirects** to set where following stops.

**How do I use it as a 404 checker and only see broken URLs?** Turn on **Only report problems**: only URLs that are not a clean 200 (redirects, broken, errors) are output, though all URLs are still checked and charged. 404s get `statusClass: "broken"`; to crawl every link on a website instead of a list, use Broken Link Checker by TidyTools.

**Why does the HTTP status checker report a URL as restricted when it opens in my browser?** Requests come from Apify's servers, and some sites block data-center traffic or show a bot challenge, so those URLs are reported as `restricted` or `error` even if they work for you. `restricted` also covers 401, 403, 429 and sites known to block automated checks such as LinkedIn, X/Twitter and Facebook.

**Am I charged for dead links and failed checks?** A dead link is the answer you asked for, so a 404, a DNS error, a refused connection or an expired or self-signed certificate is charged ($0.001, `charged: true`). Not charged: invalid input lines, duplicates, and URLs that never answer even after the slower second try (timeout, connection dropped; `charged: false`). The run's `SUMMARY.billing` shows what was charged.

**What happens when my input is wrong (empty, no valid URLs, unreadable list file)?** The run still finishes normally and costs nothing: every bad line or unreadable list or sitemap gets one `invalid` row with the reason (`errorType: "invalid_input"`), so you can fix it and run again. Via the API, `urls` or `redirectMap` sent as one text block instead of a list is split into lines, and numbers sent as text (`"timeoutSecs": "20"`) are read; an unusable number falls back to the default.

**Does it check bad certificates?** It reports them and never turns certificate checking off: `https://expired.badssl.com/` comes back as `TLS: certificate expired`, a self-signed one as `TLS: self-signed certificate`, a wrong host name as `TLS: certificate does not match the domain`. The SSL fields (`sslError`, `sslDaysLeft`) say more.

**Are `example.com` and `EXAMPLE.com/` the same URL?** Yes: upper/lower case in the host name, a trailing slash on a bare domain and a `#fragment` do not change the request, so they are checked once and charged once. The row lists every spelling in `inputs`.

### Support

Open an issue in the **Issues** tab with an example URL. Issues are checked regularly.

Found this useful? A short review on the Store helps other users find it.

# Actor input Schema

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

Main input (fill this, or `urlsText` / `urlSources` / `sitemapUrl` / `redirectMap`). One URL per line (full URLs or bare domains like example.com). Up to 100,000 per run in total. You can also paste text, upload a list file, give a sitemap or a redirect map below.

## `urlsText` (type: `string`):

Optional: paste URLs separated by new lines, commas, semicolons, tabs or spaces (e.g. a column copied from a spreadsheet).

## `urlSources` (type: `array`):

Optional: upload a .txt or .csv file, or link to one. A Google Sheets link works as it is (the normal edit or share link of a sheet shared as "Anyone with the link"; the tab in the link is read), and so do Google Drive and Dropbox file links. Every http(s) URL in the file is checked. A Google Sheets link pasted into URLs is read the same way.

## `sitemapUrl` (type: `string`):

Optional: check every page listed in this XML sitemap (sitemap indexes and .gz files are followed).

## `redirectMap` (type: `array`):

Optional: one "old URL, expected new URL" pair per line. Each old URL is checked and compared with the expected target: matchesExpected plus an issue (wrong-target, chain, 302-not-301, https-downgrade, not-redirected, target-broken). Use 410 (or 404 / gone) as the target for pages you removed on purpose: they pass when they answer 404 or 410. Relative paths such as "/old-page,/new-page" (Shopify and WordPress exports) are supported: set "Redirect base URL" below. You can also paste a link to the CSV or Google Sheet here, or use "Redirect map file".

## `redirectMapUrl` (type: `string`):

Optional: link to a redirect map CSV or a Google Sheet (shared as "Anyone with the link"). The old and new columns are found by their header (Old URL / New URL, Redirect from / Redirect to, Source / Target, From / To), otherwise the first two columns are used. Works together with the lines above.

## `redirectBaseUrl` (type: `string`):

Old site origin for relative paths in the redirect map, e.g. https://shop.com: "/old-page" is checked as https://shop.com/old-page and a relative target is put on the same site. Leave empty to use the site of the full old URLs in the map when they all share one site; otherwise relative lines are reported as invalid (not charged).

## `onlyProblems` (type: `boolean`):

Only output URLs that are not a clean 200 (redirects, broken, errors). All URLs are still checked and charged.

## `includeHtmlSignals` (type: `boolean`):

For final 200 HTML pages, also download the first 256 KB and report title, canonical, metaRobots, indexable and canonicalMatches. With a redirect map, a new page whose canonical is not the expected URL gets the canonical-mismatch issue. No extra charge; a little slower. On by default; turn it off for the fastest status-only check.

## `checkSsl` (type: `boolean`):

For https URLs, read the certificate once per host: sslDaysLeft, sslValidTo, sslIssuer, sslValid and sslExpiring. No extra charge.

## `sslWarnDays` (type: `integer`):

Certificates expiring within this many days get sslExpiring: true (and count as a problem for "Only report problems").

## `method` (type: `string`):

"Auto" tries a lightweight HEAD request and confirms with GET when a server rejects HEAD. Values: auto = Auto (recommended); HEAD = HEAD only; GET = GET only.

## `followRedirects` (type: `boolean`):

Off: report the first response only (e.g. 301 with its Location header) without requesting the target.

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

Stop following after this many redirects (reported as an error).

## `timeoutSecs` (type: `integer`):

How long to wait for each server response.

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

How many URLs are checked at the same time, across all sites. Each site also gets at most "Parallel requests per site" at once, so a long list of mixed sites runs fast without flooding any one of them.

## `maxConcurrencyPerHost` (type: `integer`):

At most this many requests at the same time to one host name (e.g. www.example.com), however many of its URLs are in the list. Raise it to check a large site of your own faster; lower it for a small or rate-limited site. Re-checks of URLs that did not answer use at most 2 per site.

## `compareWithPrevious` (type: `boolean`):

Monitoring: remember each URL's status and final URL, and mark every result change: new, changed or unchanged (with changeType such as broke, recovered, status-changed, new-redirect). SUMMARY lists changed and removed URLs. Use with a schedule.

## `monitorName` (type: `string`):

Runs with the same name are compared. Leave empty to use the sitemap or first URL's host. Use a different name for each URL list you monitor.

## `onlyChanges` (type: `boolean`):

With comparison on: after the first (baseline) run, output only URLs that are new or whose status or final URL changed. All URLs are still checked and charged.

## `userAgent` (type: `string`):

Optional User-Agent header. Leave empty to identify as TidyToolsLinkCheck.

## `proxyConfiguration` (type: `object`):

Check URLs through an Apify proxy, e.g. to see how a site answers visitors from another country. Apify proxy usage is billed to your Apify account.

## Actor input object example

```json
{
  "urls": [
    "http://python.org",
    "https://github.com/apify/crawlee",
    "https://example.com/this-page-does-not-exist",
    "https://expired.badssl.com/"
  ],
  "onlyProblems": false,
  "includeHtmlSignals": true,
  "checkSsl": true,
  "sslWarnDays": 14,
  "method": "auto",
  "followRedirects": true,
  "maxRedirects": 10,
  "timeoutSecs": 20,
  "maxConcurrency": 40,
  "maxConcurrencyPerHost": 8,
  "compareWithPrevious": false,
  "onlyChanges": false
}
```

# Actor output Schema

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

No description

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

No description

## `full` (type: `string`):

No description

# 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 = {
    "urls": [
        "http://python.org",
        "https://github.com/apify/crawlee",
        "https://example.com/this-page-does-not-exist",
        "https://expired.badssl.com/"
    ],
    "includeHtmlSignals": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("tidytools/bulk-url-status-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 = {
    "urls": [
        "http://python.org",
        "https://github.com/apify/crawlee",
        "https://example.com/this-page-does-not-exist",
        "https://expired.badssl.com/",
    ],
    "includeHtmlSignals": True,
}

# Run the Actor and wait for it to finish
run = client.actor("tidytools/bulk-url-status-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 '{
  "urls": [
    "http://python.org",
    "https://github.com/apify/crawlee",
    "https://example.com/this-page-does-not-exist",
    "https://expired.badssl.com/"
  ],
  "includeHtmlSignals": true
}' |
apify call tidytools/bulk-url-status-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,tidytools/bulk-url-status-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/ZOWuVMgMJNRNJnmyb/builds/TeDlBWyZzNh6Dwmwq/openapi.json
