Browser API
Extract
Turn a page into structured data: what it already embeds, a ready-made template, CSS selectors, or a JSON Schema.
Body parameters
| Name | Type | Description |
|---|---|---|
urlrequired | string (≤ 2048 chars) | Absolute http:// or https:// URL. No embedded credentials, no private or internal hosts. |
structured | true | Return the data the page embeds (JSON-LD, microdata, RDFa, OpenGraph, app state such as __NEXT_DATA__) and normalized listing, business and product records. No LLM, render cost. |
selectors | object (1–100 fields) | Map of field name → CSS selector, or → { selector, attr?, all?, html? }. Deterministic; see With selectors. |
template | "lead" | "product" | "article" | "job" | A ready-made extraction. Fields come from the page's markup first; the LLM runs only when a required field is missing (+40 credits, only when it ran). See Templates. |
llm_fallback | boolean | With template: false never calls the LLM (markup only, never charged for it). Without LLM extraction in your plan the template runs markup-only anyway. Default: true. |
schema | JSON Schema | Describe the data you want and an LLM fills it from the page (+40 credits). Pay-as-you-go and invoiced modes only; may be turned off while in preview. |
mode | "auto" | "http" | "browser" | As for render. http is cheapest and is usually enough for structured and selectors. Default: "auto". |
unblock | "none" | "stealth" | "residential" | "auto" | Unblocking tier. residential goes through your team's own proxy. auto escalates none → stealth → residential (when your team has a proxy) and charges only for the tier that succeeded. See Unblocking tiers. Default: "none". |
wait_for | string (≤ 500 chars) | CSS selector to wait for before capturing. Implies a browser render. |
markdown_mode | "main" | "full" | With schema or template: the page text the LLM (and the article template's body) reads. full keeps listing grids that main can drop. Default: "main". |
timeout_ms | integer (1000–120000) | Time budget for the whole request, shared by every unblock tier tried. Running out returns 504 timeout (not charged). Default: 30000. |
max_age | integer (seconds, 0–86400) | Opt-in cache. If your account made the same request (same URL and options) within max_age seconds, you get that response back without us contacting the site, for 0 credits. An older copy is revalidated with a conditional request when the site supports it. 0 always fetches but stores the result. Omit it and the cache is not used. See Render → Caching. |
respect_robots | boolean | Check the site’s robots.txt (user agent PageteerBot) first. A disallowed URL fails with 403 robots_disallowed and is not charged; a Crawl-delay spaces our requests to that site. Default: false. |
actions | object[] (≤ 20) | Read-only page actions run in the browser after the page loads: wait, wait_for, scroll, click ("Load more", tabs, accordions), press and screenshot. Forces a browser render. No typing, form submission or logins. See Page actions. |
Send exactly one of structured, template, selectors or schema (template and schema together are a 400). For leads, products, articles and job postings start with a template: it reads the page's schema.org markup, which is exact and costs no more than the fetch, and pays for the LLM only when that markup is missing.
Embedded structured data
curl -X POST https://api.104-236-84-15.sslip.io/v1/extract \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example/p/trail-runner-3","structured":true,"mode":"http"}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://shop.example/p/trail-runner-3",
"structured": true,
"mode": "http"
}),
});
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/extract",
headers=headers,
json={
"url": "https://shop.example/p/trail-runner-3",
"structured": True,
"mode": "http",
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"data": {
"records": [
{ "kind": "product", "type": "Product", "source": "json_ld", "name": "Trail Runner 3",
"price": 129.99, "currency": "USD", "availability": "InStock", "low_price": 129.99,
"high_price": 139.99, "offer_count": 2, "sku": "TR3-BLK-42", "gtin": "0123456789012",
"brand": "Outdoor Co.", "rating": { "value": 4.6, "count": 212 }, … }
],
"json_ld": [ … ], "microdata": [ … ], "rdfa": [], "opengraph": { … }, "twitter": {},
"app_state": [ … ], "truncated": false, "errors": []
},
"url": "https://shop.example/p/trail-runner-3",
"final_url": "https://shop.example/p/trail-runner-3",
"status": 200,
"title": "Trail Runner 3 – Outdoor Co.",
"tier_used": "none",
"mode_used": "http",
"attempts": [{ "tier": "none", "status": 200, "ms": 310 }],
"cached": false,
"credits": 1
}data has the same shape as the render structured format; see Structured data for every field.
Templates
A template returns the same fields for every page of its kind. Each field is read, in order, from JSON-LD, then microdata, then OpenGraph and meta tags, then the page itself (mailto: and tel: links, social and contact links, headings, <time>, the page Markdown). Only if a required field is still empty does the LLM run, once, for the empty fields; it never replaces a value the markup gave. Fields nothing supplied are null (or []).
| template | Required (LLM if missing) | Fields |
|---|---|---|
leadv1 | name | name, description, website, logo, emails, phones, address, social_profiles, contact_page_url, people |
productv1 | name, price | name, brand, description, url, price, currency, availability, sku, gtin, mpn, images, rating, review_count |
articlev1 | title, body_markdown | title, description, author, publisher, published_at, updated_at, body_markdown, images, canonical_url, keywords |
jobv1 | title, company | title, company, company_url, location, remote, employment_types, salary, posted_at, valid_through, apply_url, description |
GET /v1/extract/templates returns each template's fields and its JSON Schema (draft 2020-12). A template's version changes only when its output changes incompatibly; new fields can be added within a version.
curl -X POST https://api.104-236-84-15.sslip.io/v1/extract \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example/p/trail-runner-3","template":"product","mode":"http"}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://shop.example/p/trail-runner-3",
"template": "product",
"mode": "http"
}),
});
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/extract",
headers=headers,
json={
"url": "https://shop.example/p/trail-runner-3",
"template": "product",
"mode": "http",
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"data": {
"name": "Trail Runner 3", "brand": "Outdoor Co.", "price": 129.99, "currency": "USD",
"availability": "in_stock", "sku": "TR3-BLK-42", "gtin": "0123456789012", "mpn": null,
"images": ["https://shop.example/img/tr3.jpg"], "rating": 4.6, "review_count": 212, …
},
"template": { "id": "product", "version": 1 },
"method": "deterministic",
"sources": { "name": "json_ld", "price": "json_ld", "images": "opengraph", "url": "meta", … },
"missing": [],
"llm_used": false,
"status": 200,
"mode_used": "http",
"credits": 1,
…
}sourcessays where each field came from:json_ld,microdata,opengraph,meta,html,markdown,page(the URL itself) orllm.methodisdeterministic(no LLM value),hybrid(markup plus LLM) orllm.llm_usedis true when the LLM ran, and only then are its credits incredits.missinglists required fields that are still empty. When the LLM could have filled them but did not run,llm_skippedsays why:disabled(llm_fallback: false),not_in_plan,unavailableortarget_status(the site answered with a4xx, so the LLM is not paid for).
With selectors
curl -X POST https://api.104-236-84-15.sslip.io/v1/extract \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.iana.org/domains/reserved","mode":"http","selectors":{"title":"h1","sections":{"selector":"h2","all":true},"rfc_links":{"selector":"a[href*=rfc]","attr":"href","all":true}}}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://www.iana.org/domains/reserved",
"mode": "http",
"selectors": {
"title": "h1",
"sections": {
"selector": "h2",
"all": true
},
"rfc_links": {
"selector": "a[href*=rfc]",
"attr": "href",
"all": true
}
}
}),
});
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/extract",
headers=headers,
json={
"url": "https://www.iana.org/domains/reserved",
"mode": "http",
"selectors": {
"title": "h1",
"sections": {
"selector": "h2",
"all": True,
},
"rfc_links": {
"selector": "a[href*=rfc]",
"attr": "href",
"all": True,
},
},
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"data": {
"title": "IANA-managed Reserved Domains",
"sections": ["Example domains", "Test IDN top-level domains", "Policy-reserved domains", "Other Special-Use Domains", "Domain Names"],
"rfc_links": ["https://www.iana.org/go/rfc2606", "https://www.iana.org/go/rfc6761", "https://www.iana.org/go/rfc6761"]
},
"url": "https://www.iana.org/domains/reserved",
"final_url": "https://www.iana.org/domains/reserved",
"status": 200,
"title": "IANA-managed Reserved Domains",
"tier_used": "none",
"mode_used": "http",
"attempts": [{ "tier": "none", "status": 200, "ms": 249 }],
"cached": false,
"credits": 1
}- A string selector returns the trimmed text of the first match (script, style and noscript text inside it is left out).
{selector, all: true}returns an array of every match;{selector, attr: "href"}returns an attribute instead of text (hrefandsrcare resolved to absolute URLs againstfinal_url);{selector, html: true}returns inner HTML. They combine.- A selector that matches nothing gives
null(or[]withall). Selecting ascriptelement itself (for example a JSON-LD block) returns its contents. An invalid CSS selector is a400.
Response fields
Every extract response carries url (what you asked for), final_url (after redirects), the target's status, the page title, tier_used, attempts and credits. Check status and final_url before trusting data: a 404 page or a redirect to a sign-in page still yields data, just not the data you wanted. If the site answers with a 5xx, you get 200 with that status and credits: 0, like render; with schema or template the LLM is not run on the error page and data is null. mode_used says whether the page was fetched over HTTP or in a browser.
With a JSON Schema
curl -X POST https://api.104-236-84-15.sslip.io/v1/extract \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/product/42","schema":{"type":"object","properties":{"name":{"type":"string"},"price":{"type":"number"},"in_stock":{"type":"boolean"}}}}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://example.com/product/42",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"price": {
"type": "number"
},
"in_stock": {
"type": "boolean"
}
}
}
}),
});
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/extract",
headers=headers,
json={
"url": "https://example.com/product/42",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
},
"price": {
"type": "number",
},
"in_stock": {
"type": "boolean",
},
},
},
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"data": { "name": "Example Widget", "price": 19, "in_stock": true },
"url": "https://example.com/product/42",
"final_url": "https://example.com/product/42",
"status": 200,
"title": "Example Widget",
"tier_used": "none",
"mode_used": "browser",
"attempts": [{ "tier": "none", "status": 200, "ms": 1840 }],
"cached": false,
"credits": 45
}The LLM reads the page title and Markdown (markdown_mode decides how much). Very long pages are cut to fit, and the response then has input_truncated: true. A failed extraction is 502 internal_error and is not charged.
LLM extraction can be wrong. Validate the output before you rely on it.
Credits
The page fetch costs the same as a render (HTTP 1, browser 5, plus any tier add-on). structured and selectors add nothing. Schema extraction adds 40. The schema example in a browser costs 45 credits. A template adds nothing when the markup has every required field and 40 only when the LLM ran (llm_used); the request needs room for that add-on up front, and what was not used is returned right away.
Errors
| Status | Code | When | Charged | What to do |
|---|---|---|---|---|
| 400 | invalid_request | A parameter is missing, invalid or unknown (the message and error.issues name it). | 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 | robots_disallowed | respect_robots is on (default for crawl and batch) and the site’s robots.txt disallows the URL for PageteerBot. | No | Skip the URL. Turn respect_robots off only for sites you own or may crawl. |
| 403 | org_suspended | The account is suspended. | No | Contact support. |
| 403 | feature_not_in_plan | Schema extraction on the free mode or while it is not available on this deployment. | No | Use selectors or structured, or add a card. |
| 403 | proxy_not_configured | unblock "residential" without a team proxy saved in Settings → Proxy (404 from POST /v1/proxy/test). | No | Save your proxy URL in Settings → Proxy (see Bring your own proxy), or use another tier. |
| 413 | payload_too_large | The request body is over 64 KB (8 MB for POST /v1/batch), or a crawl is too large to export in one file. | No | Send less, or split a batch; read a large crawl with GET /v1/crawl/:id. |
| 422 | target_blocked | The site blocked every tier we tried (with unblock "auto", the tiers this deployment offers). block_reason, block_vendor and attempts say how. | No | Try a stronger unblock tier (or "auto"); if auto already failed, retry much later. |
| 422 | captcha_required | The site answered with a CAPTCHA or bot challenge. We never solve them and try no further tier. | No | Do not retry soon. Use the site’s API or data feed if it has one. |
| 422 | login_required | The site redirected to a login or sign-in page (final_url says where). We never log in. | No | The content is not public: skip it. |
| 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. |
| 429 | concurrency_limit | Too many of your requests are in flight at once. | No | Wait for one to finish (Retry-After: 1) or lower your parallelism. |
| 429 | target_rate_limited | The site asked us to slow down (429, or 503 with Retry-After), or per-site pacing would not let your request start in time. We pause that site for everyone. | No | Wait error.retry_after seconds (also in Retry-After). |
| 502 | proxy_failed | Your proxy failed: reason "auth" (it rejected the credentials), "unreachable", "status" (it refused the connection) or "blocked" (its host resolves to a private address). | No | Fix the URL or credentials in Settings → Proxy and test it. "unreachable" and "status" may pass on retry. |
| 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. |
| 504 | timeout | The site did not answer within timeout_ms. | No | Retry, maybe with a larger timeout_ms or mode "http". |