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/watch is a V2 endpoint with its own plan gate, separate from V1 conversions. How many active watchers you can hold at once is capped by max_watchers on your plan, and a watch-disabled plan cannot create one at all — both denials come back as 402. 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:

  1. Create. You POST a URL. The URL is SSRF-screened (a private, loopback, or metadata host is rejected before any row is written), a wat_ ID is minted, and the watcher is stored active with next_check_at set to now.
  2. Claim. A droplet-local watch_worker scans every 60 seconds for active rows whose next_check_at has passed. It claims them under FOR UPDATE SKIP LOCKED and 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.
  3. 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.
  4. 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.
  5. Notify. When the diff reports a change, the HMAC-signed webhook (if set) and the owner email (if notify_email is on) fire concurrently, best-effort.
  6. 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 6043200 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 before and after values inside changes are 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_at is set.
  • paused — off the schedule (next_check_at is null). Reached either by PATCH {"status": "paused"} or automatically after three consecutive render failures.
  • deleted — terminal tombstone, reached only via DELETE. The row is kept (so checks history survives) but it never appears in lists and reads as 404 on 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 many active watchers a project may hold at once. Paused and deleted watchers do not count. A value of 0 with 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)