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 & pathDescription
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/checkSame, with a JSON body {"url": "...", "assets": true, "max": 200} (or form-encoded). The body can be at most 8 KB.
GET /api/healthService 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"]
}
categoryMeaning
ok2xx/3xx final response with no redirects.
redirectEnds in a non-error response after one or more redirects (see hops).
brokenFinal 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.
blockedThe 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.
uncheckedNot 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.
flagMeaning
mixed_content_activeHTTPS page loads an http:// script, stylesheet, iframe, embed or other <link> resource. Browsers block these.
mixed_content_passiveHTTPS page loads an http:// image, video, audio or track.
insecure_linkHTTPS page has a plain http:// hyperlink.
https_downgradeA redirect hop goes from https to http.
long_chain3 or more redirects.
redirect_loop / too_many_redirectsLoop detected, or more than 10 hops.
maybe_bot_blockedFinal 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_supportedHEAD returned an error but GET worked.
blocked_redirectA redirect pointed to a blocked target and was not followed.
resolves_privateThe 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_skippedHEAD 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"}.

HTTPcodeWhen
400invalid_url, invalid_scheme, credentials_in_url, invalid_requestBad input
403blocked_target, blocked_port, blocked_redirectThe 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.
422dns_not_foundThe page host doesn't resolve.
429rate_limitedOver 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/504page_fetch_failed, page_timeout, redirect_loop, too_many_redirects, bad_redirect, dns_errorThe 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)