Web Watchdog - Website Change Monitor & Diff API for AI Agents avatar

Web Watchdog - Website Change Monitor & Diff API for AI Agents

Pricing

from $0.30 / 1,000 target checkeds

Go to Apify Store
Web Watchdog - Website Change Monitor & Diff API for AI Agents

Web Watchdog - Website Change Monitor & Diff API for AI Agents

Check if any URL changed since a given content hash, or watch it on a schedule. Diffs web pages, JSON APIs, RSS feeds and sitemaps and returns what changed, old vs new values, numeric deltas and a one-line summary. Billed per change detected, not per check. Strongest on APIs, docs and changelogs.

Pricing

from $0.30 / 1,000 target checkeds

Rating

5.0

(4)

Developer

Santhej Kallada

Santhej Kallada

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

2

Monthly active users

12 days ago

Last modified

Share

Web Watchdog — Website Change Monitor & Diff API for AI Agents

Check whether any URL changed since a given content hash, or watch it on a schedule.

Web Watchdog diffs web pages, JSON APIs, RSS/Atom feeds and XML sitemaps and returns what changed: old vs new values, added and removed lines, pre-extracted numeric deltas, and one deterministic human- and LLM-readable sentence. Every row is flat JSON with the same 34 keys, every time.

You are billed per change detected, not per check. A quiet run costs three tenths of a cent plus $0.0003 per target. A run that finds nothing produces an empty dataset and zero webhook calls.


What this actor is good at — and where it is not

This is a change detector for machine-readable and text-stable sources. It is strongest on:

  • JSON APIs — package registries, release feeds, status APIs, your own endpoints
  • Documentation and changelogs — release notes, CHANGELOG.md, docs pages
  • Registries and indexes — npm, PyPI, Node dist, GitHub repo/release metadata
  • Status pages — "All Systems Operational" → "Degraded Performance"
  • Pricing pages — a card rate moving 2.9% → 2.7%
  • RSS/Atom feeds and XML sitemaps — new items, new or updated URLs

The measured numbers, published rather than implied

The economics rest on HTTP conditional GET: if a server answers If-None-Match / If-Modified-Since with 304 Not Modified, the check costs ~1.5 KB on the wire and zero parse CPU, versus ~30 KB gzip for a full body — a 20× bandwidth reduction per target. But not every server plays along, and we measured how many actually do:

Target classServers returning a real 304
JSON APIs (registry.npmjs.org, api.github.com, pypi.org, nodejs.org/dist)~100% (4/4)
A realistic API / registry / docs / CDN mix67%
Arbitrary consumer HTML33%

Re-verification found this drifts: 4/4 JSON APIs still return real 304s, but 0/4 of the HTML pages retested did — stripe.com, status.openai.com and anthropic.com/pricing send no validator at all, and docs.apify.com/platform stopped sending an ETag or Last-Modified between probes, having previously sent both.

So, plainly: dynamic marketing pages land on the full-scrape cost profile, not the cheap one. They still work — the normalisation pipeline and the diff do not care — but the conditional-GET saving is not there, and you should expect to pay the per-check price with no shortcut. If you want the cheap path, watch the API behind the page rather than the page.

Two more traps we handle rather than hide:

  • "Validator present but revalidation ignored." bbc.com/news and ycombinator.com/companies both send a valid ETag and then answer If-None-Match with a full 200. Naive implementations assume "has validator therefore cheap" and silently revert to full-scrape economics. We count consecutive misses per target, stop sending validators after 3, and surface revalidationHonoured: false on the row so the cost profile is visible.
  • JavaScript-rendered pages. There is no browser in v1. openai.com/api/pricing/ and ycombinator.com/companies yield 0.0 KB of extractable static text while their raw HTML demonstrably changes — a hash that is stable forever and meaningless forever. Rather than report "no change" indefinitely, an unscoped watch whose normalised text is under 200 characters fails loudly with errorReason: "js_rendered_no_static_content" and confidence: 0.3. (This guard does not apply to scoped watches — "In stock" is 8 characters and perfectly valid.)

Billed per change detected, not per check

Every subscription incumbent bills for checks performed, so what you pay is fully decoupled from what you get: the same monthly bill whether a page changed 400 times or zero times. Here the expensive event fires only when something actually changed and passed the significance triggers.

EventPrice (FREE tier)When
actor-start$0.003Once per run
target-checked$0.0003Per target that returned a definitive answer — a 304, a parsed 200, or a real 4xx
change-detected$0.008 → $0.006 at DIAMONDOnly when a real change is found and reported

Volume tiers on change-detected: FREE $0.0080 · BRONZE $0.0076 · SILVER $0.0072 · GOLD $0.0068 · PLATINUM $0.0064 · DIAMOND $0.0060.

What is deliberately NOT billed:

  • Timeouts, DNS failures, TLS failures and blocked hosts — no definitive answer, no target-checked charge. Point this at 1,000 gated pages and you pay for the run, not for 1,000 error rows.
  • baseline_captured — the first sighting of a target, and any hash produced under a different normaliser version. Never billed.
  • no_change rows, quarantined targets, and anything past maxChangeEvents.

Every row carries an explicit billed: true|false, so the dataset reconciles against the invoice without inference.

Worked example

100 targets, checked hourly, 730 runs a month, roughly one change per run:

730 x ($0.003 start + 100 x $0.0003 per check + 1 x $0.008 per change) = $29.93 / month

For context on the same 72,000-check workload: Distill Professional is $35/mo; Visualping's page cap (not its check cap) forces a $140/mo tier for 100 pages regardless of frequency, and 100 pages hourly exceeds even its $350 50K plan. There is no page cap and no subscription floor here — a month with zero runs costs $0.00.

One honest caveat we would rather state than have you discover: Distill's Flexi pay-as-you-go rate is $0.00025/check, which is cheaper per check than target-checked before a single change event — but it requires an $80/mo plan to unlock. The accurate claim is the cheapest metered price with no subscription floor, not "the cheapest price".

Runaway-bill protection

change-detected is the one uncapped term, so four brakes ship with it:

ControlEffect
maxChangeEvents (default 500)Hard cap on billable change events per run. Worst case $4.00/run. Once hit the run keeps checking and stops charging, and the summary reports budgetCapped: true.
maxBudgetUsdPre-flight abort: 0.003 + targets × 0.0003 + min(maxChangeEvents, targets) × 0.008 is computed before any fetching and the run refuses to start if it exceeds your number. The min() is deliberate — each target is checked once per run, so a run can never emit more change events than it has targets.
Flap detection (always on)A page that swaps back to content it has already served — an A/B test, a rotating banner — is reported as changeType: "flap_suppressed" and not billed, from the third sighting onward. A permanent A/B test costs $0.008 once, not $0.008 per run. Stateful mode only.
noiseQuarantineRuns (default 5)The backstop for churn that is not a clean oscillation. A target that changes on 5 consecutive runs is auto-quarantined: still checked, still reported as changeType: "quarantined", not billed, until it goes one full run quiet.
sensitivitySemantic triggers rather than raw diff, so a cookie-banner reshuffle is not a billable event. Thresholds are on the triggers themselves — never on how long the page is — so a price move, an availability flip, anything inside your selector, and a genuine text edit are all billed the same whether the page is 100 lines or 6,000. See What counts as significant.

⚠️ Stateless mode has no quarantine and no flap detection. quarantined, changeStreak, revalidation-miss tracking and the per-target hash history all live in the state blob. A stateless caller passes back one hash, not a set, so the actor cannot know whether a state has been seen before and cannot suppress a flap — that is a real limitation, not an omission from this list. In stateless mode the only spend limits are maxChangeEvents and maxBudgetUsd. Treat maxBudgetUsd as mandatory when autoSchedule is on.


Two ways to use it

1. Stateless one-shot: "has this changed since <hash>?"

Pass knownHash on every target and the run writes nothing, opens no store, works under any token scope, and hands back newHash for you to persist. This is the shape an MCP tool call or an agent loop wants — the agent holds the state, so the tool is idempotent and replayable, with no remote monitor object to create, own or clean up.

{
"targets": [
{
"url": "https://registry.npmjs.org/apify",
"watchId": "apify-latest",
"jsonPath": "dist-tags.latest",
"knownHash": "wd2:3f2a9c8e1b47d605e2c1a9f8b3d4e5061728394a5b6c7d8e9f0a1b2c3d4e5f60",
"knownEtag": "\"6c1f-5f2a9c8e1b4\""
}
]
}

Round-trip newHashknownHash, etagknownEtag, lastModifiedknownLastModified and your next call earns the 304 too.

2. Scheduled watch with durable state

Give a list of URLs and let the actor keep the state. It opens one named key-value store (wd-state-{stateKey}) holding exactly one record with every target's hash, validators, check counts and streaks. Leave stateKey empty and it is derived from the sorted target URL set, so re-running the same list resumes the same watch with zero bookkeeping.

{
"targets": [
"https://registry.npmjs.org/apify",
"https://nodejs.org/dist/index.json",
{ "url": "https://status.openai.com/", "selector": ".status-index-page .component-status" },
{ "url": "https://api.github.com/repos/apify/crawlee/releases/latest", "jsonPath": "tag_name" }
],
"sensitivity": "normal",
"intervalMinutes": 60,
"webhookUrl": "https://n8n.example.com/webhook/watchdog",
"maxChangeEvents": 200,
"maxBudgetUsd": 2.5
}

Set autoSchedule: true to have the actor create or update exactly one Apify Schedule named watch-{stateKey} at intervalMinutes. It is off by default because schedules are your quota (100 on Starter) and silently consuming it would break your other automations. When it is off the run summary still contains a ready-to-POST scheduleRequestBody so an agent can create it in one call. Apify creates schedules disabled — enable it once and it runs.

If the named store cannot be created (restricted scoped token), the actor logs the required scope, sets state_store_unavailable in the summary, and degrades to stateless rather than crashing. You get usable hashes instead of a permissions error.


Detection methods

mode is auto by default and dispatches on the response Content-Type. You can pin it globally or per target. method on every row tells you how the answer was actually derived.

MethodWhat it comparesNotes
conditional_get_hashETag/Last-Modified, then a normalised page hashThe cheap path. A 304 ends the check with zero parsing.
json_pathOne value at a dotted/bracket pathThe strongest primitive by far: registry.npmjs.org/apify is 5,216,807 bytes; watching dist-tags.latest reduces the watched state to 9 bytes. pypi.org 192,960 → 10 B. nodejs.org/dist 327,629 → 11 B.
css_selectorThe text inside a CSS selectorReal HTML parsing via cheerio, not regex. Scoped watches are significant by construction.
rss_guidThe set of item GUIDs/idsNever a document hash — feeds reorder constantly. Emits item_added / item_removed with guids and titles.
sitemap_lastmod<lastmod> values, falling back to a <loc> set diffRoot sitemaps are 196–1,148 bytes, so polling one is essentially free.
full_body_hashThe raw body hash, unnormalisedEscape hatch. Expect false positives on anything with a nonce or a timestamp.

Normalisation is a billing-integrity feature

Raw SHA-256 over HTML flapped on 8 of 15 real pages fetched seconds apart — a 53% false-positive rate. Under per-change billing, every false positive is a wrongly billed event. The pipeline runs, in this order: strip script/style/noscript/svg/template/iframe and preload/CSRF tags → delete volatile attributes (nonce, integrity, data-csrf, data-timestamp, data-testid, style, …) → strip query strings from every src/href (kills cache-busting hashes) → scope to your selector or body → extract text → mask hex blobs, UUIDs, ISO-8601 timestamps, epochs, clock times, "3 minutes ago" and RFC-822 dates to stable sentinels → collapse whitespace.

After the pipeline: 1 of 15, and that one was a genuine news-homepage change.

The hash is versioned: wd2:<sha256>

newHash always carries a wd<version>: prefix. Any change to the normaliser inputs changes every hash for every URL, which would otherwise make 100% of a stateless caller's targets report content_changed against a hash that is merely stale — on the uncapped pricing term. So on a prefix mismatch the actor emits baseline_captured, never content_changed, and never bills.

What counts as significant

sensitivity drives a discrete trigger set, not a magnitude float. A change is emitted, delivered and billed if any trigger fires, and triggerReasons on the row says which:

TriggerFires when
T_SCOPEDThe change was inside your selector or jsonPath — you already told us what matters
T_NUMERICAny number moved by at least the sensitivity's percentage threshold
T_LINESchangedLineCount reached the sensitivity's line threshold
T_ITEMSitem_added or item_removed
T_AVAILbecame_available or became_unavailable
sensitivityNumeric thresholdLines (whole page)Lines (scoped)
low1.0%101
normal (default)0.5%21
highany11

There is no page-length term in that table, and that is deliberate. The same edit gets the same verdict whether it lands on a 100-line page or a 6,000-line one. An earlier build filtered T_LINES-only changes by the fraction of the document that moved; because that fraction shrinks as pages grow, it silently swallowed a genuine three-paragraph rewrite on any page over ~1,300 lines and a twenty-line addition on a 6,000-line page. It was removed. A monitor that quietly stops reporting is worse than a noisy one.

changedLineCount is added + removed, so a one-line MODIFICATION counts as 2 (the old line is removed, the new line is added) while a one-line INSERTION counts as 1. At the default normal:

What the page didchangedLineCountEmitted and billed at normal?
A price moved, e.g. 2.9%2.7% — anywhere, on a page of any size2Yes. T_NUMERIC
Anything at all inside your selector or jsonPath≥1Yes. T_SCOPED
In stockOut of stock, operationaldegraded≥1Yes. T_AVAIL
A new RSS item or sitemap URL≥1Yes. T_ITEMS
Three new changelog entries — on a 400-line page or a 4,000-line one3Yes, identically. T_LINES
A three-paragraph policy rewrite on a 6,000-line terms page6Yes. Page length is not evidence
One line's text was edited, first time you have seen that text2Yes. T_LINES
…and then the page swapped back to text it has served before2NochangeType: "flap_suppressed", billed: false
An A/B copy swap: 7 lines of marketing text, no number moved, once it starts alternating7No — same, from the third sighting
One new line was inserted, nothing removed1No — it does not reach the line threshold

Flap detection: the page has to go somewhere new

The one thing that can stop a T_LINES-only change from being billed is recurrence, not size.

The actor remembers the last 8 distinct content hashes per watched target (inside the same single state record — it costs no extra storage operations). If a page changes back to a state it has already served, that is an A/B test or a rotating banner, not news:

  • A content hash never seen before always emits. Always. Whatever the page length, whatever the sensitivity. This is what makes the feature safe: a real edit is always novel.
  • A hash already in the target's history does not — unless a price moved, a stock or status state flipped, an item appeared or disappeared, or the change was inside your selector. Those four bypass flap detection completely, because a price that oscillates has moved twice and a status page that recovers and relapses is doing exactly what you are watching it for.
  • It takes effect on the third sighting: baseline, then one unavoidable report the first time the page swaps, then silence. A permanent A/B test costs $0.008 once, not per run.
  • sensitivity: "high" does not turn it off, because there is nothing to turn off: high still reports every novel state on every changed line. It just does not re-announce a state you hold.
  • Not available in stateless mode — there is no history there to compare against.

A suppressed flap is still written to the dataset, with triggerReasons: ["T_LINES"], significance: "trivial", billed: false and a diffSummary naming the hash it recognised. You can always see what the page did; you are simply not charged or woken for it.

If a target is still noisier than you want, pick sensitivity: "low", scope the watch with a selector so the noise is outside it, or let noiseQuarantineRuns catch it after 5 consecutive changed runs — that is the backstop for churn that never repeats itself, such as a view counter.

Equivalent price renderings are collapsed before hashing

$0.30, 30¢, 0.30 USD and USD 0.30 are the same amount, and a page that A/B tests its own formatting must not look like a price change. Before hashing, the actor canonicalises USD/EUR/GBP amounts: thousands separators and insignificant trailing zeros are dropped, and ¢ is converted to the major unit. Every rewrite is value-preserving$0.30 and $0.35 stay different, $0.305 is never rounded to $0.30, and $0.30 never becomes €0.30. Ambiguous forms (30p, 30c, "30 cents", and . used as a thousands separator as in €1.234,56) are deliberately left alone, because a wrong rewrite would hide a real price change.


Output — 34 keys, every key on every row

Every key is present on every row: null, [], false or 0never absent. Errors are rows with the same shape, not exceptions.

{
"eventId": "9c41e0b7a3d5f218",
"watchId": "apify-latest",
"url": "https://registry.npmjs.org/apify",
"changeType": "value_changed",
"significance": "major",
"triggerReasons": ["T_SCOPED", "T_NUMERIC"],
"diffSummary": "dist-tags.latest changed from 3.4.4 to 3.5.0 on https://registry.npmjs.org/apify",
"method": "json_path",
"selector": "dist-tags.latest",
"oldValue": "3.4.4",
"newValue": "3.5.0",
"diffAdded": ["3.5.0"],
"diffRemoved": ["3.4.4"],
"changedLineCount": 2,
"numbersChanged": [
{ "from": 4, "to": 5, "delta": 1, "pctChange": 25, "label": "dist-tags.latest" }
],
"magnitude": 1,
"newHash": "wd2:8b1a0c4e7f2d95360a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071",
"previousHash": "wd2:3f2a9c8e1b47d605e2c1a9f8b3d4e5061728394a5b6c7d8e9f0a1b2c3d4e5f60",
"etag": "\"7d2a-6b3c9f1e2a4\"",
"lastModified": "Fri, 14 Aug 2026 09:12:44 GMT",
"revalidationHonoured": true,
"httpStatus": 200,
"detectedAt": "2026-08-14T10:00:03.412Z",
"previousCheckAt": "2026-08-14T09:00:02.887Z",
"checkCount": 37,
"confidence": 1,
"bytesTransferred": 30184,
"latencyMs": 268,
"quarantined": false,
"billed": true,
"errorReason": null,
"error": null,
"webhookDelivered": true,
"stateKey": "a91c3f7d2b04"
}
#FieldTypeMeaning
1eventIdstringIdempotency key — see below
2watchIdstringYour name, or the derived id (see below). The join key across runs
3urlstringThe exact URL requested
4changeTypestringcontent_changed | value_changed | item_added | item_removed | became_available | became_unavailable | baseline_captured | no_change | flap_suppressed | quarantined | rate_limited | error — the one field to branch on. flap_suppressed = the page went back to content it has already served; reported, never billed
5significancestringtrivial | minor | major
6triggerReasonsarrayWhich triggers fired. [] on non-emitted rows
7diffSummarystringOne deterministic templated sentence. No LLM call. "" when nothing changed
8methodstringHow the answer was derived
9selectorstring|nullThe CSS selector or JSON path the comparison was scoped to
10oldValuestring|nullPrevious watched value, truncated to 2,000 chars
11newValuestring|nullCurrent watched value, truncated to 2,000 chars
12diffAddedarrayLines present now and absent before, capped at 50
13diffRemovedarrayLines gone, capped at 50
14changedLineCountintegerUncapped count, before the 50-line display cap
15numbersChangedarray{from, to, delta, pctChange, label} pre-extracted so you do no math
16magnitudenumberchangedLineCount / (oldLines + newLines) — the fraction of the document that moved. Diagnostic only: nothing in the emit or billing decision reads it, because a fraction of a long page is not a measure of significance
17newHashstringwd2:<sha256> — the field a stateless caller persists
18previousHashstring|nullWhat this check compared against. null on baseline_captured
19etagstring|nullRound-trip as knownEtag
20lastModifiedstring|nullRound-trip as knownLastModified
21revalidationHonouredbooleanFalse when the origin sent a validator and answered 200 anyway
22httpStatusinteger304 = cheap path fired, 200 = full body, 0 = transport failure
23detectedAtstringISO-8601 UTC
24previousCheckAtstring|nullSo you know the observation window
25checkCountintegerConsecutive checks since the last change. 0 in stateless mode
26confidencenumber0–1. Drops below 0.5 when unscoped static extraction looks too thin to trust
27bytesTransferredintegerWire bytes, compressed. ~1.5 KB on a 304 vs ~30 KB gzip for a body
28latencyMsintegerRound-trip for the final request, including retries
29quarantinedbooleanReported but not billed until the target goes quiet for a run
30billedbooleanWhether this row generated a change-detected charge
31errorReasonstring|nulljs_rendered_no_static_content | http_error | timeout | dns_error | tls_error | rate_limited | blocked | selector_not_found | json_path_not_found | invalid_url | body_too_large | state_store_unavailable | ssrf_blocked
32errorstring|nullActionable message naming the fix where one exists
33webhookDeliveredboolean|nulltrue on 2xx, false if every in-run retry failed, null when no webhook was configured
34stateKeystringThe resolved state key, or the literal "stateless"

watchId — how the derived id is computed

Supply watchId on a target object and that string is used verbatim. Leave it out — which is what a bare URL string does — and it is derived from the canonical form of the URL, not the raw one:

canonicalUrlKey(url) = the URL with
scheme and host lower-cased,
any user:password credentials removed,
the fragment (#...) removed,
a default port (:80 on http, :443 on https) removed,
query parameters sorted by name, then by value,
and a bare trailing "/" removed when there is no query
watchId = sha256(canonicalUrlKey(url)).slice(0, 12)

Canonicalising first is deliberate: ?b=2&a=1 and ?a=1&b=2 are the same endpoint, and a watch must not split into two histories because a caller reordered its query string. The cost is that you cannot reproduce the id from the raw URL you typed — so if you need to compute watchId on your side, canonicalise the same way, or just set watchId explicitly, which is always exact:

{ "url": "https://registry.npmjs.org/apify", "watchId": "apify-latest" }

Worked examples:

Input URLCanonical keywatchId
https://registry.npmjs.org/apifyhttps://registry.npmjs.org/apifyfd0871697c5c
HTTPS://Registry.NPMjs.ORG:443/apify#tophttps://registry.npmjs.org/apifyfd0871697c5c
https://example.com/api?b=2&a=1https://example.com/api?a=1&b=22c92d744698a

stateKey, when you leave it empty, is derived the same way: sha256 over the sorted, de-duplicated set of canonical keys, truncated to 16 characters.

eventId — the idempotency key, and how to dedupe

eventId = sha256(watchId + "|" + previousHash + "|" + newHash + "|" + runId).slice(0, 16)

It is event-addressed, not content-addressed: runId and previousHash are both in the key. That matters more than it sounds. A content-addressed id (sha256(watchId + newHash)) is byte-identical for every recurrence of the same content state — so a target flapping A → B → A → B (an A/B test, a rotating banner, a stock badge, a queue depth) emits the same id for run 1's A→B and run 3's A→B, and a set-membership dedupe silently throws the second real change away. A became_available event would even collide with the original baseline_captured for that hash.

Recommended dedupe pattern — safe with this key:

const seen = new Set(await loadSeenEventIds()); // your store, capped/TTL'd as you like
for (const row of datasetItems) {
if (seen.has(row.eventId)) continue; // a duplicate DELIVERY of one event
seen.add(row.eventId);
await handle(row); // a genuinely new event, always
}

Use eventId for delivery deduplication (webhook retry, dataset re-read, run resurrection) and watchId as the join key for "what is the current state of this target". Do not use newHash as an event key — two different events can legitimately share it.


Webhook delivery

Set webhookUrl and the actor POSTs one request per emitted change event, and nothing at all when nothing changed. Firing is the signal. Apify's native webhooks are run-scoped, cannot carry dataset items in their payload (the receiver has to make a second authenticated API call), and fire on every run regardless — which wakes your agent up to learn that nothing happened.

Each POST carries the full flat 34-key body and these headers:

HeaderValue
Content-Typeapplication/json
X-Watchdog-Event-IdThe row's eventId
X-Watchdog-Delivery-Attempt1, 2 or 3
X-Watchdog-Signaturesha256=<HMAC-SHA256 of the exact raw body>, when webhookSecret is set

10 s timeout, 3 in-run attempts at 1 s / 4 s / 12 s with jitter. Failures are persisted in state (capped at 200) and retried at the start of the next run before any fetching, then dropped after 24 hours. Because the actor delivers its own events there is no platform-level retry net, so deliveryFailures and webhookDropped are both surfaced in the run summary rather than swallowed.

Verify the signature over the raw bytes, before parsing:

const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedHeader));

Run summary

Written to the key-value store record OUTPUT on every run:

stateKey, mode (stateful/stateless), normalizerVersion, targetsChecked, definitiveAnswers, changesDetected, notModified304, fullBodies, errorsByReason, bytesTransferred, wallMs, estimatedChargeUsd, budgetCapped, quarantinedTargets, deliveryFailures, webhookDropped, stateRecordCount, sameHostWarning, scheduleId, scheduleRequestBody.

notModified304 versus fullBodies is your live cost profile: the first number is the cheap path, the second is not.


Input reference

One field is enough. {"targets": ["https://registry.npmjs.org/apify"]} is a complete, valid call.

FieldTypeDefaultPurpose
targetsarray["https://registry.npmjs.org/apify"]URL strings or objects {url, watchId, selector, jsonPath, mode, knownHash, knownEtag, knownLastModified, headers}. Max 5,000. A bare string derives its watchId from the canonical URL — see watchId
modestringautoGlobal detection mode; per-target mode overrides
sensitivitystringnormallow / normal / high — drives the trigger set
webhookUrlstring""One POST per change event, silence otherwise
webhookSecretstringHMAC-SHA256 signing key (secret)
maxChangeEventsinteger500Hard cap on billable change events per run, 0–5,000
maxBudgetUsdnumberPre-flight abort. Worst case = 0.003 + targets × 0.0003 + min(maxChangeEvents, targets) × 0.008
emitUnchangedbooleanfalseAlso push no_change rows. Never affects billing
stateKeystring""Named store suffix; empty derives it from the URL set
statelessModebooleanfalseForce stateless. Auto-enabled when every target has knownHash
intervalMinutesinteger60Cadence for the cron and staleness gap, 5–10,080
Advanced
noiseQuarantineRunsinteger5Auto-quarantine after N consecutive changed runs, 0–1,000. 0 disables. Stateful only
concurrencyinteger50Global parallel cap, 1–100. Per-host is separately clamped to 2
maxBodyBytesinteger5242880Abort past this → body_too_large
githubTokenstringSent as Bearer on api.github.com targets only (secret)
proxyConfigurationobject{"useApifyProxy": false}Optional IP rotation. RESIDENTIAL is downgraded to DATACENTER
autoSchedulebooleanfalseCreate/update exactly one Schedule watch-{stateKey}

Limits, safety and honest caveats

  • No JavaScript rendering in v1. SPA-only pages fail loudly (see the SPA guard above) instead of reporting "no change" forever.
  • Egress guard. Every target URL, every redirect hop (max 3, redirect: manual) and the webhookUrl itself are validated: http/https only, default ports only, and any host resolving to loopback, link-local (169.254.0.0/16, fe80::/10), RFC1918, CGNAT (100.64/10), unique-local or 0.0.0.0/8 is refused with errorReason: "ssrf_blocked". This actor fetches user-supplied URLs on a schedule from shared cloud infrastructure, so this is a hard control, not a setting.
  • Blocked domains. A small registrable-domain blocklist (major retail, social and reddit.com) is refused with errorReason: "blocked" and never charged. Those sites either hard-block datacenter traffic or forbid automated collection in their terms; a generic watcher pointed at them is a terms problem, not a technical one.
  • Per-host politeness costs wall time. Global concurrency is up to 100, but per-host concurrency is clamped to 2 with jitter no matter what you set. 1,000 URLs spread across many hosts is fast; 1,000 URLs on a single host is not, and the summary raises sameHostWarning when the sweep is at risk of overrunning intervalMinutes.
  • Rate limits. Unauthenticated GitHub is 60 requests/hour per IP and each run may present a different egress IP — set githubToken if you watch more than a handful of repos, or expect rate_limited rows.
  • One state record, by design. The named store holds exactly one blob. If you ever see stateRecordCount above 2 in the summary, something is wrong — open an issue.
  • Residential proxy is excluded on purpose: its bandwidth cost would exceed the entire rest of the run. Sites that require it are refused rather than served badly.

Support

Issues, feature requests and target classes that misbehave: use the Issues tab on the Actor page. Include the watchId, errorReason and method from the row — they are on every row precisely so a report is one copy-paste.