Browser API
Batch
Hand over a list of URLs, get every result back as one file. Batches run in the background, pace each site politely, retry what is worth retrying and can tell you when they are done.
Body parameters
| Name | Type | Description |
|---|---|---|
urls | string[] | The URLs to fetch. Send this or items, not both. Up to your plan's limit per batch (Free 100, Pay as you go 10,000, Invoiced 25,000). |
items | { url, id?, metadata? }[] | URLs with your own id (≤ 200 chars) and metadata (any JSON object, ≤ 2 KB). Both come back unchanged with each result and webhook. |
formats | string[] | Formats stored per URL, as for render: html, markdown, text, links, metadata, emails, phones, structured (no screenshot). 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 URL, as for render. Default: "none". |
extract | { selectors } | CSS selectors run on every page, as for extract (1–100 fields). The values land in result.data and in data.<name> CSV columns. |
wait_for | string (≤ 500 chars) | CSS selector to wait for before capturing. Implies a browser render. |
wait_ms | integer (0–30000) | Extra milliseconds to wait after the page loads (browser mode). |
country | string (2 letters) | Exit-IP country, as for render (residential tier). |
headers | object | Extra request headers for every URL, as for render. Batches do not take cookies. |
timeout_ms | integer (1000–120000) | Per URL, as for render. A URL that times out is retried. Default: 30000. |
respect_robots | boolean | Check each site’s robots.txt first. Disallowed URLs fail as robots_disallowed (free), and Crawl-delay spaces requests. On by default for batches. Default: true. |
max_age | integer (seconds, 0–86400) | Opt-in response cache, as for render: a copy fetched within max_age seconds (for example by an earlier batch) is reused for 0 credits. |
webhook | { url, events?, secret? } | Where to POST signed events. events: ["batch.completed"] (default) and/or "batch.item.completed". secret: 16–256 characters; omit it and we generate one. See Webhooks. |
metadata | object | Any JSON object (≤ 4 KB), returned with the batch. |
Request bodies can be up to 8 MB. URLs that are malformed or point at private or internal addresses fail straight away (free, counted in rejected) without failing the batch. An invalid CSS selector, an unreachable webhook URL or a tier your plan does not include fail the whole request with a 4xx.
Start a batch
curl -X POST https://api.104-236-84-15.sslip.io/v1/batch \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"url":"https://example.com","id":"ex-1","metadata":{"source":"docs"}},{"url":"https://www.iana.org/domains/reserved","id":"iana-1"},{"url":"http://10.0.0.1/","id":"bad"}],"mode":"http","formats":["markdown"],"extract":{"selectors":{"heading":"h1"}},"metadata":{"run":"docs-audit"},"webhook":{"url":"https://hooks.example.com/pageful"}}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"items": [
{
"url": "https://example.com",
"id": "ex-1",
"metadata": {
"source": "docs"
}
},
{
"url": "https://www.iana.org/domains/reserved",
"id": "iana-1"
},
{
"url": "http://10.0.0.1/",
"id": "bad"
}
],
"mode": "http",
"formats": [
"markdown"
],
"extract": {
"selectors": {
"heading": "h1"
}
},
"metadata": {
"run": "docs-audit"
},
"webhook": {
"url": "https://hooks.example.com/pageful"
}
}),
});
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/batch",
headers=headers,
json={
"items": [{
"url": "https://example.com",
"id": "ex-1",
"metadata": {
"source": "docs",
},
}, {
"url": "https://www.iana.org/domains/reserved",
"id": "iana-1",
}, {
"url": "http://10.0.0.1/",
"id": "bad",
}],
"mode": "http",
"formats": ["markdown"],
"extract": {
"selectors": {
"heading": "h1",
},
},
"metadata": {
"run": "docs-audit",
},
"webhook": {
"url": "https://hooks.example.com/pageful",
},
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"job_id": "6ac0f917934df03715b56c42",
"status": "queued",
"total": 3,
"accepted": 2,
"rejected": 1,
"webhook_secret": "whsec_51fece5d09ba37aae6b7acfec1c7f19aac0f574c6d629ce6"
}The private address was rejected up front (rejected: 1). webhook_secret appears only when we generated it: store it to verify webhook signatures. Submitting is free, but needs credits for at least one URL (else 402, with a reason).
Retry-safe submits
Send an Idempotency-Key header (1–255 visible ASCII characters) so a retried submit never starts a second batch. The same key with the same body returns the original 202 with an idempotent-replayed: true header. The same key with a different body is a 409 idempotency_conflict. Keys are per account and last as long as the batch (30 days). Details in Async jobs.
curl -X POST "https://api.104-236-84-15.sslip.io/v1/batch" \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: listings-2026-10-02" \
-d @batch.jsonCheck progress
{
"id": "6ac0f917934df03715b56c42",
"status": "done",
"counts": { "total": 3, "queued": 0, "running": 0, "succeeded": 2, "failed": 1, "canceled": 0 },
"outcomes": { "forbidden_target": 1, "ok": 2 },
"credits_used": 2,
"error": null,
"metadata": { "run": "docs-audit" },
"webhook": { "url": "https://hooks.example.com/pageful", "events": ["batch.completed"] },
"created_at": "2026-10-03T12:46:15.308Z",
"updated_at": "2026-10-03T12:46:17.432Z",
"completed_at": "2026-10-03T12:46:17.432Z"
}status:queued→running→done,failedorcanceled. A batch isdonewhen every URL has finished, whether it succeeded or not; stop polling at any of the last three.countsare per URL status;outcomescounts URLs per outcome code. New codes may appear over time, so treat the keys as open-ended.erroris set when the batch stopped early (credits ran out, or the account was suspended:failed).
Polling is free. A good loop polls every few seconds with backoff; see Async jobs & polling, or let a webhook tell you instead.
Get results
| Name | Type | Description |
|---|---|---|
format | "json" | "jsonl" | "csv" | json returns one page of items; jsonl and csv stream the whole batch as a download. Default: "json". |
status | string | Only items with this status: queued, running, succeeded, failed or canceled. |
after | integer | Cursor: only items with index greater than this. Pass the previous page’s next. Default: -1. |
limit | integer (1–1000) | Page size (json only). Default: 100. |
include_content | "true" | "false" | false leaves out result (status, outcome and metadata only): much smaller pages. Default: "true". |
curl "https://api.104-236-84-15.sslip.io/v1/batch/6ac0f917934df03715b56c42/results?limit=2" \
-H "Authorization: Bearer $PAGETEER_API_KEY"const res = await fetch(`https://api.104-236-84-15.sslip.io/v1/batch/6ac0f917934df03715b56c42/results?${new URLSearchParams({"limit":"2"})}`, {
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/batch/6ac0f917934df03715b56c42/results",
headers=headers,
params={
"limit": 2,
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"items": [
{
"index": 0,
"id": "ex-1",
"url": "https://example.com",
"status": "succeeded",
"outcome": "ok",
"http_status": 200,
"final_url": "https://example.com",
"title": "Example Domain",
"tier_used": "none",
"credits": 1,
"attempts": 1,
"error": null,
"metadata": { "source": "docs" },
"result": {
"markdown": "This domain is for use in documentation examples without needing permission. …",
"data": { "heading": null }
},
"completed_at": "2026-10-03T12:46:17.427Z"
},
{
"index": 1,
"id": "iana-1",
"url": "https://www.iana.org/domains/reserved",
"status": "succeeded",
"outcome": "ok",
"http_status": 200,
"final_url": "https://www.iana.org/domains/reserved",
"title": "IANA-managed Reserved Domains",
"tier_used": "none",
"credits": 1,
"attempts": 1,
"error": null,
"metadata": null,
"result": {
"markdown": "# IANA-managed Reserved Domains\n\nCertain domains are set aside, …",
"data": { "heading": "IANA-managed Reserved Domains" }
},
"completed_at": "2026-10-03T12:46:16.849Z"
}
],
"next": 1
}Items come in index order. Pass next as after to get the next page, until next is null (see pagination). Results are readable while the batch runs, so you can stream them out as they finish.
Item fields
| Name | Type | Description |
|---|---|---|
index | integer | Position in your urls/items array (0-based). Also the cursor. |
id | string | null | Your items[].id, echoed back. |
url | string | The URL as you sent it. |
status | string | queued, running, succeeded, failed or canceled. |
outcome | string | null | ok for a fetched page, otherwise the code that ended it (see Outcomes). Open-ended. null while open. |
http_status | integer | null | The site’s HTTP status (a 404 is still outcome ok: check it). |
final_url | string | null | After redirects. |
title | string | null | Page title. |
tier_used | string | null | Unblocking tier that served the page. |
credits | integer | Credits this URL cost (0 unless it succeeded). |
attempts | integer | Fetch attempts made (retries included). |
error | { code, message } | null | Why the URL failed or was canceled. |
metadata | any | Your items[].metadata, echoed back. |
result | object | null | The requested formats (strings over 200,000 characters are cut, with <format>_truncated: true), data with extract.selectors, and cached/revalidated when served from the cache. null unless succeeded, or with include_content=false. |
completed_at | string | null | ISO time the URL finished. |
Download everything
format=jsonl streams one item per line (the same objects); format=csv streams one row per URL with the columns index, id, url, status, outcome, http_status, final_url, title, tier_used, credits, attempts, error_code, error_message, completed_at, metadata (as JSON) and one data.<name> column per selector. Both come as attachments named batch_<id>.jsonl / .csv; status and include_content filter them too. Results are kept for 30 days. You can also download them from the Batches page in the dashboard.
curl "https://api.104-236-84-15.sslip.io/v1/batch/6ac0f917934df03715b56c42/results?format=jsonl" \
-H "Authorization: Bearer $PAGETEER_API_KEY" -o results.jsonlindex,id,url,status,outcome,http_status,final_url,title,tier_used,credits,attempts,error_code,error_message,completed_at,metadata,data.heading
0,ex-1,https://example.com,succeeded,ok,200,https://example.com,Example Domain,none,1,1,,,2026-10-03T12:46:17.427Z,"{""source"":""docs""}",
2,bad,http://10.0.0.1/,failed,forbidden_target,,,,,0,0,forbidden_target,"Private, loopback and reserved network targets are not allowed",2026-10-03T12:46:15.308Z,,List batches
Your most recent batches, newest first. limit: 1–100, default 20 (anything else is a 400).
{
"batches": [
{
"id": "6ac0f917934df03715b56c42",
"status": "done",
"total": 3,
"succeeded": 2,
"failed": 1,
"canceled": 0,
"credits_used": 2,
"metadata": { "run": "docs-audit" },
"created_at": "2026-10-03T12:46:15.308Z",
"completed_at": "2026-10-03T12:46:17.432Z"
}
]
}Cancel
Returns the batch (same shape as GET) with status: "canceled". URLs not fetched yet become canceled and are not charged, including any in flight at that moment. Results already fetched stay available, and a batch.completed webhook is sent. Canceling a finished batch changes nothing, so it is safe to repeat.
Outcomes and retries
Every URL ends succeeded, failed or canceled. We retry timeouts, errors on our side and site 5xx responses, up to 3 attempts with growing delays (from 30 seconds). When a site asks us to slow down (target_rate_limited), we pause that site for everyone and come back after its Retry-After, without using up an attempt (up to 10 times). We never solve CAPTCHAs or log in: those URLs, like robots.txt disallows and blocks, fail straight away, are never retried and are free:
| Status | Code | When | Charged |
|---|---|---|---|
| — | ok | Fetched. The site may still have answered 404: see http_status. | Yes |
| 422 | target_blocked | The site blocked every tier we tried, or answered with a block page or an empty response. Try unblock: "auto". | No |
| 422 | captcha_required | The site answered with a CAPTCHA or bot challenge. | No |
| 422 | login_required | The site redirected to a login page. | No |
| 403 | robots_disallowed | The site’s robots.txt disallows the URL (respect_robots). | No |
| 429 | target_rate_limited | The site kept asking us to slow down after 10 waits. | No |
| 504 | timeout | Still timing out after the last retry. | No |
| — | target_error | The site kept answering 5xx after the last retry (http_status has the code). | No |
| 403 | forbidden_target | Denylisted, private or internal URL. | No |
| 400 | invalid_request | Not an absolute http(s) URL, or the domain has no DNS record. (A temporary DNS failure is retried.) | No |
| 402 | insufficient_credits | Credits ran out (or spend_limit_reached: the spend limit was hit); the batch stopped here. error.reason may say why. | No |
| — | canceled | The batch was canceled first. | No |
In your usage log (dashboard and request logs) each batch URL appears with the status Pageteer returned (200 for a fetched page), not the site's; the site's status is the item's http_status. A URL canceled while in flight is refunded and logged with 0 credits.
Other codes from the errors list can appear too, and so can new ones. Treat unknown codes as failures for that URL. To re-run only the failures, fetch ?status=failed&include_content=false and submit those URLs as a new batch.
Pacing
A batch never floods a single site. Each target host gets one batch request every couple of seconds, across all batches, so 1,000 URLs on one site trickle in over about half an hour while URLs on other sites run in parallel. Batches on the same site take turns, so a small batch is not stuck behind a large one. A batch uses at most half your plan's concurrency, so your live API calls keep working.
Webhooks
With webhook set, we POST a signed batch.completed event when the batch finishes (also on cancel), and optionally a batch.item.completed event per URL. Payloads, retries and signature verification in Node and Python are on the Webhooks page.
Credits
Each successful URL costs the same as a render with the same mode and tier: 1,000 pages in HTTP mode cost 1,000 credits, in browser mode 5,000. Failed, rejected and canceled URLs are free, and so are submitting, polling and downloading results. When credits run out mid-batch, the batch stops (done, with error set) and the remaining URLs fail as insufficient_credits or spend_limit_reached.
Batches are best for many independent URLs (listings, product pages, business pages). To follow links within one site, use crawl.
Errors
Errors of the API calls themselves (per-URL failures are outcomes, above):
| Status | Code | When | Charged | What to do |
|---|---|---|---|---|
| 400 | invalid_request | A parameter is invalid, more URLs than your plan allows, both or neither of urls/items, an invalid selector, a webhook URL that does not resolve, or a bad Idempotency-Key. | 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 webhook URL is private, internal or denylisted. | 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 batch 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. |
| 413 | payload_too_large | The request body is over 8 MB. | No | Split the batch. |
| 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. |