Extract PDF links
Seven annotations from a PDF reader come back as the four real web links, each with its page number, while comments, highlights and internal page jumps are left out. A link repeated on three pages appears three times. Free in your browser.
- Free
- Runs in your browser
- No sign-up
- Your data stays on your device
Run — free
Runs in your browser. Free, unlimited — your data never leaves this page.
The PDF link extractor reads the per-page annotation list of a PDF — exactly what a reader library such as pdf.js hands back from getAnnotations — and returns every hyperlink it finds, each one with its page number and target URL. It is the missing step between 'I have the annotations' and 'I know where this document points': one deterministic pass, no rendering, no OCR, no guessing. Internal-only links and non-link annotations are filtered out, so what you get back is a clean, ordered list of the external references the document makes, ready for an audit, a crawler seed list or a compliance check.
Why annotations, not raw text
Finding links by scanning extracted text is unreliable: a visible URL in the text may not be clickable, and a clickable link may show anchor text that looks nothing like its target. The authoritative source of truth for extract links from pdf work is the annotation layer. PDF readers store interactive elements as annotations attached to a page, and hyperlinks live there as entries of subtype Link with a URI.
This capability takes that list as input — one record per annotation with its page number, subtype and optional URI — and filters it down to the actual outbound hyperlinks. Because the page number travels with every annotation, each result tells you exactly where in the document the link sits, which is what makes the output usable for reviews and audits rather than just a bare list of URLs.
What the extractor keeps and what it drops
The filter is deliberately strict. Only annotations whose subtype is Link are considered; comments, highlights, form widgets and every other annotation kind are skipped. Among Link annotations, only those carrying a non-empty URI survive — a link annotation without a URI is typically an internal destination jump to another page, which is navigation inside the document, not a hyperlink to an external target, so it is left out.
URIs are trimmed of surrounding whitespace but otherwise returned verbatim: no rewriting, no resolution, no deduplication. If the same URL appears on three pages, you get three entries, because the fact that it appears three times is information. The output is ordered exactly as the annotations arrive, which in practice means document order.
The same URL may legitimately appear several times in a long document — in the running footer, in a bibliography and in a contact page — and collapsing those occurrences would erase the distinction between one citation and a systematic reference, so the extractor keeps them all and lets the caller decide what to aggregate downstream.
Where it fits in a pipeline
The typical flow is: open the PDF with a reader library, collect annotations page by page, flatten them into the record list this capability expects, and post them. You get back a count and an array of {page, url} pairs. Teams use it to inventory the outbound references of contracts and reports before publication, to build the seed list for a link-checking pass, or to confirm that a redacted document no longer points anywhere it should not.
An empty annotation list is treated as an error rather than an empty result, because an empty list almost always means the extraction step upstream failed or the file never had an annotation layer at all — and silently returning zero links would hide that.
What you can do with it
Audit outbound references before publishing
Inventory every hyperlink a report or contract makes, with page numbers, so reviewers can confirm each target is intentional and correct.
Seed a broken-link check
Feed the extracted URLs into a link checker to catch dead destinations in a document your clients are about to download.
Verify a redacted document
Confirm that the published version of a PDF no longer links to internal staging or partner URLs that should have been removed.
FAQ
What input does it expect?
A flattened annotation list: one record per annotation with its 1-based page number, its subtype and, for links, its URI. Reader libraries like pdf.js produce this from getAnnotations with a small mapping step.
Why do I get an error instead of an empty list?
An empty annotation array means the upstream extraction failed or the PDF has no annotation layer.
Are internal page jumps included?
No. Link annotations without a URI are internal destinations inside the document, not outbound hyperlinks, and are skipped.
Are duplicate URLs deduplicated?
No. If the same URL is linked on three pages you get three entries, because each occurrence carries its own page number and that repetition is itself useful information.
Does it fetch or validate the URLs?
No. The extraction is purely local and deterministic; no network requests are made against the targets.
Related tools
For developers — API access
Everything on this page is available programmatically. This section is for teams who want to wire it into their own systems; everyone else can just use the tool above.
API endpoint
Prefer to automate it? One authenticated POST creates the task; the result comes back by webhook or a signed link. The same capability also runs here on the web, by email and from Telegram — and soon from our app too.
Call it from your stack
curl -X POST https://api.kit.forhosting.com/pdf/extract-links \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"annotations":[{"page":1,"subtype":"Link","uri":"https://example.com/docs"},{"page":1,"subtype":"Text","uri":null},{"page":3,"subtype":"Link","uri":"https://example.com/contact"}]}'const res = await fetch("https://api.kit.forhosting.com/pdf/extract-links", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"annotations": [
{
"page": 1,
"subtype": "Link",
"uri": "https://example.com/docs"
},
{
"page": 1,
"subtype": "Text",
"uri": null
},
{
"page": 3,
"subtype": "Link",
"uri": "https://example.com/contact"
}
]
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/pdf/extract-links",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"annotations": [
{
"page": 1,
"subtype": "Link",
"uri": "https://example.com/docs"
},
{
"page": 1,
"subtype": "Text",
"uri": null
},
{
"page": 3,
"subtype": "Link",
"uri": "https://example.com/contact"
}
]
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/pdf/extract-links", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"annotations":[{"page":1,"subtype":"Link","uri":"https://example.com/docs"},{"page":1,"subtype":"Text","uri":null},{"page":3,"subtype":"Link","uri":"https://example.com/contact"}]}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"annotations":[{"page":1,"subtype":"Link","uri":"https://example.com/docs"},{"page":1,"subtype":"Text","uri":null},{"page":3,"subtype":"Link","uri":"https://example.com/contact"}]}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/pdf/extract-links", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"annotations": [
{
"page": 1,
"subtype": "Link",
"uri": "https://example.com/docs"
},
{
"page": 1,
"subtype": "Text",
"uri": null
},
{
"page": 3,
"subtype": "Link",
"uri": "https://example.com/contact"
}
]
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "pdf.extract_links",
"status": "queued",
"_links": {
"result": "/tasks/tsk_…/result"
}
}The API is asynchronous: the call returns a task_id immediately and the result arrives by webhook. Polling is capped at 1 req/s per task.
Pricing
Published price — no tokens, no invented credits. A failed task is never charged.
Limits
max_mb | 1 |
max_annotations | 50000 |
Errors
| HTTP | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or invalid API key. |
402 | insufficient_balance | Your balance doesn't cover the task price. |
404 | unknown_type | That task type doesn't exist. |
429 | rate_limited | Too many requests. Use the webhook instead of polling. |
Technical notes
Why annotations, not raw text
The contract also validates every record as it goes: a missing page number, a non-integer page or an absent subtype stops the call with a clear invalid_input error instead of silently producing a partial answer you would be tempted to trust.
Where it fits in a pipeline
The same code runs free in your browser on this page, so you can validate a payload by pasting it and only pay when you automate the call.
Technical questions
What does it cost?
$0.002 per request. It is also free to run in your browser on this page.