Watch
POST /v2/watch convierte cualquier URL en un monitor de cambios. Registras una página
una vez; 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
—por webhook, correo o ambos— cuando la página cambia de verdad. Reemplaza
la maquinaria de cron, comparación y alertas que de otro modo tendrías
que montar alrededor de el endpoint perceive: un
solo registro en lugar de un planificador, un bucket de almacenamiento y un script de comparación.
Esta es la llamada útil más pequeña. Envía una URL y recibe de vuelta un watcher programado para comprobar cada hora:
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"
}'
La respuesta es el registro completo del watcher. Está active de inmediato, y
next_check_at se fija en el siguiente tick del poller:
{
"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
}
Conviene señalarlo de entrada.
/v2/watches un endpoint V2 con su propia verificación de plan, separada de las conversiones V1. Cuántos watchers activos puedes mantener a la vez está limitado pormax_watchersen tu plan, y un plan con watch deshabilitado no puede crear ninguno en absoluto: ambas denegaciones vuelven como402. Consulta la página de precios para ver las asignaciones actuales por nivel.
Endpoints
| Method | Path | Propósito |
|---|---|---|
POST |
/v2/watch |
Crea un watcher para una URL. Devuelve 201. |
GET |
/v2/watch |
Lista los watchers de este proyecto, los más recientes primero. |
GET |
/v2/watch/{watcher_id} |
Obtiene el registro completo de un watcher. |
GET |
/v2/watch/{watcher_id}/snapshots |
Lista el historial de comprobaciones de un watcher, los más recientes primero. |
PATCH |
/v2/watch/{watcher_id} |
Actualiza la cadencia, los ajustes de diff o pausa/reanuda. |
DELETE |
/v2/watch/{watcher_id} |
Borrado lógico de un watcher (idempotente). |
Content-Type: application/json en POST y PATCH.
Autenticación
Autentícate con una clave privada en la cabecera X-API-Key para
llamadas de servidor a servidor. Esta es la vía que usa cada ejemplo de abajo.
X-API-Key: sk_live_your_private_key
Las claves públicas con un token bearer JWT también funcionan, usando el mismo flujo que
cualquier otro endpoint: genera un token con tu clave pk_ y luego envíalo
como Authorization: Bearer <token>. El flujo completo, incluyendo el bloqueo de dominio
y la renovación de tokens, está en la guía de autenticación.
Cada clave de API lleva una lista 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 alcanzar las rutas por watcher que posee:
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 en la lista por separado.
Cómo funciona watch
No hay ninguna cola externa detrás de esto: ni Google Cloud Tasks, ni
un planificador de terceros. La programación vive enteramente en la columna de base de datos
next_check_at, y un poller en proceso la impulsa:
- Create. Haces un
POSTde una URL. La URL se filtra por SSRF (un host privado, de loopback o de metadatos se rechaza antes de escribir ninguna fila), se acuña un IDwat_, y el watcher se almacena comoactiveconnext_check_atfijado en el momento actual. - Claim. Un
watch_workerlocal del droplet escanea cada 60 segundos en busca de filas activas cuyonext_check_atya haya pasado. Las reclama bajoFOR UPDATE SKIP LOCKEDy avanza la programación de cada una un intervalo completo en la misma transacción: así un renderizado lento nunca se reclama dos veces, y un fallo a mitad de renderizado simplemente salta un ciclo. - Render. 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 perceive. El renderizado es sin credenciales: no se almacena auth, cookies ni cabeceras, así que no queda nada secreto en reposo para la comprobación recurrente.
- Score and diff. Un renderizado que puntúa por debajo del umbral mínimo de calidad (0.4) o marcado como bloqueado se registra como una comprobación solo de auditoría: sin hash de contenido, así que nunca se convierte en una línea base de diff ni dispara una notificación. Un buen renderizado se convierte en una captura (texto del contenido principal más estructura extraída), se compara con la última captura buena, y el veredicto se escribe en una fila de snapshot.
- Notify. Cuando el diff reporta un cambio, el webhook firmado con HMAC (si
está configurado) y el correo del propietario (si
notify_emailestá activado) se disparan de forma concurrente, con mejor esfuerzo. - Reschedule. El worker escribe el siguiente
next_check_at. Tres fallos de renderizado consecutivos pausan el watcher y envían un correo al propietario en lugar de reprogramar.
Como la programación es una columna de base de datos, el tiempo de inactividad no necesita lógica de recuperación: el primer tick tras el arranque barre todo lo que esté vencido.
Parámetros de la solicitud
Create (POST /v2/watch)
| Parameter | Type | Default | Descripción |
|---|---|---|---|
url |
string |
— | La página a monitorizar. Debe empezar por http:// o https://. Máximo 2.048 caracteres. Obligatorio. |
frequency_minutes |
integer |
60 |
Minutos entre comprobaciones. Suelo 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. |
track_fields |
object |
null |
Subconjunto opcional de campo/selector para acotar qué cuenta como cambio. Consulta Rastrear un subconjunto de campos. |
webhook_url |
string |
null |
Destino opcional de notificación de cambios. Firmado con HMAC, filtrado por SSRF justo antes de cada entrega. Máximo 2.048 caracteres; debe ser http(s). |
notify_email |
boolean |
true |
Envía correo al propietario del proyecto ante un cambio detectado y ante una autopausa. |
El esquema de la solicitud es estricto (extra="forbid"): un campo desconocido se
rechaza con 422. Deliberadamente no hay aquí superficie de auth, cookies ni
headers: los watchers se mantienen sin credenciales, la misma postura que
el endpoint ingest.
Update (PATCH /v2/watch/{watcher_id})
Cada campo es opcional; solo se aplican las claves presentes en el cuerpo.
| Parameter | Type | Descripción |
|---|---|---|
frequency_minutes |
integer |
Nueva cadencia. Los mismos límites 60–43200 que en create. |
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 "bórralo" y almacena NULL. |
notify_email |
boolean |
Alterna el correo al propietario. |
status |
string |
active o paused. Reanudar rearma la programación (next_check_at se fija en el siguiente tick); pausar la borra para que el poller deje de reclamar la fila. |
Un cuerpo vacío ({}) se rechaza con 422 en lugar de tratarse como un no-op silencioso.
Ten en cuenta que status aquí solo acepta active o paused: el estado terminal
deleted se alcanza mediante DELETE, nunca con PATCH. Reanudar un
watcher en pausa cuenta como añadir un monitor activo, así que vuelve a verificar el mismo
límite max_watchers que en create y puede devolver 402.
Modos de diff
diff_mode elige cuál de las cuatro estrategias conscientes del tipo de contenido ejecuta el motor.
auto ejecuta las cuatro y fusiona sus hallazgos; los modos con nombre
restringen el diff a una sola estrategia.
diff_mode |
Estrategia | Qué señala |
|---|---|---|
auto (por defecto) |
Las cuatro de abajo | Todo tipo de cambio en una sola pasada. |
text |
Ratio de SequenceMatcher del contenido principal |
Cambió el texto del cuerpo: se señala cuando la similitud baja de 0.98, así que reordenar una palabra o editar espacios en blanco no hace oscilar el watcher. Lleva un diff unificado limitado a 100 líneas. |
structured |
Coincidencia de listas con clave | Elementos añadidos / eliminados / modificados por campo a través de enlaces (emparejados por href) y bloques JSON-LD (emparejados por @type + name). Insensible al orden. |
tables |
Coincidencia por encabezado de contexto | Tablas emparejadas por título/encabezado; reporta cambios en el número de filas, tablas añadidas/eliminadas y ediciones de contenido con igual número de filas (acotado a 100 filas de contexto). |
metadata |
Comparación de diccionario clave por clave | Campos de metadatos de página añadidos, eliminados y modificados. |
Sea cual sea el modo que elijas, la similarity del snapshot es siempre el ratio
global de la captura completa (0.0–1.0). Bajo un modo restringido eso significa que
similarity puede leerse baja mientras has_changes es false: una sección que
no estás comparando se desvió, pero nada en la estrategia que elegiste cambió.
Rastrear un subconjunto de campos
track_fields acota el diff a los cambios cuya sección, campo o clave
coincide con un término rastreado. Acepta un objeto: sus 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.
| Field | Type | Descripción |
|---|---|---|
watcher_id |
string |
ID opaco (wat_...). Úsalo en las rutas por watcher. |
url |
string |
La URL monitorizada. |
status |
string |
active, paused o deleted. |
frequency_minutes |
integer |
Cadencia actual, tras el suelo 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 correo al propietario está activado. |
consecutive_errors |
integer |
Fallos de renderizado seguidos. Se reinicia a 0 en una comprobación exitosa; en 3 el watcher se autopausa. |
checks_count |
integer |
Total de comprobaciones ejecutadas, exitosas o fallidas. |
last_check_at |
string |
Marca de tiempo UTC de la comprobación más reciente, o null. |
next_check_at |
string |
Marca de tiempo UTC de la siguiente comprobación programada. null mientras está en pausa 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 mutació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 página:
| Field | Type | Descripción |
|---|---|---|
watchers |
array |
La página de resúmenes, los más recientes primero. |
skip |
integer |
El desplazamiento que solicitaste. |
limit |
integer |
El tamaño de página en efecto. |
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 comprobaciones del watcher,
los más recientes primero. Cada entrada lleva el veredicto del diff: el cuerpo de la captura
del snapshot en sí vive en almacenamiento y no se devuelve aquí.
| Field | Type | Descripción |
|---|---|---|
checked_at |
string |
Marca de tiempo UTC de la comprobación. |
has_changes |
boolean |
Si esta comprobación detectó un cambio. |
similarity |
number |
Ratio global de la captura completa, 0.0–1.0, o null. |
render_quality |
number |
Puntuación de calidad de renderizado de la comprobació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. |
Conviene señalarlo. Los valores
beforeyafterdentro dechangesson contenido de página crudo (texto de enlace, metadatos, valores JSON-LD), no salida saneada. Si los renderizas en HTML —un dashboard, un correo— debes escaparlos tú mismo. Los valores de cadena largos ya están truncados a 2.000 caracteres, y un solo diff está limitado a 500 registros de cambio.
Ciclo de vida y programación
Un watcher pasa por tres estados:
active— en la programación del poller.next_check_atestá fijado.paused— fuera de la programación (next_check_atesnull). Se alcanza ya sea conPATCH {"status": "paused"}o automáticamente tras tres fallos de renderizado consecutivos.deleted— lápida terminal, alcanzada solo víaDELETE. La fila se conserva (para que sobreviva el historial de comprobaciones) pero nunca aparece en listas y se lee como404en las rutas por watcher.
El suelo horario se aplica en dos lugares: en create/update, y de nuevo por
el planificador cuando avanza next_check_at. Así que incluso una fila cuya cadencia
se editó directamente en la base de datos nunca puede superar el suelo.
Autopausa
Tras tres fallos de renderizado consecutivos, el watcher se pone en paused,
su programación se borra y —si notify_email está activado— el propietario recibe un
correo de watcher en pausa. Una sola comprobación exitosa reinicia consecutive_errors
a 0. Para reiniciar un watcher en pausa, hazle un PATCH de vuelta a active, lo que
rearma next_check_at para el siguiente tick.
Eliminación
DELETE /v2/watch/{watcher_id} es un borrado lógico: cambia status a
deleted, borra next_check_at, y devuelve el registro convertido en lápida con un
200. Es idempotente: eliminar un watcher ya eliminado devuelve el
mismo registro sin cambios. Los watchers eliminados dejan de contar contra tu
límite max_watchers de inmediato (solo los watchers active cuentan).
Ejemplos de código
curl — crear con valores por defecto
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 — cada 6 horas, webhook + rastreo de tablas
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 — pausar, luego reanudar
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);
Verificación de plan
Watch está limitado de dos maneras, ambas separadas de tu cuota de conversiones V1:
watch_enabled— una bandera booleana del plan. Un plan sin ella no puede crear un watcher en absoluto (402).max_watchers— el límite de cuántos watchersactivepuede mantener un proyecto a la vez. Los watchers en pausa y eliminados no cuentan. Un valor de0con la bandera activada significa ilimitado (enterprise/admin).
Crear un watcher no consume una conversión ni una operación V2: watch
está limitado por cuántos watchers mantienes, no por un contador por llamada. Como
cada watcher comprueba con una cadencia de una hora o más lenta, el volumen total de comprobaciones
de un proyecto está acotado por max_watchers y el suelo horario juntos. Las asignaciones
actuales de max_watchers por plan están en la página de precios.
Respuestas de error
| Status | Condición |
|---|---|
400 Bad Request |
La URL resuelve a una dirección privada, de loopback, de enlace local o de metadatos (protección SSRF en el momento de la creación). |
401 Unauthorized |
Clave de API / token JWT ausente o inválido. |
402 Payment Required |
Watch no está habilitado en tu plan, o tu recuento de watchers activos ha alcanzado max_watchers (también se aplica en una reanudación paused→active). |
403 Forbidden |
/v2/watch no está en los endpoints permitidos de la clave de API. |
404 Not Found |
watcher_id desconocido, uno propiedad de otro proyecto, o uno ya borrado lógicamente. |
422 Unprocessable Entity |
frequency_minutes por debajo de 60 o por encima de 43200, un cuerpo PATCH vacío ({}), un enum incorrecto en diff_mode o status, una url/webhook_url que no es http(s), o cualquier campo desconocido. |
500 Internal Server Error |
El watcher no se pudo crear. Reintenta. |
La referencia completa de códigos de estado está en la guía de códigos de error.
Límites
| Límite | Valor |
|---|---|
| Longitud de URL | 2.048 caracteres |
Longitud de webhook_url |
2.048 caracteres |
frequency_minutes |
60–43.200 (1 hora a 30 días) |
| Suelo horario | 60 minutos, aplicado en create, update y programación |
| Intervalo de sondeo | 60 segundos (una comprobación se dispara dentro de un minuto de su hora programada) |
| Errores consecutivos antes de autopausa | 3 |
| Suelo de calidad de renderizado (sin diff por debajo) | 0.4 |
| Umbral de similitud de cambio de texto | 0.98 |
| Diff unificado de texto | 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 / snapshots | por defecto 20, máximo 100 |
| Watchers activos por proyecto | dependiente del plan (max_watchers) |