---
seo_title: Watch (Phase 2): Website Change Detection | EnConvert
meta_desc: Private beta, Phase 2: monitor any URL for changes on an hourly or slower cadence, with a webhook or an email to you when the page content actually changes.
keywords: website change detection api, setting up webhooks for site change monitoring, page change monitoring api webhook, monitor web page changes api, website diff api, price change monitoring api, url change tracker api, web page change notification webhook
---

# Website Change Detection API

<div class="alert alert-warning">
<strong>Private beta.</strong> 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 <a href="/docs/coming-soon">Coming Soon</a>, and every release is announced in <a href="/changelog">the changelog</a>.
</div>

`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](/docs/endpoints/perceive.md): 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:

```bash
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:

```json
{
    "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.

```http
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](/docs/authentication.md).

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](/docs/endpoints/perceive.md). 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](#diff-modes). |
| `track_fields` | `object` | `null` | Optional field/selector subset to narrow what counts as a change. See [Tracking a subset of fields](#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](/docs/concepts/v1-and-v2.md).

### 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`, 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

```bash
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

```bash
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

```bash
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

```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

```javascript
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](/docs/reference/errors.md).

---

## 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`.
