Skip to content
Docs menu

Browser API

Render

Fetch a page and get it back in the formats you need.

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

Body parameters

Render parameters
NameTypeDescription
urlrequiredstring (≤ 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".
formatsstring[]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_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).
screenshotobjectOptions for the screenshot format: { full_page?: boolean, type?: "jpeg" | "png", quality?: 1–100 }.
countrystring (2 letters)ISO 3166-1 alpha-2 country for the exit IP. Takes effect on the residential tier (your proxy’s {country} placeholder).
headersobjectExtra 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_msinteger (1000–120000)Time budget for the whole request, shared by every unblock tier tried. Running out returns 504 timeout (not charged). Default: 30000.
max_ageinteger (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_robotsbooleanCheck 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.
actionsobject[] (≤ 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
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"]}'
JavaScript (fetch)
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'));
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/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"])
200 OK (abridged)
{
  "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.

Render response fields
NameTypeDescription
urlstringThe URL you asked for.
final_urlstringWhere the page ended up after redirects.
statusintegerThe target site’s HTTP status. A 404 is a successful render; check this before using the content.
titlestring | nullThe page title ("" when the page has none).
htmlstring | nullformats: html. The page HTML (after JavaScript in browser mode); null when the response is not HTML.
markdownstringformats: markdown. Shaped by markdown_mode.
textstringformats: text. Visible text. Very long text is cut, and text_truncated: true is set.
linksstring[]formats: links. Absolute, de-duplicated link URLs.
metadataobjectformats: metadata. {title, description, canonical, lang, og: {…}, twitter: {…}, json_ld: […], favicon, robots, author, published_time, keywords}; missing values are null.
emailsstring[]formats: emails. Email addresses published on the page.
phonesstring[]formats: phones. Phone numbers published on the page.
structuredobjectformats: structured. See Structured data.
screenshot_url / screenshot_base64stringformats: 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" | nullOnly 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).
attemptsobject[]{ tier, status, ms, blocked?, block_reason? } for every tier tried, in order.
cachedbooleantrue when served from the response cache (max_age).
cache_age_sintegerOnly when cached: seconds since the copy was fetched.
revalidatedbooleanOnly when the site confirmed the cached copy (304).
creditsintegerCredits 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
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"]}'
JavaScript (fetch)
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'));
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/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"])
200 OK (abridged)
{
  "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: every application/ld+json block, parsed (trailing commas and HTML-comment wrappers tolerated), with @graph flattened into its nodes. Each node keeps its @context.
  • microdata and rdfa: top-level itemscope / typeof items 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:* and article:* tags, keyed by the full property name (repeated tags become arrays). twitter: twitter:* tags.
  • app_state: embedded application state. source is next_data (Next.js __NEXT_DATA__), nuxt_data (Nuxt 3, decoded from its devalue format), nuxt (window.__NUXT__), apollo_state (window.__APOLLO_STATE__), window_state (other window.__NAME__ = {…} or JSON.parse("…") assignments) or json_script (any other application/json script). parsed: false means the value is JavaScript rather than JSON (Nuxt 2's function form, for example) and data is null.
  • records: one normalized record per listing, business or product found in JSON-LD, microdata or RDFa (de-duplicated across the three). Every record has kind, type, source, name, url, description and image; missing values are null.
    • listing (RealEstateListing, SingleFamilyResidence, House, Apartment and other Residence / Accommodation types, and an Offer whose itemOffered is 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 in mainEntity, about or itemOffered (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 ignoring www., a trailing slash and the fragment, or the same name on the same host) and exactly one listing matches, the two become one listing: its empty price, currency, availability, name, url, description and image are filled from the product, the product record is dropped, and merged_from lists 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}).
    Prices are numbers ("425,000" becomes 425000); address is {street, locality, region, postal_code, country, formatted} and geo is {lat, lng}. The raw blocks are always there too, for fields the records do not cover.
  • Size cap: the whole structured object is capped at about 1 MB. A block that does not fit is shrunk (long strings and arrays are cut) or its data is dropped; that block gets truncated: true and its original size in bytes, and the top-level truncated is true. Records come first, raw app state last. errors lists 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_age seconds ago, you get that result back without us contacting the site: "cached": true, cache_age_s, the x-cache: HIT header, and 0 credits.
  • If the copy is older and the site sent an ETag or Last-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": true and x-cache: REVALIDATED for 1 credit plus the tier add-on. Otherwise we fetch the page normally and store it.
  • max_age: 0 always fetches but stores the result for later requests. Without max_age nothing 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, or 503 with Retry-After, we stop: you get 429 target_rate_limited with retry_after (free), and requests to that site pause for everyone until then instead of retrying or escalating tiers.
  • Network errors and 502/503/504 are retried a couple of times with exponential backoff and jitter, within your timeout_ms.
  • With respect_robots: true we check robots.txt first and honor its Crawl-delay.

Credits

  • HTTP mode: 1; browser mode: 5. With mode: "auto" you pay for the mode the engine used. wait_for, wait_ms and the screenshot format 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, metadata or 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

Render errors
StatusCodeWhenChargedWhat to do
400invalid_requestA parameter is missing, invalid or unknown (the message and error.issues name it).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 URL is denylisted, resolves (or redirects) to a private or internal address, or sends cookies or credentials to a login, account or checkout page.NoDo not retry. See the Acceptable Use Policy.
403robots_disallowedrespect_robots is on (default for crawl and batch) and the site’s robots.txt disallows the URL for PageteerBot.NoSkip the URL. Turn respect_robots off only for sites you own or may crawl.
403org_suspendedThe account is suspended.NoContact support.
403feature_not_in_planSchema extraction on the free mode or while it is not available on this deployment.NoUse selectors or structured, or add a card.
403proxy_not_configuredunblock "residential" without a team proxy saved in Settings → Proxy (404 from POST /v1/proxy/test).NoSave your proxy URL in Settings → Proxy (see Bring your own proxy), or use another tier.
413payload_too_largeThe request body is over 64 KB (8 MB for POST /v1/batch), or a crawl is too large to export in one file.NoSend less, or split a batch; read a large crawl with GET /v1/crawl/:id.
422target_blockedThe site blocked every tier we tried (with unblock "auto", the tiers this deployment offers). block_reason, block_vendor and attempts say how.NoTry a stronger unblock tier (or "auto"); if auto already failed, retry much later.
422captcha_requiredThe site answered with a CAPTCHA or bot challenge. We never solve them and try no further tier.NoDo not retry soon. Use the site’s API or data feed if it has one.
422login_requiredThe site redirected to a login or sign-in page (final_url says where). We never log in.NoThe content is not public: skip it.
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.
429concurrency_limitToo many of your requests are in flight at once.NoWait for one to finish (Retry-After: 1) or lower your parallelism.
429target_rate_limitedThe 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.NoWait error.retry_after seconds (also in Retry-After).
502proxy_failedYour proxy failed: reason "auth" (it rejected the credentials), "unreachable", "status" (it refused the connection) or "blocked" (its host resolves to a private address).NoFix the URL or credentials in Settings → Proxy and test it. "unreachable" and "status" may pass on retry.
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.
504timeoutThe site did not answer within timeout_ms.NoRetry, 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.