Skip to content
Docs menu

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.

POST https://api.104-236-84-15.sslip.io/v1/batch

Body parameters

Batch parameters
NameTypeDescription
urlsstring[]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.
formatsstring[]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_forstring (≤ 500 chars)CSS selector to wait for before capturing. Implies a browser render.
wait_msinteger (0–30000)Extra milliseconds to wait after the page loads (browser mode).
countrystring (2 letters)Exit-IP country, as for render (residential tier).
headersobjectExtra request headers for every URL, as for render. Batches do not take cookies.
timeout_msinteger (1000–120000)Per URL, as for render. A URL that times out is retried. Default: 30000.
respect_robotsbooleanCheck 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_ageinteger (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.
metadataobjectAny 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
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"}}'
JavaScript (fetch)
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'));
Python (requests)
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"])
202 Accepted
{
  "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
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.json

Check progress

GET https://api.104-236-84-15.sslip.io/v1/batch/:id
200 OK
{
  "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, failed or canceled. A batch is done when every URL has finished, whether it succeeded or not; stop polling at any of the last three.
  • counts are per URL status; outcomes counts URLs per outcome code. New codes may appear over time, so treat the keys as open-ended.
  • error is 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

GET https://api.104-236-84-15.sslip.io/v1/batch/:id/results
Results query parameters
NameTypeDescription
format"json" | "jsonl" | "csv"json returns one page of items; jsonl and csv stream the whole batch as a download. Default: "json".
statusstringOnly items with this status: queued, running, succeeded, failed or canceled.
afterintegerCursor: only items with index greater than this. Pass the previous page’s next. Default: -1.
limitinteger (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
curl "https://api.104-236-84-15.sslip.io/v1/batch/6ac0f917934df03715b56c42/results?limit=2" \
  -H "Authorization: Bearer $PAGETEER_API_KEY"
JavaScript (fetch)
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'));
Python (requests)
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"])
200 OK (markdown trimmed)
{
  "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

Batch item fields
NameTypeDescription
indexintegerPosition in your urls/items array (0-based). Also the cursor.
idstring | nullYour items[].id, echoed back.
urlstringThe URL as you sent it.
statusstringqueued, running, succeeded, failed or canceled.
outcomestring | nullok for a fetched page, otherwise the code that ended it (see Outcomes). Open-ended. null while open.
http_statusinteger | nullThe site’s HTTP status (a 404 is still outcome ok: check it).
final_urlstring | nullAfter redirects.
titlestring | nullPage title.
tier_usedstring | nullUnblocking tier that served the page.
creditsintegerCredits this URL cost (0 unless it succeeded).
attemptsintegerFetch attempts made (retries included).
error{ code, message } | nullWhy the URL failed or was canceled.
metadataanyYour items[].metadata, echoed back.
resultobject | nullThe 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_atstring | nullISO 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
curl "https://api.104-236-84-15.sslip.io/v1/batch/6ac0f917934df03715b56c42/results?format=jsonl" \
  -H "Authorization: Bearer $PAGETEER_API_KEY" -o results.jsonl
CSV (include_content=false)
index,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

GET https://api.104-236-84-15.sslip.io/v1/batch

Your most recent batches, newest first. limit: 1–100, default 20 (anything else is a 400).

200 OK
{
  "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

DELETE https://api.104-236-84-15.sslip.io/v1/batch/:id

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:

Common item outcomes
StatusCodeWhenCharged
—okFetched. The site may still have answered 404: see http_status.Yes
422target_blockedThe site blocked every tier we tried, or answered with a block page or an empty response. Try unblock: "auto".No
422captcha_requiredThe site answered with a CAPTCHA or bot challenge.No
422login_requiredThe site redirected to a login page.No
403robots_disallowedThe site’s robots.txt disallows the URL (respect_robots).No
429target_rate_limitedThe site kept asking us to slow down after 10 waits.No
504timeoutStill timing out after the last retry.No
—target_errorThe site kept answering 5xx after the last retry (http_status has the code).No
403forbidden_targetDenylisted, private or internal URL.No
400invalid_requestNot an absolute http(s) URL, or the domain has no DNS record. (A temporary DNS failure is retried.)No
402insufficient_creditsCredits ran out (or spend_limit_reached: the spend limit was hit); the batch stopped here. error.reason may say why.No
—canceledThe 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):

Batch errors
StatusCodeWhenChargedWhat to do
400invalid_requestA 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.NoFix the request; retrying it unchanged fails again.
401invalid_api_keyNo key, an unknown key, or a revoked or expired key.NoCheck the Authorization header and the key on the API keys page.
402insufficient_creditsNot 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.NoAct on the reason at error.billing_url: add a card, update the payment method, or restart the subscription. Or wait for the 1st (UTC).
402spend_limit_reachedBillable usage is on and this month’s spend limit is reached (error.reason: spend_limit).NoRaise the spend limit at error.billing_url (the Billing page), or wait for the 1st (UTC).
403forbidden_targetThe webhook URL is private, internal or denylisted.NoDo not retry. See the Acceptable Use Policy.
403org_suspendedThe account is suspended.NoContact support.
403proxy_not_configuredunblock "residential" without a team proxy (Settings → Proxy).NoSave your proxy URL in Settings → Proxy (see Bring your own proxy), or use another tier.
404not_foundNo batch with that id in your account.NoCheck the path and id.
409idempotency_conflictAn Idempotency-Key was reused with a different request body (POST /v1/batch or /v1/crawl). error.job_id names the original job.NoUse a new key for a new job.
413payload_too_largeThe request body is over 8 MB.NoSplit the batch.
429rate_limitedOver your requests per minute, or too many requests to one site across the platform right now.NoWait Retry-After seconds, then back off with jitter.
500 / 502 / 503internal_errorSomething failed on our side (rendering engine, LLM extraction or the API itself), or the rendering engine is at capacity (503, Retry-After: 5).NoRetry with backoff (honor Retry-After); quote x-request-id if it persists.