Browser API
Render
Fetch a page and get it back in the formats you need.
Body parameters
| Name | Type | Description |
|---|---|---|
urlrequired | string (≤ 2048 chars) | Absolute http:// or https:// URL. No embedded credentials, no private or internal hosts. |
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. 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". |
formats | string[] | Any of html, markdown, text, links, metadata, emails, phones, screenshot, structured. Each becomes a field of the response (see Response fields). screenshot forces a browser render. emails/phones return contact details published on the page; use them only as the Acceptable Use Policy allows. Default: ["html"]. |
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". |
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). |
screenshot | object | Options for the screenshot format: { full_page?: boolean, type?: "jpeg" | "png", quality?: 1–100 }. |
country | string (2 letters) | ISO 3166-1 alpha-2 country for the exit IP. Takes effect on the residential tier (your proxy’s {country} placeholder). |
headers | object | Extra request headers for the target, as { name: value } (at most 25; names are letters, digits and dashes). Host, Content-Length, Connection, proxy and X-Forwarded-* headers are refused. |
cookies | { name, value, domain?, path? }[] | Up to 50 cookies to send. Not for logging in: cookies sent to login, account or checkout pages are refused with 403 forbidden_target. |
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. |
Example
A JavaScript-heavy page in a real browser, escalating through the unblocking tiers if the site pushes back:
curl -X POST https://api.104-236-84-15.sslip.io/v1/render \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/pricing","mode":"browser","unblock":"auto","formats":["markdown","metadata"]}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/render', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://example.com/pricing",
"mode": "browser",
"unblock": "auto",
"formats": [
"markdown",
"metadata"
]
}),
});
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/render",
headers=headers,
json={
"url": "https://example.com/pricing",
"mode": "browser",
"unblock": "auto",
"formats": ["markdown", "metadata"],
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"url": "https://example.com/pricing",
"final_url": "https://example.com/pricing",
"status": 200,
"title": "Pricing – Example",
"markdown": "# Pricing\n…",
"metadata": {
"title": "Pricing – Example",
"description": "Plans for every team.",
"canonical": "https://example.com/pricing",
"lang": "en",
"og": { "title": "Pricing – Example", "type": "website" },
"twitter": {},
"json_ld": [],
"favicon": "https://example.com/favicon.ico",
"robots": null,
"author": null,
"published_time": null,
"keywords": null
},
"tier_used": "stealth",
"mode_used": "browser",
"attempts": [
{ "tier": "none", "status": 403, "ms": 640, "blocked": true, "block_reason": "status_403" },
{ "tier": "stealth", "status": 200, "ms": 2140 }
],
"cached": false,
"credits": 10
}Response fields
Only the formats you asked for are present.
| Name | Type | Description |
|---|---|---|
url | string | The URL you asked for. |
final_url | string | Where the page ended up after redirects. |
status | integer | The target site’s HTTP status. A 404 is a successful render; check this before using the content. |
title | string | null | The page title ("" when the page has none). |
html | string | null | formats: html. The page HTML (after JavaScript in browser mode); null when the response is not HTML. |
markdown | string | formats: markdown. Shaped by markdown_mode. |
text | string | formats: text. Visible text. Very long text is cut, and text_truncated: true is set. |
links | string[] | formats: links. Absolute, de-duplicated link URLs. |
metadata | object | formats: metadata. {title, description, canonical, lang, og: {…}, twitter: {…}, json_ld: […], favicon, robots, author, published_time, keywords}; missing values are null. |
emails | string[] | formats: emails. Email addresses published on the page. |
phones | string[] | formats: phones. Phone numbers published on the page. |
structured | object | formats: structured. See Structured data. |
screenshot_url / screenshot_base64 | string | formats: screenshot. A temporary https link, or the image as base64. |
tier_used | "none" | "stealth" | "residential" | The unblocking tier that served the page. |
proxy_source | "customer" | null | Only when tier_used is residential: "customer" when the page came through your team's own proxy. See Bring your own proxy. |
mode_used | "http" | "browser" | The fetcher that served the page. With mode "auto" it sets the price (http 1 credit, browser 5). |
attempts | object[] | { tier, status, ms, blocked?, block_reason? } for every tier tried, in order. |
cached | boolean | true when served from the response cache (max_age). |
cache_age_s | integer | Only when cached: seconds since the copy was fetched. |
revalidated | boolean | Only when the site confirmed the cached copy (304). |
credits | integer | Credits charged; same as the x-credits-used header. |
If the site answers with a 5xx, you still get 200 from us with that status, and credits: 0: a failing site is not charged.
Structured data
Many pages already carry their data in machine-readable form: schema.org JSON-LD, microdata or RDFa, OpenGraph tags, or the JSON state a JavaScript app was rendered from. The structured format returns all of it from the one fetch, parsed, with no LLM and no extra credits. It works in HTTP mode, which is the cheapest and least likely to meet a CAPTCHA, so try mode: "http" first: embedded state such as __NEXT_DATA__ is in the HTML before any JavaScript runs.
curl -X POST https://api.104-236-84-15.sslip.io/v1/render \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://realty.example/listing/123-maple","mode":"http","formats":["structured"]}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/render', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://realty.example/listing/123-maple",
"mode": "http",
"formats": [
"structured"
]
}),
});
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/render",
headers=headers,
json={
"url": "https://realty.example/listing/123-maple",
"mode": "http",
"formats": ["structured"],
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"structured": {
"records": [
{
"kind": "listing", "type": "RealEstateListing", "source": "json_ld",
"name": "123 Maple St, Springfield, IL 62704", "url": "https://realty.example/listing/123-maple",
"price": 425000, "currency": "USD", "availability": "InStock",
"property_type": "SingleFamilyResidence", "bedrooms": 4, "bathrooms": 2.5,
"floor_size": { "value": 2150, "unit": "FTK" }, "year_built": 1998,
"address": { "street": "123 Maple St", "locality": "Springfield", "region": "IL",
"postal_code": "62704", "country": "US", "formatted": "123 Maple St, Springfield, IL 62704, US" },
"geo": { "lat": 39.7817, "lng": -89.6501 }, "date_posted": "2026-09-14", "image": "https://…", "description": null,
"merged_from": []
},
{
"kind": "business", "type": "RealEstateAgent", "name": "Example Realty",
"phone": "(217) 555-2671", "phone_e164": "+12175552671", "email": "sales@realty.example",
"address": { … }, "geo": null, "hours": [], "url": "https://realty.example/", …
}
],
"json_ld": [ { "@context": "https://schema.org", "@type": "RealEstateListing", … } ],
"microdata": [],
"rdfa": [],
"opengraph": { "og:title": "123 Maple St, Springfield, IL 62704", "og:image": ["https://…", "https://…"] },
"twitter": { "twitter:card": "summary_large_image" },
"app_state": [
{ "source": "next_data", "id": "__NEXT_DATA__", "parsed": true, "bytes": 18234, "data": { "props": { … } } }
],
"truncated": false,
"errors": []
},
"credits": 1
}json_ld: everyapplication/ld+jsonblock, parsed (trailing commas and HTML-comment wrappers tolerated), with@graphflattened into its nodes. Each node keeps its@context.microdataandrdfa: top-levelitemscope/typeofitems in the same JSON-LD-like shape. schema.org types are shortened (Product, not the full URL). RDFa support is RDFa Lite (vocab,typeof,property).opengraph:og:*,product:*,place:*,business:*andarticle:*tags, keyed by the full property name (repeated tags become arrays).twitter:twitter:*tags.app_state: embedded application state.sourceisnext_data(Next.js__NEXT_DATA__),nuxt_data(Nuxt 3, decoded from its devalue format),nuxt(window.__NUXT__),apollo_state(window.__APOLLO_STATE__),window_state(otherwindow.__NAME__ = {…}orJSON.parse("…")assignments) orjson_script(any otherapplication/jsonscript).parsed: falsemeans the value is JavaScript rather than JSON (Nuxt 2's function form, for example) anddatais null.records: one normalized record per listing, business or product found in JSON-LD, microdata or RDFa (de-duplicated across the three). Every record haskind,type,source,name,url,descriptionandimage; missing values arenull.listing(RealEstateListing, SingleFamilyResidence, House, Apartment and other Residence / Accommodation types, and an Offer whoseitemOfferedis one):price,currency,availability,property_type,address,geo,bedrooms,bathrooms,floor_size,lot_size,year_built,date_posted,merged_from. A listing that wraps its home inmainEntity,aboutoritemOffered(inline or by@id) becomes one merged record.
Some sites (Redfin, for example) publish each home twice: a Residence with the address but no price, and next to it a Product or Offer with the same name and URL that carries the price. When a product describes the same home as a listing (the same@id, the same URL ignoringwww., a trailing slash and the fragment, or the same name on the same host) and exactly one listing matches, the two become onelisting: its emptyprice,currency,availability,name,url,descriptionandimageare filled from the product, the product record is dropped, andmerged_fromlists what was folded in ([{kind, type, source, url}], empty otherwise). Products that match no listing stay products.business(Organization, LocalBusiness and its subtypes):address,phone,phone_e164,email,geo,hours({days, opens, closes}from openingHoursSpecification, or{text}from openingHours),price_range,same_as.product(Product, ProductGroup, Vehicle, …, and a standalone Offer):price,currency,availability(e.g.InStock),condition,low_price,high_price,offer_count,sku,gtin,mpn,brand,rating({value, count}).
"425,000"becomes425000);addressis{street, locality, region, postal_code, country, formatted}andgeois{lat, lng}. The raw blocks are always there too, for fields the records do not cover.- Size cap: the whole
structuredobject is capped at about 1 MB. A block that does not fit is shrunk (long strings and arrays are cut) or itsdatais dropped; that block getstruncated: trueand its original size inbytes, and the top-leveltruncatedis true. Records come first, raw app state last.errorslists blocks that could not be parsed (an invalid JSON-LD block does not fail the request).
Caching
Fetching the same page again and again is slow for you and noisy for the site. Send max_age (seconds, up to a day) to opt in:
- If your account made the same request (same URL and options) less than
max_ageseconds ago, you get that result back without us contacting the site:"cached": true,cache_age_s, thex-cache: HITheader, and 0 credits. - If the copy is older and the site sent an
ETagorLast-Modified, we ask the site whether it changed (a conditional GET, HTTP fetches only). If it did not (304 Not Modified), you get the copy with"revalidated": trueandx-cache: REVALIDATEDfor 1 credit plus the tier add-on. Otherwise we fetch the page normally and store it. max_age: 0always fetches but stores the result for later requests. Withoutmax_agenothing is read from or written to the cache.- Only successful (2xx) results are stored. Cached pages are scoped to your team and kept in memory and the database. Entries expire within 24 hours of the original fetch; accessing or revalidating a copy does not extend that deadline. Database TTL deletion runs asynchronously and can lag expiry. Entries may drop early under load. The default entry cap is 8 MiB, with a 1 MiB database cap for screenshots and PDFs; oversized entries are skipped in full.
- Identical concurrent cacheable requests within your team share one fetch. Followers are charged as cache hits. Schema extraction still runs and is charged separately for each request.
Polite fetching
Sites escalate to CAPTCHAs and blocks when a client looks abusive. We fetch politely on your behalf, which keeps your success rate up:
- Requests to any one site are spaced out and capped in concurrency, across all customers.
- If the site answers
429, or503withRetry-After, we stop: you get429 target_rate_limitedwithretry_after(free), and requests to that site pause for everyone until then instead of retrying or escalating tiers. - Network errors and
502/503/504are retried a couple of times with exponential backoff and jitter, within yourtimeout_ms. - With
respect_robots: truewe check robots.txt first and honor itsCrawl-delay.
Credits
- HTTP mode: 1; browser mode: 5. With
mode: "auto"you pay for the mode the engine used.wait_for,wait_msand thescreenshotformat need a browser, so they cost the browser price. - Plus the tier that succeeded: stealth +5, residential through your own proxy +0. Blocked attempts before it are free.
- Formats are free:
structured,metadataor all nine cost the same as the render alone. - Served from the cache (
max_age): 0. Revalidated (304): 1 plus the tier add-on. - The example above (browser + stealth) costs 10 credits. A 404 from the target is a successful render and is charged; a target 5xx and all of our errors are not.
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". |
Private and internal addresses, denylisted sites and login, checkout or account pages are refused with 403 forbidden_target. See the Acceptable Use Policy.