Browser API
Monitors
Watch a page and hear about it when it changes: a price, a changelog, a job board, a competitor's terms. Pageteer re-checks it on a schedule, ignores noise, stores a diff of every change and can POST it to your webhook.
Body parameters
| Name | Type | Description |
|---|---|---|
urlrequired | string | The page to watch (absolute http(s) URL). |
name | string | Your label (≤ 100 characters). |
interval | "hourly" | "6h" | "daily" | "weekly" | How often it is checked. Your plan sets how many monitors you can have and the shortest interval (Free: 5 monitors, every 6 hours at most; Pay as you go: 200 monitors, every hour at most; Invoiced: 1,000 monitors, every hour at most). Default: "daily". |
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 each check, as for render. Default: "none". |
wait_for | string (≤ 500 chars) | CSS selector to wait for before capturing. Implies a browser render. |
country | string (2 letters) | Exit-IP country, as for render. |
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. |
respect_robots | boolean | Honor robots.txt: a disallowed page fails its checks as robots_disallowed (free). Default: true. |
selector | string | Compare only the text of the elements this CSS selector matches (#price, main .changelog) instead of the whole page. Nothing matching is a change too. |
markdown_mode | "main" | "full" | Without a selector, what page text is compared: "main" drops navigation, footers and sidebars; "full" keeps the whole body. Default: "main". |
selectors | object | Compare fields instead of text: CSS selectors as for extract ({ "price": ".price" }). Changes come with a field diff. |
schema | object | Compare fields extracted with an LLM against this JSON Schema, as for extract. Plans with LLM extraction only. The LLM runs only when the page itself changed (+40 credits for that check). |
ignore | string[] | Up to 20 bits of text that change on every visit and should not count, matched case-insensitively within a line; * matches anything on that line: "Updated * ago", "Visitors: *". Plain text, not regular expressions. |
webhook | { url, events?, secret? } | Signed events to your https URL: monitor.changed and monitor.paused (default both). The secret is generated and returned once as webhook_secret when you do not send one. |
metadata | object | Any JSON (≤ 2 KB), echoed on the monitor and its events. |
Send at most one of selector, selectors and schema. The first check runs right away and sets the baseline; later checks compare against the last content seen.
Create a monitor
curl -X POST https://api.104-236-84-15.sslip.io/v1/monitors \
-H "Authorization: Bearer $PAGETEER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/pricing","name":"Competitor pricing","interval":"daily","mode":"http","selector":"#plans","ignore":["Updated * ago"],"webhook":{"url":"https://your-app.example.com/hooks/pageteer"}}'const res = await fetch('https://api.104-236-84-15.sslip.io/v1/monitors', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAGETEER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://example.com/pricing",
"name": "Competitor pricing",
"interval": "daily",
"mode": "http",
"selector": "#plans",
"ignore": [
"Updated * ago"
],
"webhook": {
"url": "https://your-app.example.com/hooks/pageteer"
}
}),
});
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/monitors",
headers=headers,
json={
"url": "https://example.com/pricing",
"name": "Competitor pricing",
"interval": "daily",
"mode": "http",
"selector": "#plans",
"ignore": ["Updated * ago"],
"webhook": {
"url": "https://your-app.example.com/hooks/pageteer",
},
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"id": "mon_6b1f3c2a9e4d5f6a7b8c9d0e",
"name": "Competitor pricing",
"url": "https://example.com/pricing",
"interval": "daily",
"status": "active",
"paused_reason": null,
"compare": "text",
"selector": "#plans",
"ignore": ["Updated * ago"],
"webhook": { "url": "https://your-app.example.com/hooks/pageteer", "events": ["monitor.changed", "monitor.paused"] },
"next_check_at": "2026-10-27T12:00:00.000Z",
"last_checked_at": null,
"checks": 0,
"changes": 0,
"credits_used": 0,
"webhook_secret": "whsec_5f0c…"
}Change history
curl "https://api.104-236-84-15.sslip.io/v1/monitors/mon_6b1f3c2a9e4d5f6a7b8c9d0e/changes?limit=1" \
-H "Authorization: Bearer $PAGETEER_API_KEY"const res = await fetch(`https://api.104-236-84-15.sslip.io/v1/monitors/mon_6b1f3c2a9e4d5f6a7b8c9d0e/changes?${new URLSearchParams({"limit":"1"})}`, {
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'));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/monitors/mon_6b1f3c2a9e4d5f6a7b8c9d0e/changes",
headers=headers,
params={
"limit": 1,
},
timeout=120,
)
res.raise_for_status()
print(res.json(), res.headers["x-credits-remaining"]){
"changes": [
{
"id": "chg_6b1f4e7d0a1b2c3d4e5f6a7b",
"monitor_id": "mon_6b1f3c2a9e4d5f6a7b8c9d0e",
"at": "2026-10-28T12:01:13.402Z",
"status": 200,
"final_url": "https://example.com/pricing",
"title": "Pricing",
"added": 1,
"removed": 1,
"diff": "@@ -3,3 +3,3 @@\n Starter\n-$19 per month\n+$24 per month\n Billed yearly",
"diff_truncated": false,
"fields": null,
"fields_truncated": false,
"hash": "9b2c…",
"previous_hash": "41de…"
}
],
"next": "chg_6b1f4e7d0a1b2c3d4e5f6a7b"
}Newest first; pass next as before for older changes, until it is null. Changes are kept 90 days. diff is a unified diff of the normalized text with two lines of context (up to 20,000 characters; added/removed are exact). Data monitors also get fields: [{ path, kind: "added" | "removed" | "changed", before, after }], with paths like price or items[2].name.
What counts as a change
Content is normalized before it is hashed and compared: whitespace runs, blank lines, invisible characters (zero-width spaces, soft hyphens, non-breaking spaces) and cache-busting query strings on image URLs never count. Your ignore patterns are removed next. A check that sees the same normalized content is not a change, even when the site re-rendered the page. A site error (5xx) is a failed check, not a change; a 404 is a successful check and shows up as a change when the page disappears.
Read, edit, pause, delete
GET /v1/monitorslists your monitors, newest first (?status=active|paused,?limit=up to 100).GET /v1/monitors/:idreturns one.PATCH /v1/monitors/:idtakes any create field (nullremoves an optional one) andpaused: true | false. Changing what is compared (url, mode, wait_for, selector, markdown_mode, selectors, schema, ignore) starts a new baseline. Resuming runs the next check right away.DELETE /v1/monitors/:iddeletes the monitor and its change history.POST /v1/monitors/:id/checkchecks now (also while paused) and answers{ checked_at, baseline, changed, change, status, credits, monitor }. The schedule does not move. 409conflictwhile another check of the same monitor runs.
curl -X PATCH "https://api.104-236-84-15.sslip.io/v1/monitors/mon_6b1f3c2a9e4d5f6a7b8c9d0e" \
-H "Authorization: Bearer $PAGETEER_API_KEY" -H "content-type: application/json" \
-d '{"paused": true}'Monitor fields
Besides your settings (url, name, interval, mode, unblock, selector, selectors, schema, ignore, webhook, metadata):
| Name | Type | Description |
|---|---|---|
id | string | mon_… (use it in the other calls). |
status | string | active or paused. |
paused_reason | string | null | Why it is paused: user (you), failures (5 failed checks in a row), insufficient_credits, spend_limit_reached, org_suspended or feature_not_in_plan. Open-ended. |
compare | string | text (page or selector text, line diff) or data (fields). |
next_check_at | string | null | When the next scheduled check is due (null while paused). |
last_checked_at / last_changed_at | string | null | The last successful check, and the last check that found a change. |
last_status | integer | null | The site’s HTTP status at the last check. |
last_error | { code, message, at } | null | The last failed check; cleared by the next successful one. |
consecutive_failures | integer | Failed checks in a row. |
content_hash | string | null | sha256 of the normalized content last seen. |
checks / changes / credits_used | integer | Totals so far. |
Scheduling, failures and pausing
Checks run at the interval plus a little jitter (at most 5 %, never more than 10 minutes), so monitors created together spread out. A failed check (timeout, block, CAPTCHA, site error) is free and retried sooner: after 5 minutes, then 10, 20…, never later than the regular interval, and never before a site's Retry-After. After 5 failed checks in a row the monitor pauses (failures). It also pauses at once when your credits or spend limit run out, when the organization is suspended, or when the plan no longer includes what it uses. Each pause sends monitor.paused; resume with PATCH { "paused": false } or from the Monitors page of the dashboard.
Webhooks
Events are signed and retried exactly like batch webhooks (pageteer-signature, pageteer-event, pageteer-delivery headers): see Webhooks for verification in Node and Python.
{
"id": "evt_6b1f4e7d9f0a1b2c3d4e5f60",
"type": "monitor.changed",
"created_at": "2026-10-28T12:01:13.455Z",
"data": {
"monitor": { "id": "mon_6b1f3c2a9e4d5f6a7b8c9d0e", "url": "https://example.com/pricing", "changes": 1, "…": "…" },
"change": { "id": "chg_6b1f4e7d0a1b2c3d4e5f6a7b", "added": 1, "removed": 1, "diff": "@@ -3,3 +3,3 @@\n …" }
}
}monitor.paused carries { monitor, reason, error? }. A change whose event would be over 1 MB is sent without its diff (diff_omitted: true); read it from the change history.
Credits
There is no monitor fee: each successful check costs what the fetch costs. HTTP mode 1 credit, browser mode 5, plus the add-on of the unblocking tier that served it; a page that answers 304 Not Modified to our conditional request costs 1. selectors cost the same; schema adds 40 only for checks where the page changed. A daily HTTP monitor is about 30 credits a month. Failed checks, creating, listing and reading changes are free. Checks appear in your usage as the monitors endpoint.
With a test key monitors check fake pages for free. https://test.pageteer.com/monitor/changing answers with a new version on every check, so you can watch a change and its webhook end to end.
Errors
| Status | Code | When | Charged | What to do |
|---|---|---|---|---|
| 400 | invalid_request | A parameter is invalid, an interval shorter than your plan allows, more monitors than your plan allows, more than one of selector/selectors/schema, or an invalid selector. | 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 for one check (create, resume, check now). | 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 page or the webhook URL is private, internal or denylisted. | No | Do not retry. See the Acceptable Use Policy. |
| 403 | feature_not_in_plan | schema without LLM extraction in your plan. | No | Use selectors or structured, or add a card. |
| 403 | org_suspended | The account is suspended. | No | Contact support. |
| 404 | not_found | No monitor with that id in your account. | No | Check the path and id. |
| 409 | conflict | Check now while another check of the monitor is running. | No | Wait for it to finish, then try again if still needed. |
| 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. |
| 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. |
Check now can also answer with the target errors of render (target_blocked, captcha_required, timeout…); they are recorded on the monitor as last_error and never charged.