Watch
POST /v2/watch turns any URL into a change monitor. You register a page
once; a droplet-local scheduler re-renders it on a fixed cadence in real
headless Chrome, diffs each capture against the previous one, and notifies
you — by webhook, email, or both — when the page actually changes. It
replaces the cron job plus diffing plus alerting plumbing you would
otherwise stand up around the perceive endpoint: one
record instead of a scheduler, a storage bucket, and a comparison script.
Here is the smallest useful call. Send a URL and get back a watcher scheduled to check hourly:
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing"
}'
The response is the full watcher record. It is active immediately, and
next_check_at is set to the poller's next tick:
{
"watcher_id": "wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
"url": "https://example.com/pricing",
"status": "active",
"frequency_minutes": 60,
"diff_mode": "auto",
"track_fields": null,
"webhook_url": null,
"notify_email": true,
"consecutive_errors": 0,
"checks_count": 0,
"last_check_at": null,
"next_check_at": "2026-06-24T18:31:07Z",
"last_change_at": null,
"created_at": "2026-06-24T18:31:07Z",
"updated_at": null
}
Worth flagging up front.
/v2/watchis a V2 endpoint with its own plan gate, separate from V1 conversions. How many active watchers you can hold at once is capped bymax_watcherson your plan, and a watch-disabled plan cannot create one at all — both denials come back as402. See the pricing page for current per-tier allowances.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v2/watch |
Create a watcher for one URL. Returns 201. |
GET |
/v2/watch |
List this project's watchers, newest first. |
GET |
/v2/watch/{watcher_id} |
Fetch one watcher's full record. |
GET |
/v2/watch/{watcher_id}/snapshots |
List a watcher's check history, newest first. |
PATCH |
/v2/watch/{watcher_id} |
Update cadence, diff settings, or pause/resume. |
DELETE |
/v2/watch/{watcher_id} |
Soft-delete a watcher (idempotent). |
Content-Type: application/json on POST and PATCH.
Authentication
Authenticate with a private key in the X-API-Key header for
server-to-server calls. This is the path every example below uses.
X-API-Key: sk_live_your_private_key
Public keys with a JWT bearer token also work, using the same flow as
every other endpoint — generate a token with your pk_ key, then send it
as Authorization: Bearer <token>. The full flow, including domain locking
and token refresh, is in the authentication guide.
Each API key carries an allowed-endpoints allowlist. If /v2/watch is not
on the key's list, the create request is rejected with 403. Once a key
can create watchers, it can also reach the per-watcher routes it owns —
GET, PATCH, and DELETE on /v2/watch/{watcher_id} and its
/snapshots page are allowed through automatically, so you do not have to
allowlist each verb separately.
How watch works
There is no external queue behind this — no Google Cloud Tasks, no
third-party scheduler. The schedule lives entirely in the database column
next_check_at, and an in-process poller drives it:
- Create. You
POSTa URL. The URL is SSRF-screened (a private, loopback, or metadata host is rejected before any row is written), awat_ID is minted, and the watcher is storedactivewithnext_check_atset to now. - Claim. A droplet-local
watch_workerscans every 60 seconds for active rows whosenext_check_athas passed. It claims them underFOR UPDATE SKIP LOCKEDand advances each one's schedule a full interval in the same transaction — so a slow render is never double-claimed, and a crash mid-render just skips one cycle. - Render. Each claimed watcher is rendered once through the shared headless Chrome singleton — the same capture pipeline behind the perceive endpoint. The render is credential-free: no auth, cookies, or headers are stored, so nothing secret sits at rest for the recurring check.
- Score and diff. A render scoring below the quality floor (0.4) or flagged as blocked is recorded as an audit-only check — no content hash, so it never becomes a diff baseline and never fires a notification. A good render is turned into a capture (main-content text plus extracted structure), diffed against the last good capture, and the verdict is written to a snapshot row.
- Notify. When the diff reports a change, the HMAC-signed webhook (if
set) and the owner email (if
notify_emailis on) fire concurrently, best-effort. - Reschedule. The worker writes the next
next_check_at. Three consecutive render failures pause the watcher and email the owner instead of rescheduling.
Because the schedule is a database column, downtime needs no recovery logic: the first tick after boot sweeps up everything overdue.
Request parameters
Create (POST /v2/watch)
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
string |
— | The page to monitor. Must start with http:// or https://. Max 2,048 characters. Required. |
frequency_minutes |
integer |
60 |
Minutes between checks. Hard hourly floor: minimum 60, maximum 43200 (30 days). |
diff_mode |
string |
"auto" |
Which diff strategy to apply. auto, text, structured, tables, or metadata. See Diff modes. |
track_fields |
object |
null |
Optional field/selector subset to narrow what counts as a change. See Tracking a subset of fields. |
webhook_url |
string |
null |
Optional change-notification target. HMAC-signed, SSRF-screened immediately before each delivery. Max 2,048 characters; must be http(s). |
notify_email |
boolean |
true |
Email the project owner on a detected change and on auto-pause. |
The request schema is strict (extra="forbid"): an unknown field is
rejected with 422. There is deliberately no auth, cookies, or
headers surface here — watchers stay credential-free, the same posture as
the ingest endpoint.
Update (PATCH /v2/watch/{watcher_id})
Every field is optional; only the keys present in the body are applied.
| Parameter | Type | Description |
|---|---|---|
frequency_minutes |
integer |
New cadence. Same 60–43200 bounds as create. |
diff_mode |
string |
Switch the diff strategy. |
track_fields |
object |
Replace the tracked-field subset. |
webhook_url |
string |
Set a new webhook. An empty string is the explicit "clear it" signal and stores NULL. |
notify_email |
boolean |
Toggle owner email. |
status |
string |
active or paused. Resuming re-arms the schedule (next_check_at is set to the next tick); pausing clears it so the poller stops claiming the row. |
An empty body ({}) is rejected with 422 rather than silently no-op'd.
Note that status accepts only active or paused here — the terminal
deleted state is reached through DELETE, never PATCH. Resuming a
paused watcher counts as adding an active monitor, so it re-checks the same
max_watchers cap as create and can return 402.
Diff modes
diff_mode picks which of four content-type-aware strategies the engine
runs. auto runs all four and merges their findings; the named modes
restrict the diff to a single strategy.
diff_mode |
Strategy | What it flags |
|---|---|---|
auto (default) |
All four below | Every kind of change in one pass. |
text |
Main-content SequenceMatcher ratio |
Body text changed — flagged when similarity drops below 0.98, so a reordered word or whitespace edit does not flap the watcher. Carries a unified diff capped at 100 lines. |
structured |
Keyed-list matching | Added / removed / per-field-modified items across links (matched by href) and JSON-LD blocks (matched by @type + name). Order-insensitive. |
tables |
Context-heading matching | Tables matched by caption/heading; reports row-count changes, added/removed tables, and equal-row-count content edits (bounded to 100 rows of context). |
metadata |
Key-by-key dict comparison | Added, removed, and modified page-metadata fields. |
Whatever mode you pick, the snapshot's similarity is always the overall
whole-capture ratio (0.0–1.0). Under a restricted mode that means
similarity can read low while has_changes is false — a section you
are not diffing drifted, but nothing in your chosen strategy changed.
Tracking a subset of fields
track_fields narrows the diff to changes whose section, field, or key
matches a tracked term. It accepts an object — its keys, plus any list
values, become the tracked terms. So {"metadata": ["title"]} tracks both
the metadata section and the title field. Matching is whole-token, not
substring: a term of price matches offers.price but not
priceCurrency.
Response
POST, GET /v2/watch/{watcher_id}, PATCH, and DELETE all return the
same watcher object.
| Field | Type | Description |
|---|---|---|
watcher_id |
string |
Opaque ID (wat_...). Use it on the per-watcher routes. |
url |
string |
The monitored URL. |
status |
string |
active, paused, or deleted. |
frequency_minutes |
integer |
Current cadence, after the hourly floor. |
diff_mode |
string |
The active diff strategy. |
track_fields |
object |
The tracked-field subset, or null. |
webhook_url |
string |
The change webhook, or null. |
notify_email |
boolean |
Whether owner email is on. |
consecutive_errors |
integer |
Render failures in a row. Reset to 0 on a successful check; at 3 the watcher auto-pauses. |
checks_count |
integer |
Total checks run, successful or failed. |
last_check_at |
string |
UTC timestamp of the most recent check, or null. |
next_check_at |
string |
UTC timestamp of the next scheduled check. null while paused or deleted. |
last_change_at |
string |
UTC timestamp of the most recent detected change, or null. |
created_at |
string |
When the watcher was created. |
updated_at |
string |
Last mutation, or null if never updated. |
GET /v2/watch returns a compact WatcherSummary per row (it drops
diff_mode, track_fields, webhook_url, notify_email, and
updated_at) wrapped in a page envelope:
| Field | Type | Description |
|---|---|---|
watchers |
array |
The page of summaries, newest first. |
skip |
integer |
The offset you requested. |
limit |
integer |
The page size in effect. |
has_more |
boolean |
true when more watchers exist beyond this page. |
Snapshot history
GET /v2/watch/{watcher_id}/snapshots returns the watcher's check timeline,
newest first. Each entry carries the diff verdict — the snapshot capture
body itself lives in storage and is not returned here.
| Field | Type | Description |
|---|---|---|
checked_at |
string |
UTC timestamp of the check. |
has_changes |
boolean |
Whether this check detected a change. |
similarity |
number |
Overall whole-capture ratio, 0.0–1.0, or null. |
render_quality |
number |
Render-quality score for the check, or null. |
change_count |
integer |
Number of structured change records. |
changes |
array |
The structured diff: one record per change, each with section, kind (added/removed/modified), key, field, before, after. |
Worth flagging. The
beforeandaftervalues insidechangesare raw page content (link text, metadata, JSON-LD values), not sanitised output. If you render them in HTML — a dashboard, an email — you must escape them yourself. Long string values are already truncated to 2,000 characters, and a single diff is capped at 500 change records.
Lifecycle and scheduling
A watcher moves through three states:
active— on the poller's schedule.next_check_atis set.paused— off the schedule (next_check_atisnull). Reached either byPATCH {"status": "paused"}or automatically after three consecutive render failures.deleted— terminal tombstone, reached only viaDELETE. The row is kept (so checks history survives) but it never appears in lists and reads as404on the per-watcher routes.
The hourly floor is enforced in two places: at create/update, and again by
the scheduler when it advances next_check_at. So even a row whose cadence
was edited directly in the database can never out-pace the floor.
Auto-pause
After three consecutive render failures, the watcher is set to paused,
its schedule is cleared, and — if notify_email is on — the owner gets a
paused-watcher email. A single successful check resets consecutive_errors
to 0. To restart a paused watcher, PATCH it back to active, which
re-arms next_check_at for the next tick.
Deletion
DELETE /v2/watch/{watcher_id} is a soft-delete: it flips status to
deleted, clears next_check_at, and returns the tombstoned record with a
200. It is idempotent — deleting an already-deleted watcher returns the
same record unchanged. Deleted watchers stop counting against your
max_watchers cap immediately (only active watchers count).
Code examples
curl — create with defaults
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing"
}'
curl — every-6-hours, webhook + table tracking
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"frequency_minutes": 360,
"diff_mode": "tables",
"webhook_url": "https://hooks.example.com/enconvert",
"notify_email": false
}'
curl — pause, then resume
curl -X PATCH \
https://api.enconvert.com/v2/watch/wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{"status": "paused"}'
curl -X PATCH \
https://api.enconvert.com/v2/watch/wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'
Python
import requests
BASE = "https://api.enconvert.com"
HEADERS = {"X-API-Key": "sk_live_your_private_key"}
# Create a watcher.
created = requests.post(
f"{BASE}/v2/watch",
headers=HEADERS,
json={
"url": "https://example.com/pricing",
"frequency_minutes": 120,
"diff_mode": "auto",
},
)
created.raise_for_status()
watcher = created.json()
watcher_id = watcher["watcher_id"]
# Later: pull the check history and read the diff verdicts.
snaps = requests.get(
f"{BASE}/v2/watch/{watcher_id}/snapshots",
headers=HEADERS,
)
snaps.raise_for_status()
for snap in snaps.json()["snapshots"]:
if snap["has_changes"]:
print(snap["checked_at"], snap["change_count"], snap["similarity"])
Node.js
const BASE = "https://api.enconvert.com";
const HEADERS = {
"Content-Type": "application/json",
"X-API-Key": "sk_live_your_private_key"
};
// Create a watcher.
const created = await fetch(`${BASE}/v2/watch`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
url: "https://example.com/pricing",
frequency_minutes: 120,
diff_mode: "auto"
})
});
const watcher = await created.json();
// List this project's watchers, newest first.
const list = await fetch(`${BASE}/v2/watch?limit=20`, {
headers: { "X-API-Key": "sk_live_your_private_key" }
}).then(r => r.json());
console.log(watcher.watcher_id, list.has_more);
Plan gating
Watch is gated two ways, both separate from your V1 conversion quota:
watch_enabled— a boolean plan flag. A plan without it cannot create a watcher at all (402).max_watchers— the cap on how manyactivewatchers a project may hold at once. Paused and deleted watchers do not count. A value of0with the flag on means unlimited (enterprise/admin).
Creating a watcher does not consume a conversion or a V2 operation — watch
is gated by how many watchers you hold, not by a per-call counter. Because
every watcher checks on an hourly-or-slower cadence, a project's total check
volume is bounded by max_watchers and the hourly floor together. Current
per-plan max_watchers allowances live on the pricing page.
Error responses
| Status | Condition |
|---|---|
400 Bad Request |
The URL resolves to a private, loopback, link-local, or metadata address (SSRF protection at create time). |
401 Unauthorized |
Missing or invalid API key / JWT token. |
402 Payment Required |
Watch is not enabled on your plan, or your active-watcher count has reached max_watchers (also enforced on a paused→active resume). |
403 Forbidden |
/v2/watch is not in the API key's allowed endpoints. |
404 Not Found |
Unknown watcher_id, one owned by another project, or one already soft-deleted. |
422 Unprocessable Entity |
frequency_minutes below 60 or above 43200, an empty PATCH body ({}), a bad enum in diff_mode or status, a url/webhook_url that is not http(s), or any unknown field. |
500 Internal Server Error |
The watcher could not be created. Retry. |
The full status-code reference is in the error-codes guide.
Limits
| Limit | Value |
|---|---|
| URL length | 2,048 characters |
webhook_url length |
2,048 characters |
frequency_minutes |
60–43,200 (1 hour to 30 days) |
| Hourly floor | 60 minutes, enforced at create, update, and scheduling |
| Poll interval | 60 seconds (a check fires within a minute of its scheduled time) |
| Consecutive errors before auto-pause | 3 |
| Render-quality floor (no diff below it) | 0.4 |
| Text-change similarity threshold | 0.98 |
| Unified text diff | capped at 100 lines |
| Change records per diff | capped at 500 |
| Per-change string value | truncated at 2,000 characters |
| Captured text body diffed | capped at 200,000 characters |
| List / snapshot page size | default 20, max 100 |
| Active watchers per project | plan-dependent (max_watchers) |