# Broken Link Checker
Source: https://docs.pocketutils.com/api-reference/broken-links
POST /v1/broken-links
Scan a single page, or crawl an entire site, for dead links with exact HTTP status codes and reasons.
Three endpoints, sharing one link-checking engine: scan one page synchronously, or start a site-wide crawl (via `sitemap.xml` and internal links) and poll it to completion. Discovered links are resolved and classified by severity. HTTP(S) destinations are checked; other link types receive an informational or diagnostic assessment.
**2 credits** per call (single-page scan or crawl start)
## Scan a single page
`POST /v1/broken-links` discovers links on one page, assesses up to 80 unique link records, and returns the completed response in the same request.
### Body parameters
The page to scan. `https://` is assumed if the scheme is omitted.
### Response
Echoes the scanned URL.
ISO 8601 timestamp.
Total links discovered on the page.
How many were actually checked (capped for performance).
`true` if the page had more links than the scan cap.
0–100.
Letter grade derived from `healthScore`.
Links that returned a healthy status.
Links with a non-fatal issue (e.g. a redirect).
Links that are dead.
Links that were noted but not scored (e.g. a non-HTTP scheme).
Links to the same site.
Links to a different site.
One row per distinct status/error code seen.
Status code as a string (`"404"`) or a network error code (`"ENOTFOUND"`).
Short human label, e.g. `"Not Found"`.
`"ok"`, `"warning"`, `"broken"`, or `"info"`.
How many links had this code.
Resolved absolute URL.
Original href after trimming surrounding whitespace.
Anchor text.
`"internal"`, `"external"`, `"mailto"`, `"tel"`, `"anchor"`, or `"other"`.
The `rel` attribute, if any.
HTTP status, or `null` when there was no response.
HTTP status text, or the network error label.
`"ok"`, `"warning"`, `"broken"`, or `"info"`.
URL-specific explanation of the outcome.
Grouping label, e.g. `"Client Error (4xx)"`, `"Network Error"`.
`true` if the link responded with a 3xx redirect.
Redirect target, when a Location header is available.
Round-trip time, present when a response was received.
Low-level network error, e.g. `"ENOTFOUND"`, when applicable.
```bash cURL theme={null}
curl -X POST https://api.pocketutils.com/v1/broken-links \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
```
```javascript JavaScript theme={null}
const res = await fetch("https://api.pocketutils.com/v1/broken-links", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com" }),
});
const { data } = await res.json();
```
```json Response (abridged) theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"scannedAt": "2026-07-21T09:14:02.000Z",
"totalLinks": 42,
"checkedLinks": 42,
"truncated": false,
"healthScore": 92,
"grade": "A+",
"summary": { "ok": 37, "warnings": 2, "broken": 3, "info": 0, "internal": 30, "external": 12 },
"statusBreakdown": [
{ "code": "200", "label": "OK", "severity": "ok", "count": 37 },
{ "code": "404", "label": "Not Found", "severity": "broken", "count": 3 }
],
"links": [
{
"url": "https://example.com/old-page",
"originalHref": "/old-page",
"text": "Learn more",
"type": "internal",
"rel": null,
"statusCode": 404,
"statusText": "Not Found",
"severity": "broken",
"reason": "The page no longer exists at this URL.",
"category": "Client Error (4xx)",
"redirected": false,
"responseTimeMs": 182
}
]
}
}
```
***
## Crawl an entire site
Use the crawl API to discover internal pages and check the unique links found across them. The result includes link outcomes, source pages, page fetch results, and a health score for the assessed links.
| Action | Method and path | Success response | Credits |
| - | - | - | - |
| Start a link audit | `POST /v1/broken-links/crawl` | `202` and a job ID | 2 |
| Get progress or results | `GET /v1/broken-links/crawl/{jobId}` | `200` and a job snapshot | 0 |
The base URL is `https://api.pocketutils.com`. Replace `{jobId}` with `data.jobId` from the start response; do not include the braces.
### Authentication and credits
Use `Authorization: Bearer YOUR_API_KEY` or `X-API-Key: YOUR_API_KEY` on API requests. API-key callers do not need a Turnstile token. Website callers using a session cookie or the anonymous allowance must provide `X-Turnstile-Token` when starting a crawl. Polling does not require Turnstile.
The start request is billed when it returns `202`. Polling costs **0 credits**, but every poll counts against your requests-per-minute limit. A later background job failure does not automatically refund an accepted start request. See [Authentication](/authentication) and [Rate Limits & Credits](/rate-limits-and-credits).
### Start request
Send `Content-Type: application/json` and a JSON object with the following field:
| Field | Type | Required | Description |
| - | - | - | - |
| `url` | string | Yes | Entry page on the website. Prefer a complete `https://` or `http://` URL. Whitespace is trimmed; `https://` is added when the scheme is omitted. |
The request supports only `url`. Page limits, timeouts, link caps, custom headers, and crawl depth are not configurable through this endpoint. Use the site root for a general audit. A URL under `/docs` does **not** restrict the crawl to that path.
A successful start returns **`202 Accepted`** with `success: true` and `data.jobId`, a UUID string. Save this ID and poll the matching tool's status endpoint. The response contains no results yet.
Each start creates a new job. Repeating the POST creates another job and can incur another charge; reuse the existing job ID to check progress.
### Start example
```bash theme={null}
curl -i -X POST "https://api.pocketutils.com/v1/broken-links/crawl" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
```
```json 202 Accepted theme={null}
{
"success": true,
"data": {
"jobId": "c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d"
}
}
```
### Get progress and results
`GET /v1/broken-links/crawl/{jobId}` takes one required path parameter:
| Parameter | Type | Description |
| - | - | - |
| `jobId` | string | The job ID returned by `POST /v1/broken-links/crawl`. Link Extractor job IDs are not interchangeable. |
No request body or query parameters are required. The completed result is returned by this same endpoint; there is no separate result download or pagination endpoint.
```bash theme={null}
curl "https://api.pocketutils.com/v1/broken-links/crawl/c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY"
```
#### Job response fields
| Field | Type | Description |
| - | - | - |
| `success` | boolean | `true` when the job lookup succeeds. |
| `data.jobId` | string | Requested job ID. |
| `data.status` | string | `queued`, `sitemap`, `crawling`, `checking`, `done`, or `error`. |
| `data.progress` | object | Latest counters, described below. |
| `data.result` | object, optional | Site audit result when `status` is `done`. |
| `data.error` | string, optional | Failure message when `status` is `error`. |
#### Phases
| Phase | Meaning | Client action |
| - | - | - |
| `queued` | Job created; preparing discovery. | Keep polling. |
| `sitemap` | Sitemap discovery phase. | Keep polling. |
| `crawling` | Fetching internal pages and collecting unique links. | Keep polling. |
| `checking` | Assessing the selected links, reusing page fetches when possible. | Keep polling. |
| `done` | Audit finished; result available. | Save `data.result` and stop polling. |
| `error` | Background job failed. | Read `data.error` and stop polling. |
#### Progress fields
| Field under `data.progress` | Type | Description |
| - | - | - |
| `phase` | string | Same phase as `data.status`. |
| `message` | string | Human-readable message; use `phase` or `status` for program logic. |
| `sitemapFound` | boolean | Whether a fetchable sitemap candidate was found; it can be `true` with zero eligible URLs. |
| `sitemapUrlCount` | integer | Eligible internal sitemap URLs retained, up to 400. |
| `pagesCrawled` | integer | Pages fetched and recorded, including pages without usable HTML. |
| `pagesDiscovered` | integer | Unique page URLs known, including queued pages; can increase during discovery. |
| `pagesCap` | integer | Fixed page cap: 250. |
| `linksFound` | integer | All unique discovered link records, including anchors and non-HTTP schemes. |
| `linksChecked` | integer | Link records assessed so far in `checking`; updated in batches of ten and finalized at `done`. |
| `linksTotal` | integer | Selected link records to assess, up to 1,500. Starts at zero before checking begins. |
These counters are snapshots. `linksChecked` includes informational assessments, not just HTTP requests. Do not calculate a stable overall percentage from changing page-discovery counts.
```json 200 OK — checking theme={null}
{
"success": true,
"data": {
"jobId": "c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d",
"status": "checking",
"progress": {
"phase": "checking",
"message": "Checking discovered links…",
"sitemapFound": true,
"sitemapUrlCount": 58,
"pagesCrawled": 58,
"pagesDiscovered": 58,
"pagesCap": 250,
"linksFound": 340,
"linksChecked": 120,
"linksTotal": 340
}
}
}
```
### Completed result fields
The crawl result differs from the single-page response: it uses `totalUniqueLinks`, `truncatedPages`, and `truncatedLinks`, and adds a `pages` array. Do not expect `totalLinks` or `truncated` in a crawl result.
| Field under `data.result` | Type | Description |
| - | - | - |
| `url` | string | Normalized entry URL. |
| `scannedAt` | string | ISO 8601 UTC timestamp when the result was assembled. |
| `sitemapFound` | boolean | Whether sitemap discovery found a fetchable candidate. |
| `sitemapUrlCount` | integer | Retained eligible sitemap URLs. |
| `pagesCrawled` | integer | Recorded page fetches; equals `pages.length`. |
| `pagesDiscovered` | integer | Unique page URLs known, including those not fetched. |
| `totalUniqueLinks` | integer | All unique links found across recorded HTML pages, before the assessment cap. |
| `checkedLinks` | integer | Assessed link records; equals `links.length` and includes non-HTTP/anchor assessments. |
| `truncatedPages` | boolean | `true` if queued pages were left unfetched because the page cap or page crawl budget stopped traversal. |
| `truncatedLinks` | boolean | `true` if more than 1,500 unique links were found and some were omitted from assessment. |
| `healthScore` | integer | Score from 0 to 100, calculated from the returned link severity counts. |
| `grade` | string | Letter grade derived from `healthScore`. |
| `summary` | object | Counts across assessed links only. |
| `statusBreakdown` | array | Counts grouped by HTTP status or network/scheme code. |
| `links` | array | Up to 1,500 assessed unique links, sorted by severity. |
| `pages` | array | Recorded page fetches, sorted by severity. |
#### Summary and status breakdown
`summary` contains integer fields `ok`, `warnings`, `broken`, `info`, `internal`, and `external`. The four severity counts sum to `checkedLinks`. `internal` and `external` count link types, so their sum may be smaller than `checkedLinks` when anchors or non-HTTP schemes are present.
Every `statusBreakdown[]` item contains:
| Field | Type | Description |
| - | - | - |
| `code` | string | HTTP status such as `404`, a network error such as `ENOTFOUND`, or a scheme grouping for non-HTTP links. |
| `label` | string | Human-readable status or error label. |
| `severity` | string | `ok`, `warning`, `broken`, or `info`. |
| `count` | integer | Assessed link records in this group. |
#### Link fields
| Field under `data.result.links[]` | Type | Description |
| - | - | - |
| `url` | string | Resolved HTTP(S) destination, or original href for an anchor or non-HTTP scheme. |
| `originalHref` | string | Original href after trimming surrounding whitespace. |
| `text` | string | Normalized anchor text, at most 120 characters; accessible labels provide fallbacks. |
| `rel` | string or null | Trimmed `rel` attribute, or `null` when absent or empty. |
| `type` | string | `internal`, `external`, `mailto`, `tel`, `anchor`, or `other`. |
| `scheme` | string | Scheme without a colon; bare anchors use `anchor` and malformed hrefs can use `invalid`. |
| `statusCode` | integer or null | Received HTTP status, or `null` for a network failure or a link that was not requested over HTTP. |
| `statusText` | string | Status label or an informational/network error label. |
| `severity` | string | `ok`, `warning`, `broken`, or `info`. |
| `reason` | string | Explanation of the outcome. |
| `category` | string | Category such as `Success (2xx)`, `Client Error (4xx)`, `Network Error`, `Non-HTTP Scheme`, or `In-Page Anchor`. |
| `redirected` | boolean | Whether the request was classified as redirected. |
| `finalUrl` | string, optional | Redirect destination, when available; interpretation depends on how the link was assessed. |
| `responseTimeMs` | integer, optional | Duration of the recorded request in milliseconds, when available. |
| `errorCode` | string, optional | Transport error code, such as `ENOTFOUND`, when available. |
| `sources` | string array | Sample of at most 25 source page URLs containing the link. |
| `sourceCount` | integer | All distinct scanned source pages containing the link; may exceed the sample length. |
#### Page fields
| Field under `data.result.pages[]` | Type | Description |
| - | - | - |
| `url` | string | Canonical page URL requested by the crawler. |
| `statusCode` | integer or null | Page fetch HTTP status, or `null` on a transport failure. |
| `statusText` | string | HTTP or network status label. |
| `severity` | string | Severity of the page fetch result. |
| `linksOnPage` | integer | Unique discovered hyperlink records on this page before site aggregation, including anchors and other schemes. |
| `depth` | integer | Breadth-first discovery depth. Entry and sitemap-seeded pages have depth zero. |
| `fromSitemap` | boolean | `true` if the page was first queued from a sitemap URL. |
`pages` records page fetch outcomes, while `links` records discovered link destinations. A page can be in `pages` without appearing as a discovered link in `links`.
```json 200 OK — completed example theme={null}
{
"success": true,
"data": {
"jobId": "c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d",
"status": "done",
"progress": {
"phase": "done",
"message": "Crawl complete.",
"sitemapFound": false,
"sitemapUrlCount": 0,
"pagesCrawled": 2,
"pagesDiscovered": 2,
"pagesCap": 250,
"linksFound": 2,
"linksChecked": 2,
"linksTotal": 2
},
"result": {
"url": "https://example.com/",
"scannedAt": "2026-10-12T09:14:02.000Z",
"sitemapFound": false,
"sitemapUrlCount": 0,
"pagesCrawled": 2,
"pagesDiscovered": 2,
"totalUniqueLinks": 2,
"checkedLinks": 2,
"truncatedPages": false,
"truncatedLinks": false,
"healthScore": 50,
"grade": "F",
"summary": {
"ok": 1,
"warnings": 0,
"broken": 1,
"info": 0,
"internal": 1,
"external": 1
},
"statusBreakdown": [
{"code": "404", "label": "Not Found", "severity": "broken", "count": 1},
{"code": "200", "label": "OK", "severity": "ok", "count": 1}
],
"links": [
{
"url": "https://example.org/old-page",
"originalHref": "https://example.org/old-page",
"text": "Partner guide",
"rel": "noopener",
"type": "external",
"scheme": "https",
"statusCode": 404,
"statusText": "Not Found",
"severity": "broken",
"reason": "The page does not exist at this URL (404 Not Found).",
"category": "Client Error (4xx)",
"redirected": false,
"responseTimeMs": 182,
"sources": ["https://example.com/"],
"sourceCount": 1
},
{
"url": "https://example.com/pricing",
"originalHref": "/pricing",
"text": "Pricing",
"rel": null,
"type": "internal",
"scheme": "https",
"statusCode": 200,
"statusText": "OK",
"severity": "ok",
"reason": "The link is working correctly.",
"category": "Success (2xx)",
"redirected": false,
"responseTimeMs": 95,
"sources": ["https://example.com/", "https://example.com/pricing"],
"sourceCount": 2
}
],
"pages": [
{
"url": "https://example.com/",
"statusCode": 200,
"statusText": "OK",
"severity": "ok",
"linksOnPage": 2,
"depth": 0,
"fromSitemap": false
},
{
"url": "https://example.com/pricing",
"statusCode": 200,
"statusText": "OK",
"severity": "ok",
"linksOnPage": 1,
"depth": 1,
"fromSitemap": false
}
]
}
}
}
```
### How links are assessed
Already-fetched internal pages reuse their page fetch outcome. Other HTTP(S) destinations are checked with `HEAD`; if the server returns `400`, `403`, `405`, or `501`, the checker tries `GET`. Each direct check has an eight-second timeout. If the GET attempt fails at the transport layer, the original HEAD response is retained.
Direct link checks do not follow redirects. They report the initial 3xx response and resolve its `Location` header into `finalUrl` when available. Page fetches follow up to five redirects, so a reused page result can report the destination's HTTP status while `redirected` is `true` and `finalUrl` is the reached page. A redirect target in a direct check is not a guarantee that the target works.
| Severity | Interpretation |
| - | - |
| `ok` | Healthy outcome, generally a 2xx response. |
| `warning` | Review needed, such as a redirect, access restriction, throttling, or certain TLS/network issues. |
| `broken` | Failure such as a 404, 410, server error, invalid URL, or unreachable destination. |
| `info` | Recorded without HTTP verification, such as an in-page anchor, email link, or telephone link. |
Use the returned `severity` and `reason` instead of assigning severity from status-code class alone: some 4xx responses are warnings. Bare `#fragment` anchors are **not verified** during a site crawl. `mailto:` and `tel:` do not validate an email address or telephone number. `javascript:` links are warnings, malformed hrefs are broken, and other non-HTTP schemes are classified without an HTTP request.
#### Health score and grade
Only `ok`, `warnings`, and `broken` contribute to the score; `info` is excluded. Let `N = ok + warnings + broken`. For nonzero `N`:
```text theme={null}
healthScore = max(0, round((1 - (broken + 0.2 * warnings) / N) * 100))
```
When `N` is zero, the score is 100. The grade thresholds are:
| Score | Grade |
| - | - |
| 90–100 | A+ |
| 85–89 | A |
| 80–84 | B+ |
| 75–79 | B |
| 70–74 | C+ |
| 65–69 | C |
| 60–64 | D |
| 0–59 | F |
The score and summary cover only the returned, assessed link subset. A high score does not establish that omitted links or unvisited pages are healthy.
### How the site is crawled
The crawler looks for sitemap locations in `robots.txt` and at `/sitemap.xml`. It expands one level of sitemap indexes, then scans the entry page and follows discovered internal page links breadth-first. A missing sitemap does not prevent crawling.
Internal links are classified by hostname, ignoring a leading `www.`. Other subdomains count as external. External destinations are collected but are not traversed to discover more pages. PDFs, images, archives, and other recognized asset extensions are not traversed as HTML pages.
The crawler reads `` elements from the fetched HTML. It does not execute page JavaScript, so links added only after client-side rendering may be absent. It does not forward your PocketUtils API key or browser session to the target website.
Pages are deduplicated after removing URL fragments and trailing path slashes. Link records are deduplicated by scheme and resolved URL; different query strings or HTTP link fragments can remain separate records. Repeated occurrences on one page do not increase `sourceCount`. Link metadata represents one discovered occurrence, rather than every variation of its text or `rel` attribute.
### Crawl and result limits
| Setting | Value | Meaning |
| - | - | - |
| Pages | 250 | Maximum internal page fetches, including the entry page. |
| Page crawl budget | 6 minutes | Budget for following queued pages, after sitemap discovery and the entry fetch. In-flight requests may finish afterward. |
| Sitemap page URLs | 400 | Maximum retained eligible sitemap URLs. This does not increase the page cap. |
| Sitemap candidates / child maps | 15 each | Up to 15 sitemap candidates and up to 15 child maps; indexes are expanded one level. |
| Concurrent page fetches | 6 | Number of pages fetched at once. |
| Page request timeout | 15 seconds | Per page fetch. |
| Sitemap request timeout | 10 seconds | Per sitemap or robots request. |
| Source page sample | 25 per link | `sourceCount` includes all discovered source pages even if `sources` is capped. |
These limits are fixed by the service. The six-minute page budget is not a promise that the whole job finishes within six minutes.
The checker assesses at most **1,500** unique link records, with up to **25** concurrent direct link checks. The cap includes anchors and non-HTTP schemes. Links beyond the cap are not represented in `links`, `summary`, `statusBreakdown`, or `healthScore`.
`truncatedPages` means queued pages remained when traversal stopped, including stops caused by the time budget. `truncatedLinks` means the assessment cap excluded discovered links. A job can finish with `status: done` and either flag set; interpret its results as a partial audit.
### Retention and polling behavior
Job state is held in memory. Jobs become eligible for removal **30 minutes after their last progress, result, or error update**; cleanup runs every five minutes. Reading a job does not refresh retention. A server restart can also lose a job. Store the completed result in your application if you need it later.
Poll about every **two seconds** for a single job, and use a longer interval when polling several jobs or sharing a small request allowance. Phases can change between polls, so you do not need to observe every phase. Stop on `done` or `error`. See [Async Jobs & Polling](/async-jobs#complete-polling-example) for complete JavaScript and Python examples.
A known job returns `200 OK` even when `data.status` is `error`. The envelope's `success: true` means the status lookup succeeded; inspect `data.status` to determine whether the crawl succeeded. `result` and `error` are omitted until applicable, rather than sent as `null`.
Request fresh status snapshots and avoid caching them in your client. The current status handlers do not explicitly set `Cache-Control: no-store`.
## Errors
| HTTP status | Applies to | Meaning and action |
| - | - | - |
| `400` | Crawl start | Missing, non-string, or invalid `url`. Correct the request. |
| `401` | Start or poll | Supplied API key is invalid or revoked. Check your credentials. |
| `402` | Crawl start | Insufficient credits for the 2-credit start request. |
| `403` | Crawl start | Website caller omitted a valid Turnstile token. API-key callers bypass this check. |
| `404` | Status lookup | Job is unknown, expired, lost after restart, or belongs to Link Extractor. |
| `429` | Start or poll | Request rate exceeded; slow down. Anonymous start requests can also exhaust the free credit allowance. |
Validation and missing-job errors use a string `error`. Platform authentication, credit, and rate-limit errors use an `error` object with `code` and `message`. Handle both shapes; see [Errors](/errors).
```json 400 Bad Request — missing URL theme={null}
{
"success": false,
"error": "URL is required and must be a string"
}
```
```json 404 Not Found theme={null}
{
"success": false,
"error": "Crawl job not found or expired."
}
```
A fatal background failure is reported by the status endpoint with **HTTP 200**:
```json 200 OK — failed job theme={null}
{
"success": true,
"data": {
"jobId": "c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d",
"status": "error",
"progress": {
"phase": "error",
"message": "The URL did not return an HTML page to crawl.",
"sitemapFound": false,
"sitemapUrlCount": 0,
"pagesCrawled": 0,
"pagesDiscovered": 0,
"pagesCap": 250,
"linksFound": 0,
"linksChecked": 0,
"linksTotal": 0
},
"error": "The URL did not return an HTML page to crawl."
}
}
```
An individual broken destination normally appears in the completed `result.links` array; it does not fail the whole job. Stop polling on a job error, address its cause, then start a new crawl if needed.
# Down Checker
Source: https://docs.pocketutils.com/api-reference/down-check
GET /v1/down-check
Check whether a site is reachable, with HTTP status, latency, and resolved IP.
Makes a single HTTP(S) request to the URL (following up to 5 redirects) and reports whether it responded, with what status, how long it took, and which IP it resolved to. The request is made from PocketUtils' servers in India, so results reflect reachability from that vantage point.
**1 credit** per call
## Query parameters
The URL to check. `https://` is assumed if the scheme is omitted.
## Response
The normalized URL that was tested.
Always `"India (Mumbai)"`.
The HTTP status code returned, including 4xx/5xx — any status counts as "reachable." `null` only when the request couldn't complete at all (DNS failure, connection refused, timeout).
Round-trip time. `null` only when the hostname never resolved.
Resolved IPv4 address (falls back to whatever DNS returns if no A record exists). `null` if DNS resolution failed.
A `null` `httpsStatus` with a non-null `ipAddress` means DNS resolved but the connection itself failed or timed out (15s timeout) — that's a genuinely down host, not a bad request. A `null` `ipAddress` means the domain doesn't resolve at all.
## Errors
| Status | Cause |
| - | - |
| `400` | `url` query parameter missing |
A target that's actually down is **not** an error — you get a `200` with `httpsStatus: null`. See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl "https://api.pocketutils.com/v1/down-check?url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch(
"https://api.pocketutils.com/v1/down-check?url=https://example.com",
{ headers: { Authorization: "Bearer YOUR_API_KEY" } },
);
const { data } = await res.json();
```
```python Python theme={null}
import requests
res = requests.get(
"https://api.pocketutils.com/v1/down-check",
params={"url": "https://example.com"},
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]
```
```json Site is up theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"testedFrom": "India (Mumbai)",
"httpsStatus": 200,
"responseTimeMs": 214,
"ipAddress": "93.184.216.34"
}
}
```
```json Site is down theme={null}
{
"success": true,
"data": {
"url": "https://this-domain-does-not-resolve.example",
"testedFrom": "India (Mumbai)",
"httpsStatus": null,
"responseTimeMs": null,
"ipAddress": null
}
}
```
# Link Extractor
Source: https://docs.pocketutils.com/api-reference/link-extract
GET /v1/link-extract
Extract every link from a page or an entire site, grouped by type, with anchor text and rel.
Three endpoints, sharing the Broken Link Checker's crawl engine but never checking HTTP status — this tool only reports each link's metadata (anchor text, `rel`, type) and counts by type. Use it to audit outbound links, find every `mailto:`/`tel:` on a site, or feed a link graph.
**1 credit** per call (single-page extract or crawl start)
## Extract from a single page
`GET /v1/link-extract?url=...` extracts every link on one page and returns immediately.
### Query parameters
The page to extract links from. `https://` is assumed if the scheme is omitted.
### Response
Resolved page URL after any redirects.
ISO 8601 timestamp.
Same as `counts.total`.
`true` if the page had more than 5,000 links.
All reported unique links, including `mailto:` and `tel:`, before the returned-array cap. Excludes bare in-page anchors and other schemes.
Links to the same site.
Links to a different site.
`mailto:` links.
`tel:` links.
In-page anchors (`#...`) and other schemes (`javascript:`, `data:`, ...) are excluded. `mailto:` and `tel:` links are included.
Resolved absolute URL (or the raw href for non-HTTP schemes).
Original href after trimming surrounding whitespace.
Anchor text.
The `rel` attribute, e.g. `"nofollow noopener"`.
`"internal"`, `"external"`, `"mailto"`, or `"tel"`.
URL scheme without the colon, e.g. `"https"`, `"mailto"`.
```bash cURL theme={null}
curl "https://api.pocketutils.com/v1/link-extract?url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch(
"https://api.pocketutils.com/v1/link-extract?url=https://example.com",
{ headers: { Authorization: "Bearer YOUR_API_KEY" } },
);
const { data } = await res.json();
```
```json Response (abridged) theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"scannedAt": "2026-07-21T09:14:02.000Z",
"totalLinks": 64,
"truncated": false,
"counts": { "total": 64, "internal": 48, "external": 12, "mailto": 3, "tel": 1 },
"links": [
{
"url": "https://example.com/pricing",
"originalHref": "/pricing",
"text": "Pricing",
"rel": null,
"type": "internal",
"scheme": "https"
},
{
"url": "mailto:hello@example.com",
"originalHref": "mailto:hello@example.com",
"text": "hello@example.com",
"rel": null,
"type": "mailto",
"scheme": "mailto"
}
]
}
}
```
***
## Extract from an entire site
Use the crawl API when you need links from multiple pages. It returns unique internal, external, `mailto:`, and `tel:` links, with the pages where each was found. It does not check whether link destinations work.
| Action | Method and path | Success response | Credits |
| - | - | - | - |
| Start extraction | `POST /v1/link-extract/crawl` | `202` and a job ID | 1 |
| Get progress or results | `GET /v1/link-extract/crawl/{jobId}` | `200` and a job snapshot | 0 |
The base URL is `https://api.pocketutils.com`. Replace `{jobId}` with `data.jobId` from the start response; do not include the braces.
### Authentication and credits
Use `Authorization: Bearer YOUR_API_KEY` or `X-API-Key: YOUR_API_KEY` on API requests. API-key callers do not need a Turnstile token. Website callers using a session cookie or the anonymous allowance must provide `X-Turnstile-Token` when starting a crawl. Polling does not require Turnstile.
The start request is billed when it returns `202`. Polling costs **0 credits**, but every poll counts against your requests-per-minute limit. A later background job failure does not automatically refund an accepted start request. See [Authentication](/authentication) and [Rate Limits & Credits](/rate-limits-and-credits).
### Start request
Send `Content-Type: application/json` and a JSON object with the following field:
| Field | Type | Required | Description |
| - | - | - | - |
| `url` | string | Yes | Entry page on the website. Prefer a complete `https://` or `http://` URL. Whitespace is trimmed; `https://` is added when the scheme is omitted. |
The request supports only `url`. Page limits, timeouts, link caps, custom headers, and crawl depth are not configurable through this endpoint. Use the site root for a general audit. A URL under `/docs` does **not** restrict the crawl to that path.
A successful start returns **`202 Accepted`** with `success: true` and `data.jobId`, a UUID string. Save this ID and poll the matching tool's status endpoint. The response contains no results yet.
Each start creates a new job. Repeating the POST creates another job and can incur another charge; reuse the existing job ID to check progress.
### Start example
```bash theme={null}
curl -i -X POST "https://api.pocketutils.com/v1/link-extract/crawl" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
```
```json 202 Accepted theme={null}
{
"success": true,
"data": {
"jobId": "a1b2c3d4-5678-4e9f-9a0b-1c2d3e4f5a6b"
}
}
```
### Get progress and results
`GET /v1/link-extract/crawl/{jobId}` takes one required path parameter:
| Parameter | Type | Description |
| - | - | - |
| `jobId` | string | The extraction job ID returned by `POST /v1/link-extract/crawl`. IDs from Broken Link Checker jobs are not interchangeable. |
No request body or query parameters are required. The completed result is returned by this same endpoint; there is no separate result download or pagination endpoint.
```bash theme={null}
curl "https://api.pocketutils.com/v1/link-extract/crawl/a1b2c3d4-5678-4e9f-9a0b-1c2d3e4f5a6b" \
-H "Authorization: Bearer YOUR_API_KEY"
```
#### Job response fields
| Field | Type | Description |
| - | - | - |
| `success` | boolean | `true` when the job lookup succeeds. |
| `data.jobId` | string | Requested job ID. |
| `data.status` | string | `queued`, `sitemap`, `crawling`, `done`, or `error`. |
| `data.progress` | object | Latest progress counters, described below. |
| `data.result` | object, optional | Present when `status` is `done`. |
| `data.error` | string, optional | Failure message when `status` is `error`. |
#### Phases
| Phase | Meaning | Client action |
| - | - | - |
| `queued` | Job created; preparing discovery. | Keep polling. |
| `sitemap` | Sitemap discovery phase. | Keep polling. |
| `crawling` | Fetching internal pages and collecting links. | Keep polling. |
| `done` | Extraction finished; result available. | Save `data.result` and stop polling. |
| `error` | Background extraction failed. | Read `data.error` and stop polling. |
There is no `checking` phase in Link Extractor.
#### Progress fields
| Field under `data.progress` | Type | Description |
| - | - | - |
| `phase` | string | Same phase as `data.status`. |
| `message` | string | Human-readable progress message; do not use it as a machine status. |
| `sitemapFound` | boolean | Whether sitemap discovery found a fetchable sitemap candidate; this does not guarantee eligible page URLs were found. |
| `sitemapUrlCount` | integer | Eligible internal sitemap URLs retained, up to 400. |
| `pagesScanned` | integer | Pages fetched and recorded so far, including pages without usable HTML. |
| `pagesDiscovered` | integer | Unique page URLs known so far, including queued pages. This can grow during the crawl. |
| `pagesCap` | integer | Fixed page cap: 250. |
| `linksFound` | integer | Unique links discovered before extractor filtering; may include anchors and other schemes excluded from the final result. |
These are counters, not a stable percentage or estimated completion time.
```json 200 OK — running theme={null}
{
"success": true,
"data": {
"jobId": "a1b2c3d4-5678-4e9f-9a0b-1c2d3e4f5a6b",
"status": "crawling",
"progress": {
"phase": "crawling",
"message": "Crawling internal pages…",
"sitemapFound": true,
"sitemapUrlCount": 58,
"pagesScanned": 22,
"pagesDiscovered": 58,
"pagesCap": 250,
"linksFound": 340
}
}
}
```
### Completed result fields
| Field under `data.result` | Type | Description |
| - | - | - |
| `url` | string | Normalized entry URL. |
| `scannedAt` | string | ISO 8601 UTC timestamp when the result was assembled. |
| `totalLinks` | integer | All unique reported links discovered across scanned pages; equals `counts.total`. |
| `truncated` | boolean | `true` if more than 3,000 reported links were discovered and the returned `links` array was capped. |
| `counts` | object | Counts across all reported unique links, including links omitted from the capped array. |
| `links` | array | Up to 3,000 reported links, sorted by type then URL within the returned subset. |
`counts` contains integer fields `total`, `internal`, `external`, `mailto`, and `tel`. The four type counts sum to `total`. Bare in-page anchors and `other` schemes are excluded; `mailto:` and `tel:` are included even though they are not HTTP links.
#### Link fields
| Field under `data.result.links[]` | Type | Description |
| - | - | - |
| `url` | string | Resolved HTTP(S) destination, or the original `mailto:` / `tel:` href. |
| `originalHref` | string | Original href after trimming surrounding whitespace; may be relative. |
| `text` | string | Whitespace-normalized anchor text, capped at 120 characters. Falls back to `aria-label`, `title`, image alt text, then `(no anchor text)`. |
| `rel` | string or null | Trimmed `rel` attribute, or `null` when absent or empty. |
| `type` | string | `internal`, `external`, `mailto`, or `tel`. |
| `scheme` | string | Scheme without the colon, such as `https`, `mailto`, or `tel`. |
| `sources` | string array | Up to 25 scanned source page URLs containing this link. |
| `sourceCount` | integer | Total distinct scanned source pages containing this link; may exceed `sources.length`. |
The crawl result contains links and counts only. It does not include a `pages` array, page totals, HTTP status codes, or a health score. Read page counters from `data.progress`. Use [Broken Link Checker](/api-reference/broken-links#crawl-an-entire-site) to check destinations.
```json 200 OK — completed example theme={null}
{
"success": true,
"data": {
"jobId": "a1b2c3d4-5678-4e9f-9a0b-1c2d3e4f5a6b",
"status": "done",
"progress": {
"phase": "done",
"message": "Extraction complete.",
"sitemapFound": false,
"sitemapUrlCount": 0,
"pagesScanned": 2,
"pagesDiscovered": 2,
"pagesCap": 250,
"linksFound": 2
},
"result": {
"url": "https://example.com/",
"scannedAt": "2026-10-12T09:14:02.000Z",
"totalLinks": 2,
"truncated": false,
"counts": {
"total": 2,
"internal": 1,
"external": 0,
"mailto": 1,
"tel": 0
},
"links": [
{
"url": "https://example.com/pricing",
"originalHref": "/pricing",
"text": "Pricing",
"rel": null,
"type": "internal",
"scheme": "https",
"sources": ["https://example.com/", "https://example.com/pricing"],
"sourceCount": 2
},
{
"url": "mailto:hello@example.com",
"originalHref": "mailto:hello@example.com",
"text": "Email us",
"rel": null,
"type": "mailto",
"scheme": "mailto",
"sources": ["https://example.com/"],
"sourceCount": 1
}
]
}
}
}
```
### How the site is crawled
The crawler looks for sitemap locations in `robots.txt` and at `/sitemap.xml`. It expands one level of sitemap indexes, then scans the entry page and follows discovered internal page links breadth-first. A missing sitemap does not prevent crawling.
Internal links are classified by hostname, ignoring a leading `www.`. Other subdomains count as external. External destinations are collected but are not traversed to discover more pages. PDFs, images, archives, and other recognized asset extensions are not traversed as HTML pages.
The crawler reads `` elements from the fetched HTML. It does not execute page JavaScript, so links added only after client-side rendering may be absent. It does not forward your PocketUtils API key or browser session to the target website.
Pages are deduplicated after removing URL fragments and trailing path slashes. Link records are deduplicated by scheme and resolved URL; different query strings or HTTP link fragments can remain separate records. Repeated occurrences on one page do not increase `sourceCount`. Link metadata represents one discovered occurrence, rather than every variation of its text or `rel` attribute.
### Crawl and result limits
| Setting | Value | Meaning |
| - | - | - |
| Pages | 250 | Maximum internal page fetches, including the entry page. |
| Page crawl budget | 6 minutes | Budget for following queued pages, after sitemap discovery and the entry fetch. In-flight requests may finish afterward. |
| Sitemap page URLs | 400 | Maximum retained eligible sitemap URLs. This does not increase the page cap. |
| Sitemap candidates / child maps | 15 each | Up to 15 sitemap candidates and up to 15 child maps; indexes are expanded one level. |
| Concurrent page fetches | 6 | Number of pages fetched at once. |
| Page request timeout | 15 seconds | Per page fetch. |
| Sitemap request timeout | 10 seconds | Per sitemap or robots request. |
| Source page sample | 25 per link | `sourceCount` includes all discovered source pages even if `sources` is capped. |
These limits are fixed by the service. The six-minute page budget is not a promise that the whole job finishes within six minutes.
The returned link array is capped at **3,000**. `totalLinks` and `counts` still cover all reported links discovered within the scanned pages. `truncated: false` does not prove the entire website was scanned: it describes only the link array cap. Link Extractor does not expose a separate page-truncation flag. Compare the page counters and treat results as an inventory of what this bounded crawl discovered.
### Retention and polling behavior
Job state is held in memory. Jobs become eligible for removal **30 minutes after their last progress, result, or error update**; cleanup runs every five minutes. Reading a job does not refresh retention. A server restart can also lose a job. Store the completed result in your application if you need it later.
Poll about every **two seconds** for a single job, and use a longer interval when polling several jobs or sharing a small request allowance. Phases can change between polls, so you do not need to observe every phase. Stop on `done` or `error`. See [Async Jobs & Polling](/async-jobs#complete-polling-example) for complete JavaScript and Python examples.
A known job returns `200 OK` even when `data.status` is `error`. The envelope's `success: true` means the status lookup succeeded; inspect `data.status` to determine whether the crawl succeeded. `result` and `error` are omitted until applicable, rather than sent as `null`.
Request fresh status snapshots and avoid caching them in your client. The current status handlers do not explicitly set `Cache-Control: no-store`.
## Errors
| HTTP status | Applies to | Meaning and action |
| - | - | - |
| `400` | Crawl start | Missing, non-string, or invalid `url`. Correct the request before retrying. |
| `401` | Start or poll | Supplied API key is invalid or revoked. Check your credentials. |
| `402` | Crawl start | Insufficient credits for the 1-credit start request. |
| `403` | Crawl start | Website caller omitted a valid Turnstile token. API-key callers bypass this check. |
| `404` | Status lookup | Extraction job is unknown, expired, lost after restart, or belongs to the other tool. |
| `429` | Start or poll | Request rate exceeded; slow down. Anonymous start requests can also exhaust the free credit allowance. |
Crawl validation and missing-job errors use a string `error`. Platform authentication, credit, and rate-limit errors use an `error` object with `code` and `message`. Handle both shapes; see [Errors](/errors).
```json 400 Bad Request — missing URL theme={null}
{
"success": false,
"error": "URL is required and must be a string"
}
```
```json 404 Not Found theme={null}
{
"success": false,
"error": "Extraction job not found or expired."
}
```
If the background crawl cannot obtain usable HTML from the entry page or encounters a fatal error, status polling can return a job failure with **HTTP 200**:
```json 200 OK — failed job theme={null}
{
"success": true,
"data": {
"jobId": "a1b2c3d4-5678-4e9f-9a0b-1c2d3e4f5a6b",
"status": "error",
"progress": {
"phase": "error",
"message": "The URL did not return an HTML page to crawl.",
"sitemapFound": false,
"sitemapUrlCount": 0,
"pagesScanned": 0,
"pagesDiscovered": 0,
"pagesCap": 250,
"linksFound": 0
},
"error": "The URL did not return an HTML page to crawl."
}
}
```
Stop polling a failed job. Correct the target URL or resolve the target site's access problem before starting a new crawl.
# Meta Scanner
Source: https://docs.pocketutils.com/api-reference/meta-scan
GET /v1/meta-scan
Parse and score a page's title, description, Open Graph, Twitter Card, and technical meta tags.
Fetches a page and audits its ``: title and description length/presence, canonical URL, viewport and charset, `robots.txt` and `sitemap.xml` reachability, every Open Graph and Twitter Card tag, and the favicon. Each check is scored and rolled up into an overall grade.
**1 credit** per call
## Query parameters
The page to scan. `https://` is assumed if the scheme is omitted.
## Response
Overall score, 0–100.
Letter grade derived from `finalScore` (e.g. `"A"`, `"B"`, `"F"`).
One entry per category, each holding every check in that category.
`"basic"`, `"technical"`, `"social"`, or `"images"`.
Check identifier — see the table below.
The extracted value (a string, or `null` if the tag is missing).
`"pass"`, `"warning"`, or `"fail"`.
Human-readable explanation of the result.
Checks that failed.
Checks that passed with a caveat.
Checks that passed cleanly.
Total checks run.
### Checks by category
| Category | Check IDs |
| - | - |
| `basic` | `meta_title`, `meta_description`, `meta_keywords`, `meta_author` |
| `technical` | `meta_viewport`, `meta_charset`, `meta_robots`, `link_canonical`, `file_robots_txt`, `file_sitemap_xml` |
| `social` | `og_title`, `og_description`, `og_image`, `og_image_alt`, `og_url`, `og_type`, `og_site_name`, `og_locale`, `twitter_card`, `twitter_title`, `twitter_description`, `twitter_image`, `twitter_site`, `twitter_creator`, `twitter_image_alt` |
| `images` | `asset_favicon` |
## Errors
| Status | Cause |
| - | - |
| `400` | `url` query parameter missing |
| `500` | The target could not be fetched or parsed |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl "https://api.pocketutils.com/v1/meta-scan?url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch(
"https://api.pocketutils.com/v1/meta-scan?url=https://example.com",
{ headers: { Authorization: "Bearer YOUR_API_KEY" } },
);
const { data } = await res.json();
```
```python Python theme={null}
import requests
res = requests.get(
"https://api.pocketutils.com/v1/meta-scan",
params={"url": "https://example.com"},
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]
```
```json Response (abridged) theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"finalScore": 78,
"grade": "B",
"categories": [
{
"id": "basic",
"results": [
{
"id": "meta_title",
"value": "Example Domain",
"status": "pass",
"message": "Title length is within the recommended range."
},
{
"id": "meta_description",
"value": null,
"status": "fail",
"message": "Meta description is missing."
}
]
},
{
"id": "technical",
"results": [
{
"id": "link_canonical",
"value": "https://example.com/",
"status": "pass",
"message": "Canonical URL is present and self-referential."
}
]
},
{
"id": "social",
"results": [
{
"id": "og_title",
"value": null,
"status": "warning",
"message": "No og:title tag found — falls back to on most platforms."
}
]
},
{
"id": "images",
"results": [
{
"id": "asset_favicon",
"value": "https://example.com/favicon.ico",
"status": "pass",
"message": "Favicon found."
}
]
}
],
"summary": { "critical": 3, "warnings": 5, "passed": 18, "total": 26 }
}
}
```
# Get quota
Source: https://docs.pocketutils.com/api-reference/quota
GET /v1/quota
Check the caller's current credit balance and limit.
Returns the current credit balance for whoever is calling — anonymous, signed-in, or an API key. This is the same data that powers the credits bar on pocketutils.com. It's free to call, uncached, and doesn't count toward your rate limit's credit spend (it still counts as a request).
## Headers
`Bearer pu_live_...`. Omit entirely to check the anonymous allowance for your IP instead.
## Response
The shape of `data` depends on the caller. Both share `callerKind`, `limit`, and `remaining`; a signed-in caller additionally gets `plan`.
Always `true` for this endpoint.
One of `"anonymous"`, `"session"`, `"apikey"`.
`"FREE"`, `"STARTER"`, `"GROWTH"`, `"SCALE"`, or `"ENTERPRISE"`. Omitted when `callerKind` is `"anonymous"`.
Total credits available this cycle (or, for anonymous, per day).
Credits left. For a signed-in caller this is cycle credits plus any unused top-up credits combined.
```bash cURL theme={null}
curl https://api.pocketutils.com/v1/quota \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch("https://api.pocketutils.com/v1/quota", {
headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();
```
```python Python theme={null}
import requests
res = requests.get(
"https://api.pocketutils.com/v1/quota",
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]
```
```json API key / Starter plan theme={null}
{
"success": true,
"data": {
"callerKind": "apikey",
"plan": "STARTER",
"limit": 5000,
"remaining": 4312
}
}
```
```json Anonymous theme={null}
{
"success": true,
"data": {
"callerKind": "anonymous",
"limit": 10,
"remaining": 7
}
}
```
# Screenshot
Source: https://docs.pocketutils.com/api-reference/screenshot
POST /v1/screenshot
Render a full-page screenshot of any URL as PNG, WebP, or JPEG.
Captures a full-page screenshot with headless Chromium: navigates to the URL, waits for the network to settle, scrolls to trigger lazy-loaded content and reveal animations, then encodes the result. The finished image is written to temporary storage and its filename returned — fetch the actual bytes from the download path described below.
**2 credits** per call
## Body parameters
The page to capture. `https://` is assumed if the scheme is omitted.
Output image format: `png`, `jpeg`, or `webp`.
Viewport to render at: `desktop` or `mobile`.
Emulated `prefers-color-scheme` while rendering: `light` or `dark`. Affects any site that themes off this media query.
## Response
Name of the generated file. Download it from `/api/uploads/` on the same host — the file is temporary and removed after roughly 5 minutes.
Size of the encoded image.
Echoes the requested `format`.
File extension for the download (`jpg` for the `jpeg` format, otherwise matches `format`).
`true` if the page was taller than the capture cap and the image was clipped rather than capturing the full height.
Total server-side time for the capture.
### Response headers
In addition to the [standard headers](/rate-limits-and-credits#response-headers), this endpoint sends:
`HIT` if served from the short-lived result cache without touching Chrome, otherwise `MISS`.
Mirrors `clipped` in the body.
Per-stage breakdown (`queue`, `page`, `nav`, `net`, `scroll`, `settle`, `encode`, `total`, all in ms) — renders as a waterfall in your browser's devtools Network panel.
## Errors
| Status | Cause |
| - | - |
| `400` | `url` missing, or `format` / `device` / `colorScheme` not one of the allowed values |
| `400` | The target could not be reached or rendered (message describes why) |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl -X POST https://api.pocketutils.com/v1/screenshot \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"format": "png",
"device": "desktop",
"colorScheme": "light"
}'
```
```javascript JavaScript theme={null}
const res = await fetch("https://api.pocketutils.com/v1/screenshot", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com",
format: "png",
device: "desktop",
colorScheme: "light",
}),
});
const { filename } = await res.json();
const imageUrl = `https://api.pocketutils.com/api/uploads/${filename}`;
```
```python Python theme={null}
import requests
res = requests.post(
"https://api.pocketutils.com/v1/screenshot",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://example.com", "format": "png", "device": "desktop"},
)
filename = res.json()["filename"]
image_url = f"https://api.pocketutils.com/api/uploads/{filename}"
```
```json Response theme={null}
{
"success": true,
"filename": "a1b2c3d4e5f6a7b8.png",
"sizeBytes": 284213,
"format": "png",
"fileExtension": "png",
"clipped": false,
"durationMs": 298
}
```
# SSL Scanner
Source: https://docs.pocketutils.com/api-reference/ssl-scan
GET /v1/ssl-scan
Inspect a domain's certificate, chain, cipher suite, and expiry window.
Opens a TLS connection to the domain and inspects what it presents: the leaf certificate, the full chain, negotiated protocol version and cipher suite, and a set of validity checks (expiry, hostname match, chain completeness, trust). Rolls up into a security grade.
**1 credit** per call
## Query parameters
Domain to scan, e.g. `example.com`. No scheme or path — just the hostname.
## Response
Always `true` for a `200` response.
Echoes the requested domain.
Always `443`.
Negotiated protocol, e.g. `"TLSv1.3"`.
Certificate subject fields (`CN`, `O`, ...).
Issuer fields, same shape as `subject`.
ISO 8601 timestamp.
ISO 8601 timestamp.
SHA-1 fingerprint.
SHA-256 fingerprint.
Certificate serial number.
Subject Alternative Names covered by this certificate.
Public key size in bits, when available.
Signature algorithm, when available.
Intermediate and root certificates. Each entry has the same shape as `certificate`, minus `keySize` and `sigalg`.
`true` if the certificate passes every check below.
Can be negative for an already-expired certificate.
Whether `valid_to` is in the past.
`true` if the certificate is its own issuer.
Whether every intermediate up to a root was presented.
Whether the chain resolves to a trusted root.
Whether the certificate's CN matches the scanned domain.
Whether the domain is covered by a Subject Alternative Name.
Present only when the chain has a problem.
Same value as `protocol`.
Negotiated cipher, e.g. `"TLS_AES_256_GCM_SHA384"`.
e.g. `"A+"`, `"A"`, `"B"`, down to `"F"`.
0–100, the basis for `securityGrade`.
## Errors
| Status | Cause |
| - | - |
| `400` | `domain` query parameter missing |
| `400` | Could not connect, the domain doesn't resolve, or the TLS handshake failed |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl "https://api.pocketutils.com/v1/ssl-scan?domain=example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch(
"https://api.pocketutils.com/v1/ssl-scan?domain=example.com",
{ headers: { Authorization: "Bearer YOUR_API_KEY" } },
);
const { data } = await res.json();
```
```python Python theme={null}
import requests
res = requests.get(
"https://api.pocketutils.com/v1/ssl-scan",
params={"domain": "example.com"},
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
data = res.json()["data"]
```
```json Response (abridged) theme={null}
{
"success": true,
"data": {
"domain": "example.com",
"port": 443,
"protocol": "TLSv1.3",
"certificate": {
"subject": { "CN": "example.com" },
"issuer": { "CN": "DigiCert Global G2 TLS RSA SHA256 2020 CA1", "O": "DigiCert Inc" },
"valid_from": "2026-01-15T00:00:00.000Z",
"valid_to": "2027-02-15T23:59:59.000Z",
"fingerprint": "AB:CD:12:34:...",
"fingerprint256": "12:34:AB:CD:...",
"serialNumber": "0A1B2C3D4E5F",
"altNames": ["example.com", "www.example.com"],
"keySize": 2048,
"sigalg": "RSA-SHA256"
},
"chain": [
{ "subject": { "CN": "DigiCert Global G2 TLS RSA SHA256 2020 CA1" }, "issuer": { "CN": "DigiCert Global Root G2" }, "valid_from": "2021-04-14T00:00:00.000Z", "valid_to": "2031-04-13T23:59:59.000Z", "fingerprint": "...", "fingerprint256": "...", "serialNumber": "..." }
],
"isValid": true,
"daysUntilExpiry": 209,
"isExpired": false,
"certValidations": {
"selfSigned": false,
"chainComplete": true,
"trustedCert": true,
"commonNameValid": true,
"sanValid": true
},
"tlsVersion": "TLSv1.3",
"cipherSuite": "TLS_AES_256_GCM_SHA384",
"securityGrade": "A+",
"score": 97
}
}
```
# URL to PDF
Source: https://docs.pocketutils.com/api-reference/url-to-pdf
POST /v1/url-to-pdf
Convert a live URL into a print-ready PDF document.
Renders a URL with headless Chromium and prints it to a paginated PDF: navigates, waits for the network to settle, scrolls to trigger lazy-loaded content, waits for images and fonts to finish, then prints. The finished file is written to temporary storage and its filename returned.
**2 credits** per call
## Body parameters
The page to convert. `https://` is assumed if the scheme is omitted.
Layout viewport before printing: `desktop` or `mobile`.
Paper size: `letter`, `legal`, `a0`, `a1`, `a2`, `a3`, `a4`, `a5`, or `a6`.
`portrait` or `landscape`.
Emulated `prefers-color-scheme` while rendering: `light` or `dark`.
## Response
Name of the generated PDF. Download it from `/api/uploads/` on the same host — the file is temporary and removed after roughly 5 minutes.
Size of the generated PDF.
Total server-side time for the conversion.
### Response headers
In addition to the [standard headers](/rate-limits-and-credits#response-headers), this endpoint sends:
`HIT` if served from the short-lived result cache without re-rendering, otherwise `MISS`.
Per-stage breakdown (`queue`, `page`, `nav`, `net`, `scroll`, `settle`, `render`, `total`, all in ms).
## Errors
| Status | Cause |
| - | - |
| `400` | `url` missing, or `deviceType` / `pageFormat` / `orientation` / `theme` not one of the allowed values |
| `400` | The domain could not be resolved, or the target blocked the request (e.g. a private/internal address) |
| `408` | The page took too long to render |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl -X POST https://api.pocketutils.com/v1/url-to-pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"pageFormat": "a4",
"orientation": "portrait",
"theme": "light"
}'
```
```javascript JavaScript theme={null}
const res = await fetch("https://api.pocketutils.com/v1/url-to-pdf", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com",
pageFormat: "a4",
orientation: "portrait",
}),
});
const { filename } = await res.json();
const pdfUrl = `https://api.pocketutils.com/api/uploads/${filename}`;
```
```python Python theme={null}
import requests
res = requests.post(
"https://api.pocketutils.com/v1/url-to-pdf",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://example.com", "pageFormat": "a4", "orientation": "portrait"},
)
filename = res.json()["filename"]
pdf_url = f"https://api.pocketutils.com/api/uploads/{filename}"
```
```json Response theme={null}
{
"success": true,
"filename": "example_com_2026-07-21_9f3a2b1c.pdf",
"sizeBytes": 512044,
"durationMs": 356
}
```
# XBEL Decryptor
Source: https://docs.pocketutils.com/api-reference/xbel-decrypt
POST /v1/xbel-decrypt
Decrypt an AES-GCM encrypted XBEL bookmark archive back to plain XML.
Decrypts an encrypted XBEL bookmark export (as produced by tools like Floccus) back into plain XBEL XML using AES-GCM with a PBKDF2-derived key (250,000 iterations, SHA-256). If the uploaded file turns out to already be plain XML, it's passed through unchanged — you don't need to know in advance whether a file needs decrypting.
**1 credit** per call
## Request
`multipart/form-data`, not JSON.
The `.xbel` file to decrypt. Max **10 MB**. Must have a `.xbel` extension (or be sent as `application/octet-stream`, since browsers rarely know the XBEL MIME type).
The decryption password. Always required by the request, even if the file turns out to be unencrypted.
## Response
Absolute URL to fetch the decrypted file from — `GET` it directly, no auth required. Valid for **30 minutes**.
Suggested filename for the download, derived from your upload (e.g. `bookmarks.xbel` → `bookmarks-decrypted.xbel`).
## Download the result
`GET` the `downloadUrl` from the response directly — it streams the file with a `Content-Disposition: attachment` header. No credit cost, no auth required. After 30 minutes the file is deleted and the link 404s.
## Errors
| Status | Cause |
| - | - |
| `400` | No file attached to the request |
| `400` | `key` missing from the form fields |
| `500` | Wrong password, or the file isn't valid XBEL / the expected `{ ciphertext, salt }` JSON shape |
| `404` | (on the download endpoint) the file's 30-minute window has expired |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl -X POST https://api.pocketutils.com/v1/xbel-decrypt \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@bookmarks.xbel" \
-F "key=my-backup-password"
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("key", "my-backup-password");
const res = await fetch("https://api.pocketutils.com/v1/xbel-decrypt", {
method: "POST",
headers: { Authorization: "Bearer YOUR_API_KEY" },
body: form,
});
const { downloadUrl } = await res.json();
```
```python Python theme={null}
import requests
with open("bookmarks.xbel", "rb") as f:
res = requests.post(
"https://api.pocketutils.com/v1/xbel-decrypt",
headers={"Authorization": "Bearer YOUR_API_KEY"},
files={"file": f},
data={"key": "my-backup-password"},
)
download_url = res.json()["downloadUrl"]
```
```json Response theme={null}
{
"success": true,
"downloadUrl": "https://api.pocketutils.com/v1/xbel-decrypt/download/bookmarks-1784541598485-566752584-decrypted.xbel",
"fileName": "bookmarks-decrypted.xbel",
"message": "File decrypted successfully"
}
```
# XBEL to HTML
Source: https://docs.pocketutils.com/api-reference/xbel-to-html
POST /v1/xbel-to-html
Convert an XBEL bookmark archive into a Netscape-format HTML file every browser can import.
Converts an XBEL bookmark file into the Netscape Bookmark HTML format that Chrome, Firefox, Safari, and Edge all accept as an import. Folders, nested folders (up to 100 levels deep), separators, and `` references are all walked and rendered in their original order; dates are converted from XBEL's ISO 8601 to the Unix-seconds format the HTML format expects.
**1 credit** per call
## Request
`multipart/form-data`, not JSON.
The `.xbel` file to convert — must already be plain XBEL XML. Max **10 MB**. Must have a `.xbel` extension (or be sent as `application/octet-stream`). If your file is encrypted, run it through [XBEL Decryptor](/api-reference/xbel-decrypt) first.
## Response
Absolute URL to fetch the converted HTML file from — `GET` it directly, no auth required. Valid for **30 minutes**.
Suggested filename for the download, derived from your upload (e.g. `bookmarks.xbel` → `bookmarks.html`).
## Download the result
`GET` the `downloadUrl` from the response directly — it streams the file with a `Content-Disposition: attachment` header, ready to import in any browser's bookmark manager. No credit cost, no auth required. After 30 minutes the file is deleted and the link 404s.
## Errors
| Status | Cause |
| - | - |
| `400` | No file attached to the request |
| `500` | The file isn't valid XML, or has no `` root element |
| `404` | (on the download endpoint) the file's 30-minute window has expired |
See the full [error reference](/errors) for the platform-level codes (`401`, `402`, `429`) that apply to every endpoint.
```bash cURL theme={null}
curl -X POST https://api.pocketutils.com/v1/xbel-to-html \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@bookmarks.xbel"
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("file", fileInput.files[0]);
const res = await fetch("https://api.pocketutils.com/v1/xbel-to-html", {
method: "POST",
headers: { Authorization: "Bearer YOUR_API_KEY" },
body: form,
});
const { downloadUrl } = await res.json();
```
```python Python theme={null}
import requests
with open("bookmarks.xbel", "rb") as f:
res = requests.post(
"https://api.pocketutils.com/v1/xbel-to-html",
headers={"Authorization": "Bearer YOUR_API_KEY"},
files={"file": f},
)
download_url = res.json()["downloadUrl"]
```
```json Response theme={null}
{
"success": true,
"downloadUrl": "https://api.pocketutils.com/v1/xbel-to-html/download/converted-1784541632760.html",
"fileName": "bookmarks.html",
"message": "File converted successfully"
}
```
# Async Jobs & Polling
Source: https://docs.pocketutils.com/async-jobs
How site-wide crawl endpoints work: start a job, poll its status, read the result.
The [Link Extractor](/api-reference/link-extract#extract-from-an-entire-site) and [Broken Link Checker](/api-reference/broken-links#crawl-an-entire-site) run site crawls in the background. Start a job once, then use its ID to request progress until it finishes.
## Endpoints
All paths use the base URL `https://api.pocketutils.com`.
| Tool | Start a job | Poll progress and results | Start cost |
| - | - | - | - |
| Link Extractor | `POST /v1/link-extract/crawl` | `GET /v1/link-extract/crawl/{jobId}` | 1 credit |
| Broken Link Checker | `POST /v1/broken-links/crawl` | `GET /v1/broken-links/crawl/{jobId}` | 2 credits |
Replace `{jobId}` with the ID returned by the matching start endpoint. IDs belong to one tool and cannot be used with the other tool's status endpoint.
## The pattern
1. Send a JSON body containing `url` to the start endpoint. Use an [API key](/authentication) for an automated integration.
2. Check for `202 Accepted` and save `data.jobId`. Acceptance means a job was created; it does not mean the website has been successfully scanned.
3. Poll the matching GET endpoint about every two seconds for one job. Known jobs return `200 OK` with their current state.
4. Inspect `data.status` on every response. Keep polling for nonterminal phases.
5. On `done`, save `data.result` and stop. On `error`, read `data.error` and stop. On `404`, the job is unavailable and further polling of that ID will not retrieve it.
### Start request
```bash theme={null}
curl -i -X POST "https://api.pocketutils.com/v1/broken-links/crawl" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
```
```json 202 Accepted theme={null}
{
"success": true,
"data": {
"jobId": "c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d"
}
}
```
### Status request
```bash theme={null}
curl "https://api.pocketutils.com/v1/broken-links/crawl/c3d4e5f6-1234-4a5b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY"
```
Polling costs **0 credits**, but counts toward your requests-per-minute limit. Poll less frequently if you have multiple jobs or other API traffic. If you receive `429`, respect `Retry-After` when present and reduce your request rate.
Only crawl starts require Turnstile for website callers; API-key callers bypass Turnstile. Polling does not require a Turnstile token.
## Response shape
The status response wraps the job snapshot in `data`:
| Field | Type | Description |
| - | - | - |
| `success` | boolean | `true` when the status lookup succeeds, including when the job itself failed. |
| `data.jobId` | string | Job ID from the start response. |
| `data.status` | string | Current phase; see below. |
| `data.progress` | object | Latest counters, with tool-specific fields. |
| `data.result` | object, optional | Present when `status` is `done`. |
| `data.error` | string, optional | Present when `status` is `error`. |
Optional fields are omitted until applicable. An HTTP `200` or `success: true` alone does not establish crawl success.
Broken Link Checker uses `pagesCrawled`, `linksChecked`, and `linksTotal` in its progress object. Link Extractor uses `pagesScanned` and has no checking counters. Both expose `phase`, `message`, `sitemapFound`, `sitemapUrlCount`, `pagesDiscovered`, `pagesCap`, and `linksFound`. Full schemas and response examples are on each tool's reference page.
The Link Extractor's completed result contains a link inventory and type counts. Broken Link Checker's completed result contains a site audit with link assessments and a page list; its schema differs from the single-page response.
## Phases
| Phase | Link Extractor | Broken Link Checker | Meaning |
| - | - | - | - |
| `queued` | Yes | Yes | Job created; preparing discovery. |
| `sitemap` | Yes | Yes | Sitemap discovery phase. |
| `crawling` | Yes | Yes | Following internal pages and collecting links. |
| `checking` | No | Yes | Assessing selected links and reusing page results when possible. |
| `done` | Yes | Yes | Terminal success; read `data.result`. |
| `error` | Yes | Yes | Terminal failure; read `data.error`. |
You may miss intermediate phases between polls. Drive your client from `status`, not the wording of `progress.message`. Discovery totals can grow, so they do not provide a stable overall completion percentage.
## Complete polling example
Both examples start one job, check the HTTP response and envelope, poll until a terminal phase, and return the completed result. They accept either tool's slug. They retry a rate-limited GET with bounded backoff, but do not automatically repeat a POST.
Run JavaScript in a runtime with `fetch` and `AbortSignal.timeout` support. The Python example uses only the standard library. Keep API keys on your server.
```javascript JavaScript theme={null}
const API_BASE = "https://api.pocketutils.com/v1";
const API_KEY = "YOUR_API_KEY";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function errorMessage(body, status) {
return typeof body?.error === "string"
? body.error
: body?.error?.message ?? `HTTP ${status}`;
}
async function crawlSite(tool, url, timeoutMs = 15 * 60 * 1000) {
if (!["link-extract", "broken-links"].includes(tool)) {
throw new Error("Choose link-extract or broken-links");
}
const deadline = Date.now() + timeoutMs;
const endpoint = `${API_BASE}/${tool}/crawl`;
const headers = { Authorization: `Bearer ${API_KEY}` };
const started = await fetch(endpoint, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ url }),
signal: AbortSignal.timeout(30000),
});
const startBody = await started.json();
if (started.status !== 202 || startBody.success !== true ||
!startBody.data?.jobId) {
throw new Error(errorMessage(startBody, started.status));
}
const jobId = startBody.data.jobId;
const statusUrl = `${endpoint}/${encodeURIComponent(jobId)}`;
console.log("Started job:", jobId);
let rateLimitRetries = 0;
while (Date.now() < deadline) {
const response = await fetch(statusUrl, {
headers,
cache: "no-store",
signal: AbortSignal.timeout(Math.min(30000, deadline - Date.now())),
});
const body = await response.json();
if (response.status === 429 && rateLimitRetries < 5) {
rateLimitRetries += 1;
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = Math.max(
Number.isFinite(retryAfter) ? retryAfter * 1000 : 0,
2000 * 2 ** rateLimitRetries,
);
await sleep(Math.min(waitMs, Math.max(0, deadline - Date.now())));
continue;
}
if (!response.ok || body.success !== true) {
throw new Error(errorMessage(body, response.status));
}
rateLimitRetries = 0;
const job = body.data;
console.log(job.status, job.progress);
if (job.status === "done") return job.result;
if (job.status === "error") {
throw new Error(job.error ?? "Crawl failed");
}
await sleep(Math.min(2000, Math.max(0, deadline - Date.now())));
}
throw new Error(`Polling timed out for job ${jobId}; the server job may still be running`);
}
// Use "link-extract" for a link inventory or "broken-links" for a link audit.
crawlSite("broken-links", "https://example.com")
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => console.error(error.message));
```
```python Python theme={null}
import json
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
API_BASE = "https://api.pocketutils.com/v1"
API_KEY = "YOUR_API_KEY"
def request_json(url, method="GET", payload=None, timeout=30):
headers = {"Authorization": f"Bearer {API_KEY}"}
raw = None
if payload is not None:
headers["Content-Type"] = "application/json"
raw = json.dumps(payload).encode("utf-8")
else:
headers["Cache-Control"] = "no-cache"
request = Request(url, data=raw, headers=headers, method=method)
try:
response = urlopen(request, timeout=timeout)
except HTTPError as error:
response = error
with response:
return (
response.code,
dict(response.headers),
json.loads(response.read().decode("utf-8")),
)
def error_message(body, status):
error = body.get("error")
if isinstance(error, str):
return error
if isinstance(error, dict):
return error.get("message", f"HTTP {status}")
return f"HTTP {status}"
def crawl_site(tool, url, timeout_seconds=15 * 60):
if tool not in ("link-extract", "broken-links"):
raise ValueError("Choose link-extract or broken-links")
deadline = time.monotonic() + timeout_seconds
endpoint = f"{API_BASE}/{tool}/crawl"
status, _, body = request_json(endpoint, "POST", {"url": url})
job_id = body.get("data", {}).get("jobId")
if status != 202 or body.get("success") is not True or not job_id:
raise RuntimeError(error_message(body, status))
print("Started job:", job_id)
status_url = f"{endpoint}/{quote(job_id, safe='')}"
rate_limit_retries = 0
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
status, headers, body = request_json(
status_url, timeout=max(0.1, min(30, remaining))
)
if status == 429 and rate_limit_retries < 5:
rate_limit_retries += 1
try:
retry_after = float(headers.get("Retry-After", "0"))
except ValueError:
retry_after = 0
wait = max(retry_after, 2 * 2 ** rate_limit_retries)
time.sleep(min(wait, max(0, deadline - time.monotonic())))
continue
if status != 200 or body.get("success") is not True:
raise RuntimeError(error_message(body, status))
rate_limit_retries = 0
job = body["data"]
print(job["status"], job["progress"])
if job["status"] == "done":
return job["result"]
if job["status"] == "error":
raise RuntimeError(job.get("error", "Crawl failed"))
time.sleep(min(2, max(0, deadline - time.monotonic())))
raise TimeoutError(
f"Polling timed out for job {job_id}; the server job may still be running"
)
# Use "link-extract" for a link inventory or "broken-links" for a link audit.
result = crawl_site("broken-links", "https://example.com")
print(json.dumps(result, indent=2))
```
The 15-minute timeout is a client-side example setting, not a service completion guarantee. Stopping polling does not cancel the server job. For production, retain the job ID, log `X-Request-Id` for troubleshooting, and handle transport failures according to your retry policy. GET status requests can be retried; a POST whose response was lost may already have created and charged a job.
## Crawl limits
Both tools scan fetched HTML without executing page JavaScript. They discover sitemaps and follow internal links. A path under `/docs` does not restrict crawling to that path. Recognized assets are collected as links but are not traversed as pages.
| Limit | Value |
| - | - |
| Page fetch cap, including the entry page | 250 |
| Queued-page crawl budget | 6 minutes after sitemap discovery and entry fetch |
| Retained eligible sitemap URLs | 400 |
| Sitemap candidates and child maps | Up to 15 each; one index level |
| Concurrent page fetches | 6 |
| Source sample per link | 25 |
| Extractor returned link cap | 3,000 |
| Checker assessed link cap | 1,500 |
Sitemap discovery, the entry request, in-flight page requests, and the checker's link assessment can make the total job duration exceed six minutes.
A bounded crawl can finish successfully without covering every page. Broken Link Checker exposes `truncatedPages` and `truncatedLinks`. Link Extractor exposes only `truncated` for its returned link array; this flag does not describe page coverage. Neither endpoint supports client-selected limits, result pagination, job cancellation, or completion webhooks.
## Job expiry
Jobs are held in memory. A job is eligible for removal 30 minutes after its last progress, result, or error update; cleanup runs every five minutes. Polling does **not** extend that window. A restart can lose state sooner. Save completed results in your own storage.
Unknown or unavailable IDs return `404`. The message depends on the tool:
```json Broken Link Checker theme={null}
{ "success": false, "error": "Crawl job not found or expired." }
```
```json Link Extractor theme={null}
{ "success": false, "error": "Extraction job not found or expired." }
```
## Errors and billing
A rejected start can return `400` for invalid input, `401` for an invalid API key, `402` for insufficient credits, `403` for failed website human verification, or `429` for rate/quota limits. Non-2xx start requests are refunded by request metering.
A successfully accepted `202` start is billed immediately. A later `data.status: error` does not automatically refund that charge. Polling is free even when the job has failed or is unavailable.
Validation and missing-job errors have a string `error`; platform errors have an object with `code` and `message`. See [Errors](/errors) and the full endpoint references for examples.
Start extraction, track progress, and read link metadata.
Start an audit, track checking, and interpret link and page results.
# Authentication
Source: https://docs.pocketutils.com/authentication
API keys, session cookies, and the anonymous tier — how PocketUtils identifies every caller.
Every `/v1` request is identified as exactly one of three **callers**. The same route handlers serve all three — a subscriber integrating server-to-server, a signed-in user browsing pocketutils.com, and a signed-out visitor trying a tool page — so the endpoint you test in a browser is byte-for-byte the one your integration calls.
| Caller | How it authenticates | Billed against |
| - | - | - |
| **API key** | `Authorization: Bearer pu_live_...` or `X-API-Key` header | Your plan's monthly credit cycle |
| **Session** | `access_token` cookie, set after signing in on pocketutils.com | Your plan's monthly credit cycle |
| **Anonymous** | No credential — the default when neither is present | A small daily allowance, per IP |
PocketUtils resolves the caller in that order: a request with a valid API key is always treated as an API-key caller, even if it also happens to carry a session cookie.
## API keys
API keys are the intended way to call PocketUtils from your own backend. A key starts with `pu_live_` followed by a random string, e.g. `pu_live_a1b2c3...`.
Every code example in this documentation uses `YOUR_API_KEY` as a placeholder. Grab your real key from the [dashboard](https://pocketutils.com/profile/api-keys) and swap it in to run the requests yourself.
Send it in either header — they're equivalent:
```bash Authorization header theme={null}
curl https://api.pocketutils.com/v1/quota \
-H "Authorization: Bearer YOUR_API_KEY"
```
```bash X-API-Key header theme={null}
curl https://api.pocketutils.com/v1/quota \
-H "X-API-Key: YOUR_API_KEY"
```
A key's plaintext is shown exactly once, in the response to the request that created it. PocketUtils stores only its SHA-256 hash, so if you lose it there is no way to recover it — revoke the key and create a new one.
### Creating a key
Keys are created and managed from your [dashboard](https://pocketutils.com/profile/api-keys), not through the public API. Creating one requires a paid plan — the Free plan is the tool-page browsing allowance only, and never gets programmatic access:
| Plan | Active API keys |
| - | - |
| Free | Not available |
| Starter | 2 |
| Growth | 10 |
| Scale | Unlimited |
| Enterprise | Unlimited |
### Invalid or revoked keys
An API key that is malformed, unknown, or revoked is a hard failure — `401 invalid_api_key` — rather than a silent fallback to the anonymous tier. If you're integrating against the API, you want to know immediately that your credential is wrong, not discover it later as an unexplained `429`.
```json theme={null}
{
"success": false,
"error": {
"code": "invalid_api_key",
"message": "That API key is not valid. Check it has not been revoked, or create a new one in your dashboard."
}
}
```
## Session cookies
When you're signed in on pocketutils.com, the tool pages call the same `/v1` endpoints using your browser session instead of a key. This path is locked to the `pocketutils.com` origin with credentialed CORS — it isn't meant for third-party integrations, only for the website itself. If you're building an integration, use an API key.
## Anonymous access
No credential at all is a valid, supported way to call every endpoint — it's what powers trying a tool without signing up. Anonymous requests are tracked by a salted hash of the caller's IP address (raw IPs are never stored) and get:
* **10 credits per day**
* **10 requests per minute**
This is intentionally small: enough to try a couple of light endpoints, not to run an integration on. Signing in raises your allowance to the Free plan's 100 credits/month; a paid plan raises it further and unlocks API keys.
## Choosing a caller type
| | Anonymous | Signed in | API key |
| - | - | - | - |
| Credential | None | Session cookie | `Authorization` / `X-API-Key` |
| Credit pool | 10/day per IP | Free: 100/month | Paid plan: 5,000–80,000+/cycle |
| Best for | Quick manual tries | Using pocketutils.com | Production integrations |
Full plan comparison and credit costs per endpoint.
Every authentication-related error code.
# Errors
Source: https://docs.pocketutils.com/errors
The error response shape, every status code, and the full error code reference.
Every failed response has `"success": false`. What's under `error` takes one of two shapes, depending on where the request failed.
## Platform-level errors
Failures in authentication, credits, rate limiting, or an unrecognized route come from shared middleware that runs before any tool-specific code, and return a structured object with a stable `code` you can match on in your own error handling:
```json theme={null}
{
"success": false,
"error": {
"code": "insufficient_credits",
"message": "Not enough credits for this request. Buy a top-up pack or wait for your cycle to renew."
}
}
```
A stable, machine-readable identifier. Safe to switch on.
A human-readable explanation. Safe to show a user, not guaranteed to stay word-for-word identical between API versions.
## Endpoint-level errors
Validation and execution failures inside an individual tool (a missing `url`, an unreachable target, a malformed upload) return `error` as a **plain string** instead:
```json theme={null}
{
"success": false,
"error": "The `url` query parameter is required"
}
```
Check the type of `error` before reading it: `typeof error === "object"` means a stable `code` is available; `typeof error === "string"` means it's a message meant for a human, not for pattern matching. Both shapes always sit under the same `error` key.
## Status codes
| Status | Meaning |
| - | - |
| `200` | Success (or, for `/broken-links/crawl` and `/link-extract/crawl` status polling, a job that's still running) |
| `202` | A crawl job was accepted and queued — response includes a `jobId` |
| `400` | Bad request — missing or invalid parameters, or a target URL that can't be processed |
| `401` | Missing, malformed, or revoked API key |
| `402` | Signed-in balance doesn't cover this request's credit cost |
| `404` | Unknown `/v1` route, or a job/file that doesn't exist or has expired |
| `408` | The target page took too long to load (render endpoints only) |
| `429` | Per-minute rate limit exceeded, or the anonymous daily allowance is used up |
| `500` | Unexpected server-side failure |
## Error code reference
| Status | Code | When it happens |
| - | - | - |
| `400` | *(string message)* | Missing/invalid input — e.g. no `url`, an unsupported `format`, an unparseable target URL |
| `401` | `invalid_api_key` | The API key is malformed, unknown, or revoked |
| `401` | `unauthorized` | An account-only route was called without a valid session or key |
| `402` | `insufficient_credits` | Your credit balance is below this request's cost. Response includes `creditsRequired` |
| `404` | `unknown_endpoint` | No route matches this method + path under `/v1` |
| `404` | *(string message)* | A crawl `jobId` or a generated file has expired or never existed |
| `408` | *(string message)* | `/url-to-pdf` or `/screenshot` timed out waiting for the target page to render |
| `429` | `rate_limited` | You exceeded your plan's requests-per-minute limit |
| `429` | `free_quota_exhausted` | An anonymous caller used up the 10-credit daily allowance for their IP |
| `500` | `caller_unresolved` | Internal wiring error — not something a valid request should trigger. [Contact support](mailto:support@pocketutils.com) if you see this |
| `500` | *(string message)* | An unexpected failure while processing the request |
## Retrying
| Status | Retryable? |
| - | - |
| `400`, `401`, `402`, `404` | No — fix the request, credential, or balance first |
| `408` | Yes — often transient on a slow target page |
| `429` | Yes, after backing off. Honor the `RateLimit-Reset` header (seconds) rather than retrying immediately |
| `500` | Yes, with backoff — these are unexpected |
Calling through the website's own proxy at `pocketutils.com/api/v1` (instead of `api.pocketutils.com` directly) adds one more possible failure: a `502` with `{ "success": false, "error": "Proxy Service Error", "message": "...", "details": "..." }` if the proxy itself can't reach the API. Direct integrations against `api.pocketutils.com` never see this shape.
Avoid `401` errors before they happen.
Avoid `429` and `402` errors before they happen.
# Introduction
Source: https://docs.pocketutils.com/index
Screenshot, PDF, SSL, meta, and link APIs — free to test in your browser, billed in credits when you automate.
PocketUtils is a collection of REST APIs for the web tasks that show up in almost every product: rendering a page as a screenshot or PDF, checking whether a site is up, scanning its meta tags or SSL certificate, extracting or auditing its links, and converting XBEL bookmark files.
Every endpoint is billed in **credits**, not requests, and every endpoint is the exact same one that powers the free playground on each [tool page](https://pocketutils.com/tools) — there's no separate "production" implementation for your integration to drift out of sync with.
```bash theme={null}
curl "https://api.pocketutils.com/v1/down-check?url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```json theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"testedFrom": "India (Mumbai)",
"httpsStatus": 200,
"responseTimeMs": 214,
"ipAddress": "93.184.216.34"
}
}
```
## Base URL
Every endpoint is versioned and served from a dedicated API host:
```text theme={null}
https://api.pocketutils.com/v1
```
Calling from a browser on `pocketutils.com` itself? The website proxies these same routes at `/api/v1` on its own origin so tool pages never need CORS. Server-to-server integrations should call `api.pocketutils.com` directly.
## Available APIs
Render a full-page PNG, WebP, or JPEG at any viewport. **2 credits**
Convert a live URL into a print-ready PDF document. **2 credits**
Crawl a site and return every dead link with its status. **2 credits**
Parse title, description, Open Graph, and Twitter Card tags. **1 credit**
Inspect the certificate chain, cipher suite, and expiry window. **1 credit**
Report reachability, HTTP status, latency, and resolved IP. **1 credit**
Extract every link on a page or site, grouped by type. **1 credit**
Decrypt a protected XBEL bookmark archive to plain XML. **1 credit**
Convert an XBEL archive into browser-importable HTML. **1 credit**
Check the caller's current credit balance. **Free**
## How it works
Every plan grants a pool of credits per billing cycle. A **light** endpoint — a fetch-and-parse lookup — costs 1 credit; a **render** endpoint — anything that boots headless Chromium or crawls more than one page — costs 2. A failed request is never billed: credits are only spent on a response that actually succeeds. See [Rate Limits & Credits](/rate-limits-and-credits).
The same handler serves a signed-out visitor on a tool page (a small daily allowance), a signed-in user browsing the site (their monthly plan credits via session cookie), and a subscriber calling with an API key. Only the credential and the billing ledger differ — see [Authentication](/authentication).
## Next steps
Make your first API call in under five minutes.
Create an API key and learn the three ways to call the API.
Credit costs, plan limits, and the headers every response carries.
The error shape, status codes, and every error code you can get back.
# Quickstart
Source: https://docs.pocketutils.com/quickstart
Get an API key and make your first PocketUtils API call in under five minutes.
This guide takes you from a fresh account to a successful API response.
Want to try an endpoint before writing any code? Every [tool page](https://pocketutils.com/tools) has a live playground that calls the exact same production endpoint documented here — no signup required.
## Prerequisites
* A PocketUtils account — [sign up free](https://pocketutils.com/auth/get-started)
* A **Starter** plan or above. API keys aren't available on the Free plan — Free is the browser-playground allowance. See [Rate Limits & Credits](/rate-limits-and-credits) for what each plan includes.
## Get started
From your [dashboard](https://pocketutils.com/profile/api-keys), click **New key**, give it a name (e.g. "Production"), and copy the generated secret immediately — it starts with `pu_live_` followed by a random string, and is shown only this once.
Every code example on this site uses `YOUR_API_KEY` as a placeholder — swap it for your real key below to run the requests yourself.
The full key is shown exactly once, at creation. PocketUtils stores only its hash — if you lose the key, revoke it from the dashboard and create a new one.
Call any endpoint with your key in the `Authorization` header. `GET /v1/down-check` is the simplest place to start — it just takes a URL to check.
```bash cURL theme={null}
curl "https://api.pocketutils.com/v1/down-check?url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const res = await fetch(
"https://api.pocketutils.com/v1/down-check?url=https://example.com",
{
headers: {
Authorization: "Bearer YOUR_API_KEY",
},
},
);
const data = await res.json();
console.log(data);
```
```python Python theme={null}
import requests
res = requests.get(
"https://api.pocketutils.com/v1/down-check",
params={"url": "https://example.com"},
headers={"Authorization": "Bearer YOUR_API_KEY"},
)
print(res.json())
```
A successful call always returns `success: true` with the result under `data`:
```json theme={null}
{
"success": true,
"data": {
"url": "https://example.com",
"testedFrom": "India (Mumbai)",
"httpsStatus": 200,
"responseTimeMs": 214,
"ipAddress": "93.184.216.34"
}
}
```
Every response also carries an `X-Credits-Remaining` header, or you can check your balance explicitly with [`GET /v1/quota`](/api-reference/quota).
A failed call always returns `success: false` — and never spends a credit:
```json theme={null}
{
"success": false,
"error": {
"code": "insufficient_credits",
"message": "Not enough credits for this request. Buy a top-up pack or wait for your cycle to renew.",
"creditsRequired": 1
}
}
```
See the full [error code reference](/errors).
## Next steps
All three ways to call the API, and how each is billed.
Every endpoint, with parameters, responses, and examples.
Need help? Reach out at [support@pocketutils.com](mailto:support@pocketutils.com).
# Rate Limits & Credits
Source: https://docs.pocketutils.com/rate-limits-and-credits
Credit costs per endpoint, plan limits, top-up packs, and the headers every response carries.
PocketUtils meters usage in two independent ways. It's worth keeping them separate, because they answer different questions:
**How much** you may spend before your cycle renews or you top up. Set by your plan.
**How fast** you may spend it, in requests per minute. Also set by your plan.
A request that would exceed either one is rejected before any work happens, and rejected requests never spend a credit.
## Credit cost per endpoint
Endpoints come in two tiers. **Light** endpoints fetch and parse — no browser involved. **Render** endpoints boot headless Chromium, or crawl more than one page.
| Endpoint | Method | Tier | Credits |
| - | - | - | - |
| [`/screenshot`](/api-reference/screenshot) | POST | Render | 2 |
| [`/url-to-pdf`](/api-reference/url-to-pdf) | POST | Render | 2 |
| [`/broken-links`](/api-reference/broken-links) | POST | Render | 2 |
| [`/broken-links/crawl`](/api-reference/broken-links) | POST | Render | 2 |
| [`/meta-scan`](/api-reference/meta-scan) | GET | Light | 1 |
| [`/ssl-scan`](/api-reference/ssl-scan) | GET | Light | 1 |
| [`/down-check`](/api-reference/down-check) | GET | Light | 1 |
| [`/link-extract`](/api-reference/link-extract) | GET | Light | 1 |
| [`/link-extract/crawl`](/api-reference/link-extract) | POST | Light | 1 |
| [`/xbel-decrypt`](/api-reference/xbel-decrypt) | POST | Light | 1 |
| [`/xbel-to-html`](/api-reference/xbel-to-html) | POST | Light | 1 |
| `/quota` | GET | — | Free |
| `/broken-links/crawl/:jobId` (status) | GET | — | Free |
| `/link-extract/crawl/:jobId` (status) | GET | — | Free |
| `/xbel-decrypt/download/:filename` | GET | — | Free |
| `/xbel-to-html/download/:filename` | GET | — | Free |
Starting a crawl costs credits once, when you start it; polling its status and downloading the resulting file are always free and don't count toward your rate limit either. See [Async Jobs & Polling](/async-jobs).
## Failed requests are never billed
Credits are reserved *before* your request runs and confirmed only after it finishes — so a burst of concurrent requests can never collectively overdraw a balance that each one individually saw as sufficient. If the request then fails (any non-2xx response, or the connection drops mid-render), the reservation is automatically refunded. You are only ever charged for a response that actually succeeds.
## Plans
| Plan | Price | Credits / cycle | Requests / min | API keys | Usage history |
| - | - | - | - | - | - |
| **Free** | \$0 | 100 | 20 | Not available | 30 days |
| **Starter** | \$9/mo | 5,000 | 60 | 2 | 1 year |
| **Growth** | \$29/mo | 20,000 | 300 | 10 | 2 years |
| **Scale** | \$99/mo | 80,000 | 1,200 | Unlimited | 5 years |
| **Enterprise** | Custom | Custom (250,000+) | 5,000 | Unlimited | 5 years |
The Free plan's 100 credits/month is the allowance for a signed-in visitor using the website's tool pages — it does not include API key access. Programmatic access starts on Starter.
### Anonymous (signed-out) allowance
No account at all still gets a small, deliberately limited allowance, tracked per IP:
* **10 credits per day**
* **10 requests per minute**
### Top-up packs
On any paid plan, buy additional credits that don't expire with the billing cycle:
| Pack | Credits | Price |
| - | - | - |
| `pack_5k` | 5,000 | \$10 |
| `pack_25k` | 25,000 | \$45 |
| `pack_100k` | 100,000 | \$160 |
Cycle credits are spent before top-up credits.
## Response headers
Every `/v1` response carries these headers, whether it succeeds or fails:
Unique ID for this request. Include it when contacting support about a specific call.
Credits this request cost (0 for free endpoints).
Your balance immediately after this request was reserved.
Your plan's requests-per-minute ceiling.
Requests left in the current one-minute window.
Seconds until the window resets.
Light `GET` endpoints also send `Cache-Control: no-store`. Without it, a browser or intermediary could serve a stale scan from cache, or silently revalidate and re-run the whole thing for a `304` — neither is what re-running a scan should do.
## 429 vs. 402: which limit did you hit
| Status | Code | Meaning | What to do |
| - | - | - | - |
| `429` | `rate_limited` | Too many requests in the current minute | Back off and retry after `RateLimit-Reset` seconds |
| `429` | `free_quota_exhausted` | Anonymous daily allowance used up | Sign in, or wait for the daily reset |
| `402` | `insufficient_credits` | Signed-in/API-key balance too low for this request's cost | Buy a top-up pack, or wait for cycle renewal |
`402` responses include a `creditsRequired` field so you know exactly how far short you were:
```json theme={null}
{
"success": false,
"error": {
"code": "insufficient_credits",
"message": "Not enough credits for this request. Buy a top-up pack or wait for your cycle to renew.",
"creditsRequired": 2
}
}
```
How each caller type is identified and billed.
The full error code reference.