LinkPulse API docs
One call checks one page. LinkPulse fetches the page, pulls out its links, and checks up to 200 of them, following every redirect hop by hand. Before every request, the host's DNS records are checked to make sure it's a public address. It returns versioned JSON. Free, no key, CORS enabled.
Endpoints
| Method & path | Description |
|---|---|
GET /api/check?url=<page> | Check one page. Optional assets=false (only <a>/<area> links) and max=1..200 (links to fetch, default 200). When there are more links than max, about half the slots go to hyperlinks and half to assets, each in document order. |
POST /api/check | Same, with a JSON body {"url": "...", "assets": true, "max": 200} (or form-encoded). The body can be at most 8 KB. |
GET /api/health | Service status, version, schema id and limits. |
OPTIONS /api/* | CORS preflight (Access-Control-Allow-Origin: *). |
A URL without a scheme is treated as https://. Only http/https on ports 80/443 are accepted.
Examples
curl
curl -s "https://linkpulse.mike-tusa.workers.dev/api/check?url=https://example.com/" | jq '.summary'
# only <a href> links, at most 20
curl -s "https://linkpulse.mike-tusa.workers.dev/api/check?url=https://example.com/&assets=false&max=20"
# POST JSON
curl -s -X POST "https://linkpulse.mike-tusa.workers.dev/api/check" -H 'content-type: application/json' \
-d '{"url":"https://example.com/"}' | jq '.links[] | select(.category=="broken") | .url'
JavaScript (browser or Node 18+)
const res = await fetch("https://linkpulse.mike-tusa.workers.dev/api/check?url=" + encodeURIComponent("https://example.com/"));
if (res.status === 429) {
console.log("rate limited, retry after", res.headers.get("retry-after"), "s");
} else {
const r = await res.json();
if (r.error) throw new Error(r.error.code + ": " + r.error.message);
console.log(r.summary); // { checked, ok, broken, ... }
for (const l of r.links.filter(l => l.category === "broken")) console.log(l.status, l.url);
for (const l of r.links.filter(l => l.redirects)) console.log(l.hops.map(h => h.status + " " + h.url).join(" -> "));
}
Response schema (linkpulse.check.v1)
Fields are only ever added under linkpulse.check.v1, never removed or renamed. A breaking change would get a new schema id.
{
"schema": "linkpulse.check.v1",
"version": "0.3.0",
"checkedAt": "2026-10-03T13:00:00.000Z",
"input": "http://github.com",
"options": { "assets": true, "max": 200 },
"page": {
"url": "http://github.com/", "finalUrl": "https://github.com/", "status": 200, "ok": true,
"contentType": "text/html; charset=utf-8", "bytes": 285000, "bodyTruncated": false,
"redirects": 1,
"hops": [ { "url": "http://github.com/", "status": 301, "location": "https://github.com/", "ms": 40, "method": "GET" },
{ "url": "https://github.com/", "status": 200, "ms": 180, "method": "GET" } ],
"ms": 260
},
"summary": { "linksFound": 314, "uniqueLinks": 243, "checked": 200, "ok": 186, "redirected": 11, "broken": 3,
"blocked": 0, "unchecked": 43, "mixedContent": 0, "insecureLinks": 0, "longChains": 1,
"redirectLoops": 0, "maybeBotBlocked": 2, "resolvesPrivate": 0 },
"truncated": true,
"truncatedReasons": ["link_cap"],
"links": [ /* link objects, see below */ ],
"notChecked": { "count": 43, "sample": ["https://github.com/...", "..."] },
"skipped": { "fragment_only": 3, "non_http_scheme": 1, "link_rel_hint": 4 },
"notes": [ { "level": "info", "text": "..." } ],
"limits": { "pagesPerCheck": 1, "maxLinks": 200, "maxHops": 10, "longChainRedirects": 3, "subrequestBudget": 1000,
"pageTimeoutMs": 10000, "linkTimeoutMs": 8000, "dnsTimeoutMs": 2500, "deadlineMs": 45000,
"maxPageBytes": 3000000, "concurrency": 6, "maxBlockedListed": 100, "maxExtracted": 5000, "dnsForLinks": true },
"timings": { "totalMs": 7500, "pageMs": 260, "linksMs": 7240 },
"subrequests": 262,
"dnsLookups": 42
}
The numbers above are illustrative, not a real result.
Link object
{
"url": "http://example.org/old", // absolute URL as found on the page (fragment removed)
"kind": "link", // link | image | script | stylesheet | icon | resource | iframe | media | embed
"tag": "a", "count": 2, // source tag; how many times it appears
"category": "redirect", // ok | redirect | broken | blocked | unchecked
"status": 200, // final HTTP status (null if no response)
"finalUrl": "https://example.org/new",
"method": "HEAD", // HEAD, or GET when HEAD failed and was retried
"redirects": 2,
"hops": [ { "url": "http://example.org/old", "status": 301, "location": "https://example.org/old", "ms": 30, "method": "HEAD" },
{ "url": "https://example.org/old", "status": 302, "location": "https://example.org/new", "ms": 41, "method": "HEAD" },
{ "url": "https://example.org/new", "status": 200, "ms": 38, "method": "HEAD" } ],
"ms": 112,
"error": null, // or { "code": "...", "message": "..." }
"flags": ["insecure_link"]
}
| category | Meaning |
|---|---|
ok | 2xx/3xx final response with no redirects. |
redirect | Ends in a non-error response after one or more redirects (see hops). |
broken | Final status ≥ 400 (or 530). Also a host that doesn't exist (NXDOMAIN or no A/AAAA records, error.code: "dns_error"), a connect/TLS error, a timeout, a redirect loop, more than 10 redirects, or an invalid Location header. |
blocked | The link or one of its redirect hops points somewhere LinkPulse refuses to fetch: a private/loopback/metadata IP, a hostname whose DNS answers include such an IP, a local name, a non-80/443 port, or credentials in the URL. Not fetched. |
unchecked | Not fetched because the 1,000-request budget or the 45 s time limit ran out (error.code subrequest_budget / deadline), or because the DNS safety lookup failed (dns_check_failed, so the link was not fetched). Links beyond max are counted in notChecked. They're only listed in links (error.code: "link_cap") when the HTML alone shows mixed content or a plain-HTTP link. |
| flag | Meaning |
|---|---|
mixed_content_active | HTTPS page loads an http:// script, stylesheet, iframe, embed or other <link> resource. Browsers block these. |
mixed_content_passive | HTTPS page loads an http:// image, video, audio or track. |
insecure_link | HTTPS page has a plain http:// hyperlink. |
https_downgrade | A redirect hop goes from https to http. |
long_chain | 3 or more redirects. |
redirect_loop / too_many_redirects | Loop detected, or more than 10 hops. |
maybe_bot_blocked | Final 401/403/429/999 or a Cloudflare cf-mitigated challenge. The link may work in a browser, since many sites block cloud IPs. |
head_not_supported | HEAD returned an error but GET worked. |
blocked_redirect | A redirect pointed to a blocked target and was not followed. |
resolves_private | The link's host, or a redirect target's host, is a public name whose DNS answers include a private, loopback, link-local or metadata address (e.g. localhost.direct → 127.0.0.1). |
get_fallback_skipped | HEAD failed and no budget was left for a GET retry, so the HEAD result is shown. |
Errors and rate limits
Errors return a non-2xx status with {"error": {"code", "message", ...}, "version"}.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_url, invalid_scheme, credentials_in_url, invalid_request | Bad input |
| 403 | blocked_target, blocked_port, blocked_redirect | The page URL, or one of the page's redirect hops, is not a public 80/443 target. For the page, the host's A/AAAA records are also checked with DNS-over-HTTPS. |
| 422 | dns_not_found | The page host doesn't resolve. |
| 429 | rate_limited | Over 20 checks/hour/IP (error.scope: "ip"), a short burst of more than ~10 per minute ("burst"), or the service-wide daily check capacity is used up ("global", resets 00:00 UTC). Has a Retry-After header (seconds) and error.retryAfterSec. |
| 502/504 | page_fetch_failed, page_timeout, redirect_loop, too_many_redirects, bad_redirect, dns_error | The page itself couldn't be fetched. error.redirects holds the hops so far. |
Successful checks include X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Identical requests within about 2 minutes are served from an in-memory cache (x-cache: HIT) and don't count. Inputs rejected by the URL rules before anything is fetched (400/403: scheme, port, IP literal, local names) don't count either. A page whose host fails the DNS check does count. A page that returns 4xx/5xx still gives a 200 response, with page.ok: false, and its links are not checked.
API keys: not available yet. If you send an X-Api-Key or Authorization header, it is ignored, the anonymous limit applies, and a note says so. There's no paid plan.
MCP server (for AI agents)
POST https://linkpulse.mike-tusa.workers.dev/mcp is a remote Model Context Protocol server (Streamable HTTP, stateless, JSON responses; protocol 2026-07-28 and the older initialize-based versions). It has one read-only tool, check_links (url, optional assets and max), which runs the same check as /api/check with the same limits: each call counts as one check. The tool returns the summary counts plus up to 25 problem links; use the JSON API for the full links[] list. Errors (blocked targets, rate limits) come back as tool results with isError: true and _meta["linkpulse/error"] = {code, httpStatus, retryAfterSeconds}.
{ "mcpServers": { "linkpulse": { "type": "http", "url": "https://linkpulse.mike-tusa.workers.dev/mcp" } } }
Machine-readable docs: llms.txt · OpenAPI 3.1 · server card.
GitHub Action
Save this as .github/workflows/linkpulse-check.yml. It calls the API with curl, prints a summary with jq, annotates broken links, and fails the job if any link is broken. It only needs curl and jq, both preinstalled on ubuntu-latest. Raw file: /github-action.yml.
# LinkPulse broken-link check for GitHub Actions.
# Copy to .github/workflows/linkpulse-check.yml in your repo. Needs only curl + jq (both preinstalled on ubuntu-latest).
# Checks ONE page per API call (up to 200 links, ~45 s max). The free anonymous API allows 20 checks per hour per IP
# and has a shared daily capacity; GitHub-hosted runners share IPs, so a 429 is possible. This workflow retries once
# after Retry-After when the wait is 2 minutes or less.
name: LinkPulse link check
on:
workflow_dispatch:
push:
branches: [main]
jobs:
links:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
LINKPULSE_API: https://linkpulse.mike-tusa.workers.dev/api/check
# Space-separated list of pages to check (each one uses one API call).
PAGES: "https://example.com/"
# Set to "true" to also fail on redirect chains of 3+ hops.
FAIL_ON_LONG_CHAINS: "false"
# "true" = don't fail on links that answered 401/403/429/999 (often bot protection, not a dead link).
IGNORE_BOT_BLOCKED: "false"
steps:
- name: Check links with LinkPulse
shell: bash
run: |
set -euo pipefail
failed=0
for page in $PAGES; do
echo "::group::LinkPulse: $page"
for attempt in 1 2; do
code=$(curl -sS -o result.json -D headers.txt -w '%{http_code}' --max-time 120 \
--get --data-urlencode "url=$page" "$LINKPULSE_API")
if [ "$code" = "429" ] && [ "$attempt" = "1" ]; then
wait=$(grep -i '^retry-after:' headers.txt | tr -dc '0-9'); wait=${wait:-60}
if [ "$wait" -le 120 ]; then echo "Rate limited, retrying in ${wait}s"; sleep "$wait"; continue; fi
fi
break
done
if [ "$code" != "200" ]; then
echo "::error::LinkPulse returned HTTP $code for $page: $(jq -r '.error.code + ": " + .error.message' result.json 2>/dev/null || cat result.json)"
failed=1; echo "::endgroup::"; continue
fi
jq -r '"checked=\(.summary.checked) ok=\(.summary.ok) redirected=\(.summary.redirected) broken=\(.summary.broken) blocked=\(.summary.blocked) unchecked=\(.summary.unchecked) mixedContent=\(.summary.mixedContent) truncated=\(.truncated)"' result.json
BROKEN='[.links[] | select(.category == "broken") | select($ign != "true" or ((.flags | index("maybe_bot_blocked")) | not))]'
jq -r --arg ign "$IGNORE_BOT_BLOCKED" "$BROKEN"' | .[] | "::error title=Broken link::\(.status // "-") \(.error.code) \(.url)"' result.json
jq -r '.links[] | select(.flags | index("long_chain")) | "::warning title=Long redirect chain::\(.redirects) redirects \(.url)"' result.json
jq -r '.links[] | select(.flags | map(startswith("mixed_content")) | any) | "::warning title=Mixed content::\(.url)"' result.json
if [ "$(jq '.truncated' result.json)" = "true" ]; then echo "::notice::Result truncated: $(jq -c '.truncatedReasons' result.json)"; fi
broken=$(jq --arg ign "$IGNORE_BOT_BLOCKED" "$BROKEN | length" result.json)
longc=$(jq '.summary.longChains' result.json)
if [ "$broken" -gt 0 ]; then failed=1; fi
if [ "$FAIL_ON_LONG_CHAINS" = "true" ] && [ "$longc" -gt 0 ]; then failed=1; fi
echo "::endgroup::"
done
exit "$failed"
How targets are restricted (SSRF guard)
- Only
http:/https:, ports 80/443, nouser:pass@. - IP literals in private, loopback, link-local, CGNAT, multicast, documentation or reserved ranges are refused. So are cloud metadata addresses (169.254.169.254, 100.100.100.200, fd00:ec2::254, 168.63.129.16), in IPv4 and IPv6 form, including IPv4-mapped, NAT64 and 6to4 forms. Decimal, hex and octal encodings (
2130706433,0x7f000001,0177.0.0.1,127.1) are normalized first. localhost,*.local,*.internal,*.localhost, other special-use names, single-label hosts, wildcard-DNS services such as*.nip.io/*.sslip.io, and LinkPulse's own host are refused.- Every redirect hop is checked again before it's fetched.
- DNS check on every request: Workers have no DNS API, so before each request (the page, every link and every redirect hop) LinkPulse looks up the host's A and AAAA records over DNS-over-HTTPS (Cloudflare, falling back to Google). If any answer is non-public, the request isn't made. Lookups are cached per host for one check only. If the lookup fails, the link is reported
uncheckedand not fetched. - Remaining limitation: DNS can change between our lookup and Cloudflare's own resolution at fetch time (DNS rebinding with a very short TTL). Cloudflare's network is a second layer here: outbound
fetch()leaves from Cloudflare's edge, not from a private network. That's defense in depth, not a guarantee.