---
seo_title: Watch (Fase 2): Detección de Cambios Web | EnConvert
meta_desc: Beta privada, Fase 2: monitorea cualquier URL en busca de cambios con una cadencia horaria o más lenta, con un webhook o un email cuando la página cambia.
keywords: api de detección de cambios en páginas web, cómo monitorear cambios en un sitio web con api, webhook para monitoreo de sitios web, api para detectar cambios de precio en una página, rastrear cambios en una url con api, notificación webhook cuando cambia una página, api de diff de sitios web, monitorizar cambios web automáticamente
---

# API de Detección de Cambios en Sitios Web

<div class="alert alert-warning">
<strong>Beta privada.</strong> Watch ya se puede llamar hoy con tu clave de API habitual en cualquier plan de pago, y los watchers no consumen ops; el plan gratuito Founding no puede crearlos. No está anunciado ni disponible de forma general: las formas de la solicitud y de la respuesta pueden cambiar sin avisar, y no hay ningún compromiso de estabilidad ni de soporte, así que todavía no montes nada crítico encima. La hoja de ruta está en <a href="/es/docs/coming-soon">Próximamente</a>, y cada versión se anuncia en <a href="/es/changelog">el changelog</a>.
</div>

`POST /v2/watch` es una API de detección de cambios en sitios web: registras
una página una sola vez, y un planificador local del droplet la vuelve a
renderizar con una cadencia fija en Chrome headless real, compara cada
captura con la anterior y te notifica (mediante webhook firmado con HMAC,
email, o ambos) cuando la página realmente cambia. Reemplazará el cron job
más el diffing más el entramado de alertas que de otro modo tendrías que
montar en torno a [el endpoint de perceive](/es/docs/endpoints/perceive.md): un solo
registro en lugar de un planificador, un bucket de almacenamiento y un
script de comparación.

Esta es la llamada mínima útil. Envía una URL y recibes de vuelta un
watcher programado para verificar cada hora:

```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"
  }'
```

La respuesta es el registro completo del watcher. Está `active` de
inmediato, y `next_check_at` se fija en el siguiente tick del poller:

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

| Método | Ruta | Propósito |
|--------|------|---------|
| `POST` | `/v2/watch` | Crea un watcher para una URL. Devuelve `201`. |
| `GET` | `/v2/watch` | Lista los watchers de este proyecto, del más reciente al más antiguo. |
| `GET` | `/v2/watch/{watcher_id}` | Obtiene el registro completo de un watcher. |
| `GET` | `/v2/watch/{watcher_id}/snapshots` | Lista el historial de verificaciones de un watcher, del más reciente al más antiguo. |
| `PATCH` | `/v2/watch/{watcher_id}` | Actualiza la cadencia, la configuración de diff, o pausa/reanuda. |
| `DELETE` | `/v2/watch/{watcher_id}` | Elimina un watcher (soft-delete, idempotente). |

**Content-Type:** `application/json` en `POST` y `PATCH`.

---

## Autenticación

Autentica con una clave privada en el encabezado `X-API-Key` para llamadas
de servidor a servidor. Este es el flujo que usan todos los ejemplos a
continuación.

```http
X-API-Key: sk_your_private_key
```

Las claves públicas con un token JWT bearer también funcionan, usando el
mismo flujo que todos los demás endpoints: genera un token con tu clave
`pk_` y luego envíalo como `Authorization: Bearer <token>`. El flujo
completo, incluido el bloqueo de dominio y la renovación de tokens, está en
[la guía de autenticación](/es/docs/authentication.md).

Cada clave de API lleva una allowlist de endpoints permitidos. Si
`/v2/watch` no está en la lista de la clave, la solicitud de creación se
rechaza con `403`. Una vez que una clave puede crear watchers, también
puede acceder a las rutas por watcher que le pertenecen: `GET`, `PATCH` y
`DELETE` en `/v2/watch/{watcher_id}` y su página `/snapshots` se permiten
automáticamente, así que no tienes que incluir cada verbo por separado en
la allowlist.

---

## Cómo funciona watch

No hay ninguna cola externa detrás de esto: ni Google Cloud Tasks, ni un
planificador de terceros. El cronograma vive por completo en la columna de
base de datos `next_check_at`, y un poller interno del proceso lo controla:

1. **Crear.** Haces un `POST` de una URL. La URL se filtra contra SSRF (un
   host privado, loopback o de metadatos se rechaza antes de escribir
   cualquier fila), se genera un ID `wat_`, y el watcher se guarda como
   `active` con `next_check_at` fijado en el momento actual.
2. **Reclamar.** Un `watch_worker` local del droplet escanea cada 60
   segundos en busca de filas activas cuyo `next_check_at` ya haya pasado.
   Las reclama con `FOR UPDATE SKIP LOCKED` y avanza el cronograma de cada
   una un intervalo completo dentro de la misma transacción, así que un
   renderizado lento nunca se reclama dos veces y un fallo a mitad del
   renderizado simplemente salta un ciclo.
3. **Renderizar.** Cada watcher reclamado se renderiza una vez a través del
   singleton compartido de Chrome headless, el mismo pipeline de captura
   que hay detrás de [el endpoint de perceive](/es/docs/endpoints/perceive.md). El
   renderizado no usa credenciales: no se almacenan auth, cookies ni
   encabezados, así que no queda nada secreto en reposo para la
   verificación recurrente.
4. **Puntuar y comparar.** Un renderizado con una puntuación por debajo del
   piso de calidad (0.4) o marcado como bloqueado se registra solo como
   verificación de auditoría, sin hash de contenido, por lo que nunca se
   convierte en línea base de diff ni dispara una notificación. Un buen
   renderizado se convierte en una captura (texto del contenido principal
   más la estructura extraída), se compara contra la última captura buena,
   y el veredicto se escribe en una fila de snapshot.
5. **Notificar.** Cuando el diff reporta un cambio, el webhook firmado con
   HMAC (si está configurado) y el email del propietario (si
   `notify_email` está activo) se disparan simultáneamente, con la mejor
   entrega posible.
6. **Reprogramar.** El worker escribe el siguiente `next_check_at`. Tres
   fallos de renderizado consecutivos pausan el watcher y envían un email
   al propietario en lugar de reprogramar.

Como el cronograma es una columna de base de datos, el tiempo de
inactividad no necesita lógica de recuperación: el primer tick tras el
arranque recoge todo lo que estaba atrasado.

---

## Parámetros de la solicitud

### Crear (`POST /v2/watch`)

| Parámetro | Tipo | Predeterminado | Descripción |
|-----------|------|---------|-------------|
| `url` | `string` | ninguno | La página a monitorear. Debe comenzar con `http://` o `https://`. Máximo 2,048 caracteres. Obligatorio. |
| `frequency_minutes` | `integer` | `60` | Minutos entre verificaciones. Piso horario estricto: mínimo `60`, máximo `43200` (30 días). |
| `diff_mode` | `string` | `"auto"` | Qué estrategia de diff aplicar. `auto`, `text`, `structured`, `tables`, o `metadata`. Consulta [Modos de diff](#diff-modes). |
| `track_fields` | `object` | `null` | Subconjunto opcional de campos/selectores para acotar qué cuenta como cambio. Consulta [Rastrear un subconjunto de campos](#tracking-a-subset-of-fields). |
| `webhook_url` | `string` | `null` | Destino opcional para la notificación de cambios. Firmado con HMAC, filtrado contra SSRF justo antes de cada entrega. Máximo 2,048 caracteres; debe ser `http(s)`. |
| `notify_email` | `boolean` | `true` | Envía un email al propietario del proyecto cuando se detecta un cambio y en caso de auto-pausa. |

El esquema de la solicitud es estricto (`extra="forbid"`): un campo
desconocido se rechaza con `422`. Deliberadamente no hay superficie para
`auth`, `cookies` ni `headers` aquí, porque los watchers permanecen sin
credenciales, la misma postura que
[el endpoint de ingest](/es/docs/concepts/v1-and-v2.md).

### Actualizar (`PATCH /v2/watch/{watcher_id}`)

Todos los campos son opcionales; solo se aplican las claves presentes en
el cuerpo.

| Parámetro | Tipo | Descripción |
|-----------|------|-------------|
| `frequency_minutes` | `integer` | Nueva cadencia. Los mismos límites `60`–`43200` que en la creación. |
| `diff_mode` | `string` | Cambia la estrategia de diff. |
| `track_fields` | `object` | Reemplaza el subconjunto de campos rastreados. |
| `webhook_url` | `string` | Establece un nuevo webhook. Una **cadena vacía** es la señal explícita de "borrarlo" y almacena `NULL`. |
| `notify_email` | `boolean` | Activa o desactiva el email al propietario. |
| `status` | `string` | `active` o `paused`. Reanudar rearma el cronograma (`next_check_at` se fija en el siguiente tick); pausar lo limpia para que el poller deje de reclamar la fila. |

Un cuerpo vacío (`{}`) se rechaza con `422` en lugar de ignorarse
silenciosamente. Ten en cuenta que aquí `status` solo acepta `active` o
`paused`. El estado terminal `deleted` se alcanza mediante `DELETE`,
nunca mediante `PATCH`. Reanudar un watcher pausado cuenta como agregar
un monitor activo, así que vuelve a comprobar el mismo límite de
`max_watchers` que la creación y puede devolver `402`.

---

## Modos de diff {: #diff-modes }

`diff_mode` elige cuál de las cuatro estrategias, sensibles al tipo de
contenido, ejecuta el motor. `auto` ejecuta las cuatro y combina sus
resultados; los modos con nombre restringen el diff a una sola estrategia.

| `diff_mode` | Estrategia | Qué señala |
|-------------|----------|---------------|
| `auto` (predeterminado) | Las cuatro siguientes | Todo tipo de cambio en una sola pasada. |
| `text` | Ratio `SequenceMatcher` del contenido principal | El texto del cuerpo cambió, se señala cuando la similitud cae por debajo de `0.98`, de modo que una palabra reordenada o un cambio de espacios en blanco no hace flapear al watcher. Incluye un diff unificado limitado a 100 líneas. |
| `structured` | Coincidencia de listas con clave | Elementos agregados / eliminados / modificados por campo entre enlaces (emparejados por `href`) y bloques JSON-LD (emparejados por `@type` + `name`). No depende del orden. |
| `tables` | Coincidencia por encabezado de contexto | Las tablas se emparejan por título/encabezado; reporta cambios en el número de filas, tablas agregadas/eliminadas, y ediciones de contenido con el mismo número de filas (limitado a 100 filas de contexto). |
| `metadata` | Comparación de diccionario clave por clave | Campos de metadatos de página agregados, eliminados y modificados. |

Sin importar qué modo elijas, el `similarity` del snapshot siempre es el
ratio general de toda la captura (0.0–1.0). En un modo restringido eso
significa que `similarity` puede mostrarse bajo mientras `has_changes` es
`false`, porque una sección que no estás comparando se movió mientras nada
cambió en la estrategia que elegiste.

### Rastrear un subconjunto de campos {: #tracking-a-subset-of-fields }

`track_fields` acota el diff a los cambios cuya sección, campo o clave
coincide con un término rastreado. Acepta un objeto cuyas claves, más
cualquier valor de lista, se convierten en los términos rastreados. Así,
`{"metadata": ["title"]}` rastrea tanto la sección `metadata` como el
campo `title`. La coincidencia es por token completo, no por subcadena:
un término `price` coincide con `offers.price` pero no con
`priceCurrency`.

---

## Respuesta

`POST`, `GET /v2/watch/{watcher_id}`, `PATCH` y `DELETE` devuelven todos
el mismo objeto watcher.

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `watcher_id` | `string` | ID opaco (`wat_...`). Úsalo en las rutas por watcher. |
| `url` | `string` | La URL monitoreada. |
| `status` | `string` | `active`, `paused`, o `deleted`. |
| `frequency_minutes` | `integer` | Cadencia actual, después del piso horario. |
| `diff_mode` | `string` | La estrategia de diff activa. |
| `track_fields` | `object` | El subconjunto de campos rastreados, o `null`. |
| `webhook_url` | `string` | El webhook de cambios, o `null`. |
| `notify_email` | `boolean` | Si el email al propietario está activo. |
| `consecutive_errors` | `integer` | Fallos de renderizado consecutivos. Se reinicia a `0` en una verificación exitosa; al llegar a `3` el watcher se auto-pausa. |
| `checks_count` | `integer` | Total de verificaciones ejecutadas, exitosas o fallidas. |
| `last_check_at` | `string` | Marca de tiempo UTC de la verificación más reciente, o `null`. |
| `next_check_at` | `string` | Marca de tiempo UTC de la próxima verificación programada. `null` mientras está pausado o eliminado. |
| `last_change_at` | `string` | Marca de tiempo UTC del cambio detectado más reciente, o `null`. |
| `created_at` | `string` | Cuándo se creó el watcher. |
| `updated_at` | `string` | Última modificación, o `null` si nunca se actualizó. |

`GET /v2/watch` devuelve un `WatcherSummary` compacto por fila (omite
`diff_mode`, `track_fields`, `webhook_url`, `notify_email` y
`updated_at`) envuelto en un sobre de paginación:

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `watchers` | `array` | La página de resúmenes, del más reciente al más antiguo. |
| `skip` | `integer` | El offset que solicitaste. |
| `limit` | `integer` | El tamaño de página en vigor. |
| `has_more` | `boolean` | `true` cuando existen más watchers más allá de esta página. |

### Historial de snapshots

`GET /v2/watch/{watcher_id}/snapshots` devuelve la línea de tiempo de
verificaciones del watcher, de la más reciente a la más antigua. Cada
entrada lleva el veredicto del diff. El cuerpo de la captura del
snapshot en sí vive en el almacenamiento y no se devuelve aquí.

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `checked_at` | `string` | Marca de tiempo UTC de la verificación. |
| `has_changes` | `boolean` | Si esta verificación detectó un cambio. |
| `similarity` | `number` | Ratio general de toda la captura, 0.0–1.0, o `null`. |
| `render_quality` | `number` | Puntuación de calidad de renderizado de la verificación, o `null`. |
| `change_count` | `integer` | Número de registros de cambio estructurados. |
| `changes` | `array` | El diff estructurado: un registro por cambio, cada uno con `section`, `kind` (`added`/`removed`/`modified`), `key`, `field`, `before`, `after`. |

> **Vale la pena aclarar.** Los valores `before` y `after` dentro de
> `changes` son contenido de página sin procesar (texto de enlaces,
> metadatos, valores JSON-LD), no una salida saneada. Si los renderizas
> en HTML (un dashboard, un email), debes escaparlos tú mismo. Los
> valores de cadena largos ya vienen truncados a 2,000 caracteres, y un
> solo diff está limitado a 500 registros de cambio.

---

## Ciclo de vida y programación

Un watcher atraviesa tres estados:

- **`active`:** en el cronograma del poller. `next_check_at` está
  definido.
- **`paused`:** fuera del cronograma (`next_check_at` es `null`). Se
  alcanza mediante `PATCH {"status": "paused"}` o automáticamente después
  de tres fallos de renderizado consecutivos.
- **`deleted`:** lápida terminal, alcanzada solo mediante `DELETE`. La
  fila se conserva (para que el historial de verificaciones sobreviva)
  pero nunca aparece en las listas y se lee como `404` en las rutas por
  watcher.

El piso horario se aplica en dos lugares: al crear/actualizar, y otra vez
por el planificador cuando avanza `next_check_at`. Así, incluso una fila
cuya cadencia se editó directamente en la base de datos nunca puede
superar el piso.

### Auto-pausa

Después de **tres fallos de renderizado consecutivos**, el watcher se
pone en `paused`, se limpia su cronograma, y el propietario recibe un
email de watcher pausado si `notify_email` está activo. Una sola
verificación exitosa reinicia `consecutive_errors` a `0`. Para reiniciar
un watcher pausado, hazle un `PATCH` de vuelta a `active`, lo cual rearma
`next_check_at` para el siguiente tick.

### Eliminación

`DELETE /v2/watch/{watcher_id}` es un soft-delete: cambia `status` a
`deleted`, limpia `next_check_at`, y devuelve el registro con lápida con
un `200`. Es idempotente, así que eliminar un watcher ya eliminado devuelve
el mismo registro sin cambios. Los watchers eliminados dejan de contar
contra tu límite de `max_watchers` de inmediato (solo cuentan los
watchers `active`).

---

## Ejemplos de código

### curl: crear con valores predeterminados

```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: cada 6 horas, webhook más rastreo de tablas

```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: pausar y luego reanudar

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

---

## Respuestas de error

| Estado | Condición |
|--------|-----------|
| `400 Bad Request` | La URL resuelve a una dirección privada, loopback, link-local o de metadatos (protección SSRF al crear). |
| `401 Unauthorized` | Falta la clave de API / token JWT, o es inválida. |
| `402 Payment Required` | Watch no está habilitado en tu plan, o tu número de watchers activos ha alcanzado `max_watchers` (también se aplica al reanudar de paused→active). |
| `403 Forbidden` | `/v2/watch` no está en los endpoints permitidos de la clave de API. |
| `404 Not Found` | `watcher_id` desconocido, uno perteneciente a otro proyecto, o uno ya eliminado (soft-delete). |
| `422 Unprocessable Entity` | `frequency_minutes` por debajo de `60` o por encima de `43200`, un cuerpo `PATCH` vacío (`{}`), un enum inválido en `diff_mode` o `status`, una `url`/`webhook_url` que no es `http(s)`, o cualquier campo desconocido. |
| `500 Internal Server Error` | No se pudo crear el watcher. Reinténtalo. |

La referencia completa de códigos de estado está en
[la guía de códigos de error](/es/docs/reference/errors.md).

---

## Límites

| Límite | Valor |
|-------|-------|
| Longitud de la URL | 2,048 caracteres |
| Watchers activos (`max_watchers`) | 20 / 100 / 500 en Indie / Studio / Production; watch no está disponible en Founding |
| Ops por comprobación | 0 (los watchers nunca consumen la cuota mensual de ops) |
| Longitud de `webhook_url` | 2,048 caracteres |
| `frequency_minutes` | 60–43,200 (1 hora a 30 días) |
| Piso horario | 60 minutos, aplicado al crear, actualizar y programar |
| Intervalo de sondeo | 60 segundos (una verificación se dispara dentro del minuto de su hora programada) |
| Errores consecutivos antes de auto-pausa | 3 |
| Piso de calidad de renderizado (sin diff por debajo) | 0.4 |
| Umbral de similitud para cambios de texto | 0.98 |
| Diff de texto unificado | limitado a 100 líneas |
| Registros de cambio por diff | limitado a 500 |
| Valor de cadena por cambio | truncado a 2,000 caracteres |
| Cuerpo de texto capturado comparado | limitado a 200,000 caracteres |
| Tamaño de página de lista / snapshot | predeterminado 20, máximo 100 |

---

## Preguntas frecuentes

### ¿Cómo configuro webhooks para el monitoreo de cambios en un sitio?

Pasa `webhook_url` al crear el watcher con `POST /v2/watch`, o agrégalo
después mediante `PATCH /v2/watch/{watcher_id}`. Cuando una verificación
detecta un cambio, EnConvert envía un POST firmado con HMAC a esa URL; el
destino se filtra contra SSRF justo antes de cada entrega, y una cadena
vacía en `PATCH` lo borra.

### ¿Con qué frecuencia puede la API verificar cambios en una página?

`frequency_minutes` define la cadencia, de `60` (el piso horario
estricto) a `43200` (30 días). El poller escanea cada 60 segundos, así
que una verificación se dispara dentro del minuto de su hora programada.

### ¿Por qué mi watcher se pausó solo?

Tres fallos de renderizado consecutivos auto-pausan un watcher, limpian
su cronograma y envían un email al propietario si `notify_email` está
activo. Hazle un `PATCH` de vuelta con `{"status": "active"}` para
rearmar `next_check_at`; una sola verificación exitosa reinicia
`consecutive_errors` a `0`.

### ¿Puedo monitorear solo parte de una página, como un precio o una tabla?

Sí. `track_fields` acota el diff a los cambios cuya sección, campo o
clave coincide con un término rastreado (coincidencia por token
completo, así que `price` coincide con `offers.price` pero no con
`priceCurrency`), y `diff_mode` puede restringir la detección a una sola
estrategia como `tables`, `structured`, `text`, o `metadata`.
