Clients
JavaScript client
@pageteer/client is a small TypeScript client for the whole API with no runtime dependencies. It runs on Node 18+, Bun, Deno, edge runtimes and in browsers (server-side keys only), using the global fetch. It ships as ESM with type definitions.
Install
The package is not on npm yet. Install it from the Pageteer repository (clients/js); installing builds it:
npm install /path/to/pageful/clients/js
# or in package.json: "@pageteer/client": "file:../pageful/clients/js"Create a client
import { Pageteer, PageteerError } from '@pageteer/client';
const pageteer = new Pageteer({
apiKey: process.env.PAGETEER_API_KEY, // default: PAGETEER_API_KEY
baseUrl: 'https://api.104-236-84-15.sslip.io', // default: PAGETEER_BASE_URL, then https://api.pageteer.com ("/v1" is added)
timeoutMs: 150_000, // per attempt
maxRetries: 2, // 429 (honoring Retry-After), 5xx and network errors
});| Name | Type | Description |
|---|---|---|
apiKey | string | pk_live_… key, or a free pk_test_… test key. Without one (and no env var) the constructor throws missing_api_key. |
baseUrl | string | API origin, with or without /v1. |
timeoutMs | number | Per-attempt timeout (above the API’s 120 s maximum). Default: 150000. |
maxRetries | number | Retries on 429, 5xx and network errors. Default: 2. |
maxRetryAfterMs | number | Longest Retry-After the client sleeps through; a longer one is thrown at once. Default: 60000. |
retryBaseDelayMs / retryMaxDelayMs | number | Jittered exponential backoff. Default: 500 / 8000. |
fetch | typeof fetch | Custom fetch implementation. |
headers | Record<string, string> | Headers sent with every request. |
Every method also takes request options as its last argument: { signal, timeoutMs, maxRetries, headers }.
Search
const r = await pageteer.search('best pizza minneapolis', { n: 5, country: 'us' });
for (const hit of r.results) console.log(hit.position, hit.title, hit.url);
console.log(r.meta.creditsUsed, r.meta.creditsRemaining, r.meta.cache); // 2, 964, "MISS"Options: n, engines (an array or a comma list), country, language, location, page, max_age. See Search.
Render, screenshot, PDF
const page = await pageteer.render('https://example.com', {
formats: ['markdown', 'links', 'metadata'],
mode: 'auto', // http | browser | auto
unblock: 'auto', // none | stealth | residential | auto
max_age: 3600, // accept a cached copy up to 1 h old (0 credits)
});
console.log(page.status, page.title, page.markdown, page.tier_used, page.credits);
// Binary by default: { data: Uint8Array, contentType, tierUsed, targetStatus, meta }
const shot = await pageteer.screenshot('https://example.com', { full_page: true });
await fs.promises.writeFile('shot.png', shot.data);
// Or JSON with base64: { screenshot_base64, content_type, clipped, credits, … }
const json = await pageteer.screenshot('https://example.com', { as: 'json' });
const bytes = Pageteer.decodeBase64(json.screenshot_base64!);
// A phone, one element, WebP, cookie banner hidden, dark theme (DEVICE_NAMES lists the presets)
const card = await pageteer.screenshot('https://example.com/pricing', {
device: 'iphone-15', selector: '#plans', format: 'webp',
hide_selectors: ['#onetrust-banner-sdk'], dark_mode: true,
});
// Full page, cut at 8,000 CSS px: shot.clipped says whether the page was taller
const tall = await pageteer.screenshot('https://example.com', { full_page: true, max_height: 8000 });
const pdf = await pageteer.pdf('https://example.com', { format: 'Letter', print_background: true });Extract
const { data, status } = await pageteer.extract<{ title: string; links: string[] }>({
url: 'https://example.com',
selectors: { title: 'h1', links: { selector: 'a', attr: 'href', all: true } },
});
await pageteer.extract({ url, structured: true }); // JSON-LD, microdata, OpenGraph, records; no LLM
await pageteer.extract({ url, schema: { type: 'object', properties: { … } } }); // LLM, +40 credits
// Templates: markup first, the LLM only for missing required fields; data is typed (ProductData, …)
const product = await pageteer.extract({ url, template: 'product' });
console.log(product.data.price, product.sources, product.llm_used);
const { templates } = await pageteer.extractTemplates(); // fields + JSON SchemasMap
// A site's URLs from its sitemaps and the start page's links, without fetching them (1 credit)
const { urls, truncated } = await pageteer.map('https://example.com', { search: 'blog', limit: 500 });
urls.forEach(u => console.log(u.url, u.source, u.lastmod));Crawl
// Start, then wait (polls with backoff until done, failed or canceled)
const { job_id } = await pageteer.crawl.start({ url: 'https://example.com', max_pages: 20, include: ['/blog/**'] });
const job = await pageteer.crawl.wait(job_id, {
intervalMs: 1000, maxIntervalMs: 10_000, timeoutMs: 600_000,
onPoll: s => console.log(s.status, s.progress),
});
// Or both in one call
const done = await pageteer.crawl.run({ url: 'https://example.com', max_pages: 5 });
done.pages.forEach(p => console.log(p.url, p.title, p.markdown?.length));
await pageteer.crawl.get(job_id);
await pageteer.crawl.cancel(job_id);
// Export a finished crawl (free): markdown | llms.txt | jsonl as text, zip as bytes
const { text: llms } = await pageteer.crawl.export(job_id, 'llms.txt');
const zip = await pageteer.crawl.export(job_id, 'zip');
await fs.promises.writeFile(zip.filename ?? 'site.zip', zip.data);wait throws PageteerError with code wait_timeout after timeoutMs (default: no limit). It returns as soon as the job is final, so check job.status and job.error.
Batch
const b = await pageteer.batch.create(
{ urls: ['https://a.example', 'https://b.example'], formats: ['markdown'] },
{ idempotencyKey: 'nightly-2026-10-02' }, // optional: a random key is sent by default
);
if (b.replayed) console.log('this batch already existed');
await pageteer.batch.wait(b.job_id);
// Every result, following the cursor for you
for await (const item of pageteer.batch.iterResults(b.job_id, { status: 'succeeded' })) {
console.log(item.url, item.result?.markdown);
}
const page = await pageteer.batch.results(b.job_id, { limit: 100, after: -1 }); // { items, next }
const csv = await pageteer.batch.export(b.job_id, 'csv'); // { text, meta }; or 'jsonl'
await pageteer.batch.list({ limit: 20 });
await pageteer.batch.cancel(b.job_id);Monitors
const m = await pageteer.monitors.create({
url: 'https://example.com/pricing',
interval: 'daily',
selector: '#plans', // or selectors: { price: '.price' } for a field diff
ignore: ['Updated * ago'],
webhook: { url: 'https://your-app.example.com/hooks/pageteer' }, // m.webhook_secret is shown once
});
const now = await pageteer.monitors.check(m.id); // { baseline, changed, change, credits, monitor }
for await (const change of pageteer.monitors.iterChanges(m.id)) console.log(change.at, change.diff);
await pageteer.monitors.pause(m.id); // resume, update, list, get, deletemonitors.create and monitors.check are not retried on a 5xx, so a lost response never creates a second monitor or charges a second check. See Monitors.
Usage
const u = await pageteer.usage();
// { period, plan, granted, used, remaining, overage_cap, billable, spend_limit_cents, estimated_cost_cents, resets_at, meta }
// Broken down per key, project, UTC day or endpoint (up to 400 days; default this month)
const byKey = await pageteer.usage({ group: 'key', from: '2026-09-01', to: '2026-09-30' });
for (const g of byKey.groups ?? []) console.log(g.key_name, g.project, g.totals.requests, g.totals.credits);group is 'key', 'project', 'day' or 'endpoint'; see Usage for the fields. Request options can go in the same object.
Test keys
With a test key every call is free and returns a deterministic fake. The client tells you which kind of key and response you have:
import { Pageteer, isTestKey } from '@pageteer/client';
isTestKey('pk_test_…'); // true (prefix check only)
const pageteer = new Pageteer({ apiKey: process.env.PAGETEER_TEST_KEY });
pageteer.testMode; // true for a pk_test_ key
const page = await pageteer.render('https://example.com');
page.test; // true in JSON bodies of test responses
page.meta.test; // true from the x-pageteer-test header (binary captures too)Proxy test
Check your team's own proxy (free). Responses served through it carry proxy_source: "customer".
const t = await pageteer.proxyTest({ country: 'de' }); // POST /v1/proxy/test
// { ok, ip, country, ms, error: { code, reason, message } | null, meta }
// No team proxy: throws PageteerError with code proxy_not_configured (404)Response metadata
Every JSON result has a meta field built from the response headers (binary captures carry it too):
interface ResponseMeta {
status: number;
requestId: string | null; // x-request-id
creditsUsed: number | null; // x-credits-used
creditsRemaining: number | null; // x-credits-remaining
cache: string | null; // x-cache: HIT | MISS | REVALIDATED
cacheAgeS: number | null; // x-cache-age
rateLimitRemaining: number | null;
rateLimitLimit: number | null;
retries: number; // retries this call needed
test: boolean; // x-pageteer-test: made with a test key
}Errors and retries
API errors and client failures are thrown as PageteerError:
try {
await pageteer.render(url);
} catch (err) {
if (!(err instanceof PageteerError)) throw err;
err.status; // HTTP status; 0 for client-side failures
err.code; // error.code, e.g. captcha_required, or a client code below
err.message;
err.requestId; // x-request-id
err.retryAfter; // seconds, from Retry-After or error.retry_after
err.details; // the rest of the error body (issues, provider, final_url, …)
err.proxyFailureReason; // proxy_failed: auth | unreachable | status | blocked; else null
err.reason; // 402: free_credits_used | payment_failed | subscription_inactive | spend_limit (open-ended)
err.billingUrl; // 402: the Billing page that fixes it
err.creditsNeeded; // 402: the request's cost, when known; else null
err.creditsRemaining; // 402: credits left, when known; else null
}- Client codes (
status0):missing_api_key,network_error,client_timeout,aborted,wait_timeout; plusinvalid_responsefor an unreadable body andhttp_<status>for a non-JSON error. 429is retried afterRetry-Afterplus jitter (thrown at once if that is longer thanmaxRetryAfterMs).5xxand network errors are retried with jittered exponential backoff.- Never retried: other
4xx(including402, whose message ends with the billing URL, andproxy_not_configured),proxy_failedwith reasonauthorblocked, client timeouts and aborts. crawl.startandbatch.createsend a randomIdempotency-Key(or yours:{ idempotencyKey }), so they are retried on5xxtoo: a replay returns the original job. WithidempotencyKey: nullno key is sent and a5xxis not retried, because it might already have created the job.
The full list of API error codes, and what to do about each, is in the errors reference.