Browser API
Crawl
Crawl a site as an asynchronous job: start it, poll it until it is done, read the pages. Crawls stay on the start host and follow links breadth-first.
Body parameters
| Name | Type | Description |
|---|---|---|
urlrequired | string | Where the crawl starts (absolute http(s) URL). |
max_pages | integer (≥ 1) | Stop after this many pages (visited plus failed). Up to your plan's limit (Free 25, Pay as you go 5,000, Invoiced 20,000); more is a 400. Default: 10. |
max_depth | integer (0–20) | Link hops to follow from the start URL. 0 visits only the start URL (and sitemap URLs). Default: 3. |
same_domain | true | Crawls stay on the start host (www. counts as the same host). Only true is accepted. Default: true. |
include | string[] (≤ 50) | Path globs a URL must match to be visited, e.g. "/blog/**". The start URL is always visited (unless sitemap is "only"). |
exclude | string[] (≤ 50) | Path globs to skip, e.g. "/tag/**". |
formats | string[] | Formats stored per page, as for render (no screenshot). Add structured to get each page's listings, businesses or products as records. Default: ["markdown"]. |
markdown_mode | "main" | "full" | main keeps the main content and drops navigation, footers and sidebars. full converts the whole body: use it for search results, listing grids and directories, which main can drop. Default: "main". |
mode | "auto" | "http" | "browser" | http: a plain HTTP fetch, no JavaScript (cheapest). browser: a real Chromium browser. auto: the engine decides; you are charged for the mode that served the page, which the response's mode_used reports. Default: "auto". |
unblock | "none" | "stealth" | "residential" | "auto" | Unblocking tier for every page, as for render. Default: "none". |
sitemap | "skip" | "include" | "only" | Seed the crawl from the site's sitemaps. See Sitemap seeding. Default: "skip". |
respect_robots | boolean | Honor robots.txt (user agent PageteerBot): disallowed pages are skipped, a disallowed start URL fails the job, and Crawl-delay spaces page fetches (up to 60 s). Set false only for sites you own or have permission to crawl. Default: true. |
Crawls do not take wait_for, timeout_ms, headers, cookies or max_age: each page uses the default 30-second budget. Need those per URL? Use batch.
Start a crawl
curl -X POST https://api.104-236-84-15.sslip.io/v1/crawl \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.iana.org/help/example-domains","max_pages":2,"max_depth":1,"mode":"http","include":["/help/**","/domains/**"]}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/crawl', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://www.iana.org/help/example-domains",
"max_pages": 2,
"max_depth": 1,
"mode": "http",
"include": [
"/help/**",
"/domains/**"
]
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
console.log(data, res.headers.get('x-credits-remaining'));import os
import requests
headers = {"Authorization": f"Bearer {os.environ['PAGETEER_API_KEY']}"}
res = requests.post(
"https://api.104-236-84-15.sslip.io/v1/crawl",
headers=headers,
json={
"url": "https://www.iana.org/help/example-domains",
"max_pages": 2,
"max_depth": 1,
"mode": "http",
"include": ["/help/**", "/domains/**"],
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){ "job_id": "6ac0f90f934df03715b56c1c" }Starting a crawl is free, but needs credits for at least one page (else 402, with a reason). The start URL goes through the usual checks first (403 forbidden_target for private or denylisted hosts).
Retry-safe starts
Send an Idempotency-Key header (1–255 visible ASCII characters) and a retried start never creates a second crawl: the same key with the same body returns the original 202 with idempotent-replayed: true; the same key with a different body is 409 idempotency_conflict. See Async jobs.
Poll for results
curl "https://api.104-236-84-15.sslip.io/v1/crawl/6ac0f90f934df03715b56c1c" \
-H "Authorization: Bearer $PAGETEER_API_KEY"const res = await fetch('https://api.104-236-84-15.sslip.io/v1/crawl/6ac0f90f934df03715b56c1c', {
headers: { Authorization: `Bearer ${process.env.PAGETEER_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
console.log(data, res.headers.get('x-credits-remaining'));import os
import requests
headers = {"Authorization": f"Bearer {os.environ['PAGETEER_API_KEY']}"}
res = requests.get(
"https://api.104-236-84-15.sslip.io/v1/crawl/6ac0f90f934df03715b56c1c",
headers=headers,
params={},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"id": "6ac0f90f934df03715b56c1c",
"status": "done",
"pages": [
{
"url": "https://www.iana.org/help/example-domains",
"depth": 0,
"credits": 1,
"tier_used": "none",
"final_url": "https://www.iana.org/help/example-domains",
"status": 200,
"title": "Example Domains",
"markdown": "# Example Domains\n\nA number of domains such as `example.com`…"
},
{
"url": "https://www.iana.org/domains",
"depth": 1,
"credits": 1,
"tier_used": "none",
"final_url": "https://www.iana.org/domains",
"status": 200,
"title": "Domain Name Services",
"markdown": "# Domain Name Services\n\nWe operate and maintain a number of …"
}
],
"progress": { "done": 2, "failed": 0, "queued": 20, "total": 2 },
"credits_used": 2,
"error": null,
"created_at": "2026-10-03T12:46:07.126Z",
"updated_at": "2026-10-03T12:46:10.824Z"
}status:queued→running→done,failedorcanceled. Stop polling at any of the last three.progress:donepages fetched,failedpages that errored,queuedURLs still waiting in the frontier (not all will be visited) andtotal=max_pages.pagesgrows as the crawl runs, in visit order;credits_usedis the total so far.errorexplains an early stop even whenstatusisdone(see When a crawl stops early).sitemapappears when the job was seeded from sitemaps (below).
Poll every few seconds with backoff; polling is free and counts toward your rate limit. See Async jobs & polling for a complete loop. Your crawl jobs are also listed in the dashboard. Jobs and their pages are kept for 30 days after they are created, then deleted.
Page fields
| Name | Type | Description |
|---|---|---|
url | string | The URL visited. |
depth | integer | Link hops from the start URL (sitemap URLs are depth 0). |
final_url | string | After redirects. |
status | integer | The site’s HTTP status for this page. |
title | string | null | Page title. |
<format> | string | array | object | One field per requested format (markdown, html, links, structured, …), as in render. Strings longer than 200,000 characters are cut and get <format>_truncated: true. |
tier_used | string | Unblocking tier that served the page. |
credits | integer | Credits this page cost. |
error | string | Only on failed pages: the error code (target_blocked, timeout, robots_disallowed, …). |
blocked | boolean | Only when the page was blocked, a CAPTCHA or a login wall. |
content_omitted | boolean | Very large crawls: once about 12 MB of content is stored, later pages keep their metadata only. |
Cancel
Stops a queued or running crawl and returns the job (same shape as GET) with status: "canceled". Pages already fetched stay in the job and stay charged; a page that was in flight is refunded. Canceling a finished job changes nothing and returns it as it is, so it is safe to repeat.
Export a site
Downloads a finished crawl (done, failed or canceled) as one file. Free: the pages were charged when they were crawled. It is streamed, so start writing it to disk straight away.
| Name | Type | Description |
|---|---|---|
markdown | text/markdown | Every page in one file, in crawl order, each under "## <title>" and "Source: <url>". Ready for a docs search index or an LLM context. Default: the default. |
llms.txt | text/plain | An llms.txt index: the site title and summary, then "- [title](url): summary" per page. Summaries are the meta description or, without one, the first paragraph (no LLM involved). |
jsonl | application/x-ndjson | Every stored page as one JSON object per line, failed pages included. |
zip | application/zip | One .md file per page at <host>/<path>.md, each with title and source front matter, plus llms.txt. |
curl "https://api.104-236-84-15.sslip.io/v1/crawl/6ac0f90f934df03715b56c1c/export?format=llms.txt" \
-H "Authorization: Bearer $PAGETEER_API_KEY" -o llms.txt
curl "https://api.104-236-84-15.sslip.io/v1/crawl/6ac0f90f934df03715b56c1c/export?format=zip" \
-H "Authorization: Bearer $PAGETEER_API_KEY" -o site.zip# Example Domains
> As described in RFC 2606 and RFC 6761, a number of domains such as example.com are reserved for documentation.
Crawled from https://www.iana.org/help/example-domains on 2026-10-03.
## Pages
- [Example Domains](https://www.iana.org/help/example-domains): As described in RFC 2606 and RFC 6761, a number of domains…
- [Domain Name Services](https://www.iana.org/domains): We operate and maintain a number of key parts of the DNS…- Markdown, llms.txt and zip need page content: crawl with
formats: ["markdown"](the default) ortext. Failed pages and pages without content are left out (Markdown says how many); JSONL keeps everything. - A crawl that is still
queuedorrunningis409 conflict. A crawl whose stored pages are too large for one file is413 payload_too_large; if an export would grow past the limit while streaming, it ends with an explicit truncation note instead of stopping silently. - The dashboard's crawl page has the same downloads under Download.
Sitemap seeding
Most sites list their pages in a sitemap. Reading it enumerates every listing, store or product page without loading the HTML pages in between, so a crawl makes fewer page loads and is less likely to trip a site's bot defenses.
- Discovery: the
Sitemap:lines in the site'srobots.txt, or/sitemap.xmlwhen there are none. Sitemap indexes are followed, and gzipped (.xml.gz) and plain-text sitemaps are read. A start URL that ends in.xmlor.xml.gzis used as the sitemap itself and is not rendered. sitemap: "only"visits only the sitemap URLs and follows no links. The start URL just names the site."include"visits the start URL, then the sitemap URLs, and keeps following links as usual.- Sitemap URLs go through the same filters as links: the start host only,
include/excludeand robots.txt. Pages are visited in sitemap order, up tomax_pages. - Reading sitemaps is free; you pay only for the pages visited. At most 25 sitemap files and 50,000 URLs are read per job. The job's
sitemapfield reports{urls, seeded, sitemaps, truncated}: the URLs found, the URLs queued after filtering, the sitemap files read, and whether a limit was hit. - With
"only", a job whose sitemaps yield no usable URL ends asfailedwith nothing charged.
curl -X POST https://api.104-236-84-15.sslip.io/v1/crawl \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://realty.example/","sitemap":"only","include":["/homes/**"],"mode":"http","formats":["structured"],"max_pages":500}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/crawl', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://realty.example/",
"sitemap": "only",
"include": [
"/homes/**"
],
"mode": "http",
"formats": [
"structured"
],
"max_pages": 500
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
console.log(data, res.headers.get('x-credits-remaining'));import os
import requests
headers = {"Authorization": f"Bearer {os.environ['PAGETEER_API_KEY']}"}
res = requests.post(
"https://api.104-236-84-15.sslip.io/v1/crawl",
headers=headers,
json={
"url": "https://realty.example/",
"sitemap": "only",
"include": ["/homes/**"],
"mode": "http",
"formats": ["structured"],
"max_pages": 500,
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"])When a crawl stops early
- Credits run out: the job ends
donewith the pages fetched so far anderrorstarting withinsufficient_creditsorspend_limit_reached(see 402 reasons). - CAPTCHA: the site answered with a CAPTCHA or bot challenge. That page is free, and the job ends
donewitherrorstarting withcaptcha_required. We do not solve CAPTCHAs and do not keep requesting pages from a site that shows one. - The start URL is disallowed by robots.txt (with
respect_robots): the job endsfailed(error: "robots.txt disallows the start URL"). - Sitemaps unusable (with
sitemap: "only"): no URL left after the filters, or the sitemaps could not be read (errorstarts withsitemap_unavailable):failed, nothing charged. - The account is suspended:
failed. - Our engine keeps failing (5 pages in a row):
failedwitherror: "engine_unavailable". Failed pages are free; start the crawl again later.
Single pages that fail (blocked, timeouts, a login wall, a robots.txt disallow) do not stop the crawl: they are recorded with error, counted in progress.failed and not charged. When a page gets 429 (or 503 with Retry-After) the crawl waits that long and retries the page, up to three times, instead of pushing on.
Credits
Each page costs the same as a render with the same mode and tier: 1 in HTTP mode, 5 in a browser. 200 pages in browser mode cost 1,000 credits. Reading robots.txt and sitemaps, polling and canceling are free; failed pages are free.
Crawls are throttled per target host so a site is never hammered, and they honor robots.txt Crawl-delay. Large crawls take time by design. Crawl pages share your plan's concurrency with your other requests.
Errors
Errors of the API calls themselves (page failures are recorded in the job instead):
| Status | Code | When | Charged | What to do |
|---|---|---|---|---|
| 400 | invalid_request | A parameter is invalid, or max_pages is over your plan’s limit. | No | Fix the request; retrying it unchanged fails again. |
| 401 | invalid_api_key | No key, an unknown key, or a revoked or expired key. | No | Check the Authorization header and the key on the API keys page. |
| 402 | insufficient_credits | Not enough credits left this month for this request, and billable usage is off. error.reason says why: free_credits_used, payment_failed or subscription_inactive. | No | Act on the reason at error.billing_url: add a card, update the payment method, or restart the subscription. Or wait for the 1st (UTC). |
| 402 | spend_limit_reached | Billable usage is on and this month’s spend limit is reached (error.reason: spend_limit). | No | Raise the spend limit at error.billing_url (the Billing page), or wait for the 1st (UTC). |
| 403 | forbidden_target | The URL is denylisted, resolves (or redirects) to a private or internal address, or sends cookies or credentials to a login, account or checkout page. | No | Do not retry. See the Acceptable Use Policy. |
| 403 | org_suspended | The account is suspended. | No | Contact support. |
| 403 | proxy_not_configured | unblock "residential" without a team proxy (Settings → Proxy). | No | Save your proxy URL in Settings → Proxy (see Bring your own proxy), or use another tier. |
| 404 | not_found | No crawl job with that id in your account. | No | Check the path and id. |
| 409 | idempotency_conflict | An Idempotency-Key was reused with a different request body (POST /v1/batch or /v1/crawl). error.job_id names the original job. | No | Use a new key for a new job. |
| 429 | rate_limited | Over your requests per minute, or too many requests to one site across the platform right now. | No | Wait Retry-After seconds, then back off with jitter. |
| 500 / 502 / 503 | internal_error | Something failed on our side (rendering engine, LLM extraction or the API itself), or the rendering engine is at capacity (503, Retry-After: 5). | No | Retry with backoff (honor Retry-After); quote x-request-id if it persists. |