Skip to content
Docs menu

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.

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

Body parameters

Monitor parameters
NameTypeDescription
urlrequiredstringThe page to watch (absolute http(s) URL).
namestringYour 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_forstring (≤ 500 chars)CSS selector to wait for before capturing. Implies a browser render.
countrystring (2 letters)Exit-IP country, as for render.
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.
respect_robotsbooleanHonor robots.txt: a disallowed page fails its checks as robots_disallowed (free). Default: true.
selectorstringCompare 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".
selectorsobjectCompare fields instead of text: CSS selectors as for extract ({ "price": ".price" }). Changes come with a field diff.
schemaobjectCompare 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).
ignorestring[]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.
metadataobjectAny 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
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"}}'
JavaScript (fetch)
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'));
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/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"])
201 Created (shortened)
{
  "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

GET https://api.104-236-84-15.sslip.io/v1/monitors/:id/changes
curl
curl "https://api.104-236-84-15.sslip.io/v1/monitors/mon_6b1f3c2a9e4d5f6a7b8c9d0e/changes?limit=1" \
  -H "Authorization: Bearer $PAGETEER_API_KEY"
JavaScript (fetch)
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'));
Python (requests)
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"])
200 OK
{
  "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/monitors lists your monitors, newest first (?status=active|paused, ?limit= up to 100). GET /v1/monitors/:id returns one.
  • PATCH /v1/monitors/:id takes any create field (null removes an optional one) and paused: 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/:id deletes the monitor and its change history.
  • POST /v1/monitors/:id/check checks now (also while paused) and answers { checked_at, baseline, changed, change, status, credits, monitor }. The schedule does not move. 409 conflict while another check of the same monitor runs.
Pause (curl)
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):

Monitor fields
NameTypeDescription
idstringmon_… (use it in the other calls).
statusstringactive or paused.
paused_reasonstring | nullWhy 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.
comparestringtext (page or selector text, line diff) or data (fields).
next_check_atstring | nullWhen the next scheduled check is due (null while paused).
last_checked_at / last_changed_atstring | nullThe last successful check, and the last check that found a change.
last_statusinteger | nullThe site’s HTTP status at the last check.
last_error{ code, message, at } | nullThe last failed check; cleared by the next successful one.
consecutive_failuresintegerFailed checks in a row.
content_hashstring | nullsha256 of the normalized content last seen.
checks / changes / credits_usedintegerTotals 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.

monitor.changed
{
  "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

Monitor errors
StatusCodeWhenChargedWhat to do
400invalid_requestA 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.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 for one check (create, resume, check now).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 page or the webhook URL is private, internal or denylisted.NoDo not retry. See the Acceptable Use Policy.
403feature_not_in_planschema without LLM extraction in your plan.NoUse selectors or structured, or add a card.
403org_suspendedThe account is suspended.NoContact support.
404not_foundNo monitor with that id in your account.NoCheck the path and id.
409conflictCheck now while another check of the monitor is running.NoWait for it to finish, then try again if still needed.
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.
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.

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.