Website Change Detection API#

Private beta. Watch is callable today with your normal API key on any paid plan, and watchers cost no ops; the free Founding plan cannot create them at all. It is not announced or generally available: the request and response shapes can change without notice, and there is no stability or support commitment, so do not build anything load-bearing on it yet. The roadmap is on Coming Soon, and every release is announced in the changelog.

POST /v2/watch is a website change detection API: you register a page once, and 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 HMAC-signed webhook, email, or both) when the page actually changes. It will replace 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_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
}

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_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 with 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, because 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, because a section you are not diffing drifted while 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 whose 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 the owner gets a paused-watcher email if notify_email is on. 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, so 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_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing"
  }'

curl: every-6-hours, webhook plus table tracking#

curl -X POST https://api.enconvert.com/v2/watch \
  -H "X-API-Key: sk_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_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_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{"status": "active"}'

Python#

import requests

BASE = "https://api.enconvert.com"
HEADERS = {"X-API-Key": "sk_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_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_your_private_key" }
}).then(r => r.json());

console.log(watcher.watcher_id, list.has_more);

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
Active watchers (max_watchers) 20 / 100 / 500 on Indie / Studio / Production; watch is unavailable on Founding
Ops per check 0 (watchers never draw from the monthly ops allowance)
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

Frequently asked questions#

How do I set up webhooks for site change monitoring?#

Pass webhook_url when you create the watcher with POST /v2/watch, or add it later via PATCH /v2/watch/{watcher_id}. When a check detects a change, EnConvert sends an HMAC-signed POST to that URL; the target is SSRF-screened immediately before each delivery, and an empty string on PATCH clears it.

How often can the API check a page for changes?#

frequency_minutes sets the cadence, from 60 (the hard hourly floor) to 43200 (30 days). The poller scans every 60 seconds, so a check fires within a minute of its scheduled time.

Why did my watcher pause itself?#

Three consecutive render failures auto-pause a watcher, clear its schedule, and email the owner if notify_email is on. PATCH it back with {"status": "active"} to re-arm next_check_at; a single successful check resets consecutive_errors to 0.

Can I monitor only part of a page, like a price or a table?#

Yes. track_fields narrows the diff to changes whose section, field, or key matches a tracked term (whole-token matching, so price matches offers.price but not priceCurrency), and diff_mode can restrict detection to a single strategy such as tables, structured, text, or metadata.